diff options
Diffstat (limited to 'remote/test/puppeteer')
337 files changed, 48736 insertions, 0 deletions
diff --git a/remote/test/puppeteer/.ci/node10/Dockerfile.linux b/remote/test/puppeteer/.ci/node10/Dockerfile.linux new file mode 100644 index 0000000000..47b1478a44 --- /dev/null +++ b/remote/test/puppeteer/.ci/node10/Dockerfile.linux @@ -0,0 +1,17 @@ +FROM node:10 + +RUN apt-get update && \ + apt-get -y install xvfb gconf-service libasound2 libatk1.0-0 libc6 libcairo2 libcups2 \ + libdbus-1-3 libexpat1 libfontconfig1 libgbm1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 \ + libgtk-3-0 libnspr4 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 \ + libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 \ + libxtst6 ca-certificates fonts-liberation libappindicator1 libnss3 lsb-release xdg-utils wget && \ + rm -rf /var/lib/apt/lists/* + +# Add user so we don't need --no-sandbox. +RUN groupadd -r pptruser && useradd -r -g pptruser -G audio,video pptruser \ + && mkdir -p /home/pptruser/Downloads \ + && chown -R pptruser:pptruser /home/pptruser + +# Run everything after as non-privileged user. +USER pptruser diff --git a/remote/test/puppeteer/.ci/node12/Dockerfile.linux b/remote/test/puppeteer/.ci/node12/Dockerfile.linux new file mode 100644 index 0000000000..0a9a0acc93 --- /dev/null +++ b/remote/test/puppeteer/.ci/node12/Dockerfile.linux @@ -0,0 +1,17 @@ +FROM node:12 + +RUN apt-get update && \ + apt-get -y install xvfb gconf-service libasound2 libatk1.0-0 libc6 libcairo2 libcups2 \ + libdbus-1-3 libexpat1 libfontconfig1 libgbm1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 \ + libgtk-3-0 libnspr4 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 \ + libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 \ + libxtst6 ca-certificates fonts-liberation libappindicator1 libnss3 lsb-release xdg-utils wget && \ + rm -rf /var/lib/apt/lists/* + +# Add user so we don't need --no-sandbox. +RUN groupadd -r pptruser && useradd -r -g pptruser -G audio,video pptruser \ + && mkdir -p /home/pptruser/Downloads \ + && chown -R pptruser:pptruser /home/pptruser + +# Run everything after as non-privileged user. +USER pptruser diff --git a/remote/test/puppeteer/.editorconfig b/remote/test/puppeteer/.editorconfig new file mode 100644 index 0000000000..c6c8b36219 --- /dev/null +++ b/remote/test/puppeteer/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/remote/test/puppeteer/.eslintignore b/remote/test/puppeteer/.eslintignore new file mode 100644 index 0000000000..f4c17fcc0f --- /dev/null +++ b/remote/test/puppeteer/.eslintignore @@ -0,0 +1,14 @@ +test/assets/modernizr.js +third_party/* +utils/browser/puppeteer-web.js +utils/doclint/check_public_api/test/ +node6/* +node6-test/* +experimental/ +lib/ +/index.d.ts +# We ignore this file because it uses ES imports which we don't yet use +# in the Puppeteer src, so it trips up the ESLint-TypeScript parser. +utils/doclint/generate_types/test/test.ts +vendor/ +web-test-runner.config.mjs diff --git a/remote/test/puppeteer/.eslintrc.js b/remote/test/puppeteer/.eslintrc.js new file mode 100644 index 0000000000..a1de9853ee --- /dev/null +++ b/remote/test/puppeteer/.eslintrc.js @@ -0,0 +1,183 @@ +module.exports = { + root: true, + env: { + node: true, + es6: true, + }, + + parser: '@typescript-eslint/parser', + + plugins: ['mocha', '@typescript-eslint', 'unicorn', 'import'], + + extends: ['plugin:prettier/recommended'], + + rules: { + // Error if files are not formatted with Prettier correctly. + 'prettier/prettier': 2, + // syntax preferences + quotes: [ + 2, + 'single', + { + avoidEscape: true, + allowTemplateLiterals: true, + }, + ], + 'spaced-comment': [ + 2, + 'always', + { + markers: ['*'], + }, + ], + eqeqeq: [2], + 'accessor-pairs': [ + 2, + { + getWithoutSet: false, + setWithoutGet: false, + }, + ], + 'new-parens': 2, + 'func-call-spacing': 2, + 'prefer-const': 2, + + 'max-len': [ + 2, + { + /* this setting doesn't impact things as we use Prettier to format + * our code and hence dictate the line length. + * Prettier aims for 80 but sometimes makes the decision to go just + * over 80 chars as it decides that's better than wrapping. ESLint's + * rule defaults to 80 but therefore conflicts with Prettier. So we + * set it to something far higher than Prettier would allow to avoid + * it causing issues and conflicting with Prettier. + */ + code: 200, + comments: 90, + ignoreTemplateLiterals: true, + ignoreUrls: true, + ignoreStrings: true, + ignoreRegExpLiterals: true, + }, + ], + // anti-patterns + 'no-var': 2, + 'no-with': 2, + 'no-multi-str': 2, + 'no-caller': 2, + 'no-implied-eval': 2, + 'no-labels': 2, + 'no-new-object': 2, + 'no-octal-escape': 2, + 'no-self-compare': 2, + 'no-shadow-restricted-names': 2, + 'no-cond-assign': 2, + 'no-debugger': 2, + 'no-dupe-keys': 2, + 'no-duplicate-case': 2, + 'no-empty-character-class': 2, + 'no-unreachable': 2, + 'no-unsafe-negation': 2, + radix: 2, + 'valid-typeof': 2, + 'no-unused-vars': [ + 2, + { + args: 'none', + vars: 'local', + varsIgnorePattern: + '([fx]?describe|[fx]?it|beforeAll|beforeEach|afterAll|afterEach)', + }, + ], + 'no-implicit-globals': [2], + + // es2015 features + 'require-yield': 2, + 'template-curly-spacing': [2, 'never'], + + // ensure we don't have any it.only or describe.only in prod + 'mocha/no-exclusive-tests': 'error', + + // enforce the variable in a catch block is named error + 'unicorn/catch-error-name': 'error', + + 'no-restricted-imports': [ + 'error', + { + patterns: ['*Events'], + paths: [ + { + name: 'mitt', + message: + 'Import Mitt from the vendored location: vendor/mitt/src/index.js', + }, + ], + }, + ], + 'import/extensions': ['error', 'ignorePackages'], + }, + overrides: [ + { + files: ['*.ts'], + extends: [ + 'plugin:@typescript-eslint/eslint-recommended', + 'plugin:@typescript-eslint/recommended', + ], + rules: { + 'no-unused-vars': 0, + '@typescript-eslint/no-unused-vars': 2, + 'func-call-spacing': 0, + '@typescript-eslint/func-call-spacing': 2, + semi: 0, + '@typescript-eslint/semi': 2, + '@typescript-eslint/no-empty-function': 0, + '@typescript-eslint/no-use-before-define': 0, + // We have to use any on some types so the warning isn't valuable. + '@typescript-eslint/no-explicit-any': 0, + // We don't require explicit return types on basic functions or + // dummy functions in tests, for example + '@typescript-eslint/explicit-function-return-type': 0, + // We know it's bad and use it very sparingly but it's needed :( + '@typescript-eslint/ban-ts-ignore': 0, + /** + * This is the default options (as per + * https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/ban-types.md), + * + * Unfortunately there's no way to + */ + '@typescript-eslint/ban-types': [ + 'error', + { + extendDefaults: true, + types: { + /* + * Puppeteer's API accepts generic functions in many places so it's + * not a useful linting rule to ban the `Function` type. This turns off + * the banning of the `Function` type which is a default rule. + */ + Function: false, + }, + }, + ], + '@typescript-eslint/array-type': [ + 2, + { + default: 'array-simple', + }, + ], + }, + }, + { + files: ['test-browser/**/*.js'], + parserOptions: { + sourceType: 'module', + }, + env: { + es6: true, + browser: true, + es2020: true, + }, + }, + ], +}; diff --git a/remote/test/puppeteer/.github/workflows/publish-on-tag.yml b/remote/test/puppeteer/.github/workflows/publish-on-tag.yml new file mode 100644 index 0000000000..b16c1be3fb --- /dev/null +++ b/remote/test/puppeteer/.github/workflows/publish-on-tag.yml @@ -0,0 +1,30 @@ +name: publish-on-tag + +on: + push: + tags: + - '*' + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Install dependencies + run: npm install + - name: Build + run: npm run build + - name: Publish puppeteer + env: + NPM_TOKEN: ${{secrets.NPM_TOKEN_PUPPETEER}} + run: | + npm config set '//wombat-dressing-room.appspot.com/:_authToken' '${NPM_TOKEN}' + npm publish + - name: Publish puppeteer-core + env: + NPM_TOKEN: ${{secrets.NPM_TOKEN_PUPPETEER_CORE}} + run: | + utils/prepare_puppeteer_core.js + npm config set '//wombat-dressing-room.appspot.com/:_authToken' '${NPM_TOKEN}' + npm publish diff --git a/remote/test/puppeteer/.npmrc b/remote/test/puppeteer/.npmrc new file mode 100644 index 0000000000..f4335b67c1 --- /dev/null +++ b/remote/test/puppeteer/.npmrc @@ -0,0 +1,2 @@ +registry=https://wombat-dressing-room.appspot.com/ +access=public diff --git a/remote/test/puppeteer/.travis.yml b/remote/test/puppeteer/.travis.yml new file mode 100644 index 0000000000..3dccb9aced --- /dev/null +++ b/remote/test/puppeteer/.travis.yml @@ -0,0 +1,118 @@ +language: node_js +services: xvfb + +# Throughout this file, the following `node_js` versions are being used: +# +# - node_js: '10' # The maintenance LTS version. +# - node_js: '12' # The oldest major active LTS version. +# - node_js: '14' # The newest major active LTS version. + +jobs: + include: + - os: 'osx' + name: 'Unit tests: macOS/Chromium' + node_js: '10' # The maintenance LTS version. + osx_image: xcode11.4 + env: + - CHROMIUM=true + before_install: + - PUPPETEER_PRODUCT=firefox npm install + script: + - ls .local-chromium .local-firefox + - npm run tsc + - npm run unit + + - os: 'windows' + name: 'Unit tests: Windows/Chromium' + node_js: '10' # The maintenance LTS version. + env: + - CHROMIUM=true + before_install: + - PUPPETEER_PRODUCT=firefox npm install + script: + - ls .local-chromium .local-firefox + - npm run tsc + - travis_retry npm run unit + + # Node <10.17's fs.promises module was experimental and doesn't behave as + # expected. This problem was fixed in Node 10.19, but we run the unit tests + # through on 10.15 to make sure we don't cause any regressions when using + # fs.promises. See https://github.com/puppeteer/puppeteer/issues/6548 for an + # example. + - node_js: '10.15.0' + name: 'Node 10.15 Unit tests: Linux/Chromium' + env: + - CHROMIUM=true + before_install: + - PUPPETEER_PRODUCT=firefox npm install + script: + - npm run unit + + - node_js: '10' # The maintenance LTS version. + name: 'Unit tests [with coverage]: Linux/Chromium' + env: + - CHROMIUM=true + before_install: + - PUPPETEER_PRODUCT=firefox npm install + script: + - travis_retry npm run unit-with-coverage + - npm run assert-unit-coverage + + - node_js: '12' # The oldest major active LTS version. + name: 'Unit tests [Node 12]: Linux/Chromium' + env: + - CHROMIUM=true + before_install: + - PUPPETEER_PRODUCT=firefox npm install + script: + - npm run unit + + - node_js: '14' # The newest major active LTS version. + name: 'Unit tests [Node 14]: Linux/Chromium' + env: + - CHROMIUM=true + before_install: + - PUPPETEER_PRODUCT=firefox npm install + script: + - npm run unit + + - node_js: '12' # The oldest major active LTS version. + name: 'Browser tests: Linux/Chromium' + addons: + chrome: stable + env: + - CHROMIUM=true + script: + - npm run test-browser + + # This bot runs all the extra checks that aren't the main Puppeteer unit tests. + - node_js: '10' # The maintenance LTS version. + name: 'Extra tests: Linux/Chromium' + env: + - CHROMIUM=true + script: + - npm run lint + # Ensure that we can generate the new docs without erroring + - npm run generate-docs + - npm run ensure-correct-devtools-protocol-revision + + # This bot runs separately as it changes package.json to test puppeteer-core + # and we don't want that leaking into other bots and causing issues. + - node_js: '10' # The maintenance LTS version. + name: 'Test bundling and install of packages' + env: + - CHROMIUM=true + script: + - npm run test-install + + - node_js: '10' # The maintenance LTS version. + name: 'Unit tests: Linux/Firefox' + env: + - FIREFOX=true + before_install: + - PUPPETEER_PRODUCT=firefox npm install + script: + - npm run funit + +notifications: + email: false diff --git a/remote/test/puppeteer/.versionrc b/remote/test/puppeteer/.versionrc new file mode 100644 index 0000000000..a0247ed4ab --- /dev/null +++ b/remote/test/puppeteer/.versionrc @@ -0,0 +1,3 @@ +{ + "releaseCommitMessageFormat": "chore(release): mark v{{currentTag}}" +} diff --git a/remote/test/puppeteer/CHANGELOG.md b/remote/test/puppeteer/CHANGELOG.md new file mode 100644 index 0000000000..35e3a06ddb --- /dev/null +++ b/remote/test/puppeteer/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. + +## [5.5.0](https://github.com/puppeteer/puppeteer/compare/v5.4.1...v5.5.0) (2020-11-16) + + +### Features + +* **chromium:** roll Chromium to r818858 ([#6526](https://github.com/puppeteer/puppeteer/issues/6526)) ([b549256](https://github.com/puppeteer/puppeteer/commit/b54925695200cad32f470f8eb407259606447a85)) + + +### Bug Fixes + +* **common:** fix generic type of `_isClosedPromise` ([#6579](https://github.com/puppeteer/puppeteer/issues/6579)) ([122f074](https://github.com/puppeteer/puppeteer/commit/122f074f92f47a7b9aa08091851e51a07632d23b)) +* **domworld:** fix missing binding for waittasks ([#6562](https://github.com/puppeteer/puppeteer/issues/6562)) ([67da1cf](https://github.com/puppeteer/puppeteer/commit/67da1cf866703f5f581c9cce4923697ac38129ef)) + +# Changelog + +All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. diff --git a/remote/test/puppeteer/CONTRIBUTING.md b/remote/test/puppeteer/CONTRIBUTING.md new file mode 100644 index 0000000000..54bfb306ed --- /dev/null +++ b/remote/test/puppeteer/CONTRIBUTING.md @@ -0,0 +1,290 @@ +<!-- gen:toc --> +- [How to Contribute](#how-to-contribute) + * [Contributor License Agreement](#contributor-license-agreement) + * [Getting Code](#getting-code) + * [Code reviews](#code-reviews) + * [Code Style](#code-style) + * [TypeScript guidelines](#typescript-guidelines) + * [Project structure and TypeScript compilation](#project-structure-and-typescript-compilation) + - [Shipping CJS and ESM bundles](#shipping-cjs-and-esm-bundles) + - [tsconfig for the tests](#tsconfig-for-the-tests) + - [Root `tsconfig.json`](#root-tsconfigjson) + * [API guidelines](#api-guidelines) + * [Commit Messages](#commit-messages) + * [Writing Documentation](#writing-documentation) + * [Adding New Dependencies](#adding-new-dependencies) + * [Running & Writing Tests](#running--writing-tests) + * [Public API Coverage](#public-api-coverage) + * [Debugging Puppeteer](#debugging-puppeteer) +- [For Project Maintainers](#for-project-maintainers) + * [Rolling new Chromium version](#rolling-new-chromium-version) + - [Bisecting upstream changes](#bisecting-upstream-changes) + * [Releasing to npm](#releasing-to-npm) +<!-- gen:stop --> + +# How to Contribute + +First of all, thank you for your interest in Puppeteer! +We'd love to accept your patches and contributions! + +## Contributor License Agreement + +Contributions to this project must be accompanied by a Contributor License +Agreement. You (or your employer) retain the copyright to your contribution, +this simply gives us permission to use and redistribute your contributions as +part of the project. Head over to <https://cla.developers.google.com/> to see +your current agreements on file or to sign a new one. + +You generally only need to submit a CLA once, so if you've already submitted one +(even if it was for a different project), you probably don't need to do it +again. + +## Getting Code + +1. Clone this repository + +```bash +git clone https://github.com/puppeteer/puppeteer +cd puppeteer +``` + +2. Install dependencies + +```bash +npm install +``` + +3. Run Puppeteer tests locally. For more information about tests, read [Running & Writing Tests](#running--writing-tests). + +```bash +npm run unit +``` + +## Code reviews + +All submissions, including submissions by project members, require review. We +use GitHub pull requests for this purpose. Consult +[GitHub Help](https://help.github.com/articles/about-pull-requests/) for more +information on using pull requests. + +## Code Style + +- Coding style is fully defined in [`.eslintrc`](https://github.com/puppeteer/puppeteer/blob/main/.eslintrc.js) and we automatically format our code with [Prettier](https://prettier.io). +- It's recommended to set-up Prettier into your editor, or you can run `npm run eslint-fix` to automatically format any files. +- If you're working in a JS file, code should be annotated with [closure annotations](https://github.com/google/closure-compiler/wiki/Annotating-JavaScript-for-the-Closure-Compiler). +- If you're working in a TS file, you should explicitly type all variables and return types. You'll get ESLint warnings if you don't so if you're not sure use them as guidelines, and feel free to ask us for help! + +To run ESLint, use: + +```bash +npm run eslint +``` + +You can check your code (both JS & TS) type-checks by running: + +```bash +npm run tsc +``` + +## TypeScript guidelines + +- Try to avoid the use of `any` when possible. Consider `unknown` as a better alternative. You are able to use `any` if needbe, but it will generate an ESLint warning. + +## Project structure and TypeScript compilation + +The code in Puppeteer is split primarily into two folders: + +- `src` contains all source code +- `vendor` contains all dependencies that we've vendored into the codebase. See the [`vendor/README.md`](https://github.com/puppeteer/puppeteer/blob/main/vendor/README.md) for details. + +We structure these using TypeScript's project references, which lets us treat each folder like a standalone TypeScript project. + +### Shipping CJS and ESM bundles + +Currently Puppeteer ships two bundles; a CommonJS version for Node and an ESM bundle for the browser. Therefore we maintain two `tsconfig` files for each project; `tsconfig.esm.json` and `tsconfig.cjs.json`. At build time we compile twice, once outputting to CJS and another time to output to ESM. + +We compile into the `lib` directory which is what we publish on the npm repository and it's structured like so: + +``` +lib +- cjs + - puppeteer <== the output of compiling `src/tsconfig.cjs.json` + - vendor <== the output of compiling `vendor/tsconfig.cjs.json` +- esm + - puppeteer <== the output of compiling `src/tsconfig.esm.json` + - vendor <== the output of compiling `vendor/tsconfig.esm.json` +``` + +The main entry point for the Node module Puppeteer is `cjs-entry.js`. This imports `lib/cjs/puppeteer/index.js` and exposes it to Node users. + +### tsconfig for the tests + +We also maintain `test/tsconfig.test.json`. This is **only used to compile the unit test `*.spec.ts` files**. When the tests are run, we first compile Puppeteer as normal before running the unit tests **against the compiled output**. Doing this lets the test run against the compiled code we ship to users so it gives us more confidence in our compiled output being correct. + +### Root `tsconfig.json` + +The root `tsconfig.json` exists for the API Extractor; it has to find a `tsconfig.json` in the project's root directory. It is _not_ used for anything else. + + +## API guidelines + +When authoring new API methods, consider the following: + +- Expose as little information as needed. When in doubt, don’t expose new information. +- Methods are used in favor of getters/setters. + - The only exception is namespaces, e.g. `page.keyboard` and `page.coverage` +- All string literals must be small case. This includes event names and option values. +- Avoid adding "sugar" API (API that is trivially implementable in user-space) unless they're **very** demanded. + +## Commit Messages + +Commit messages should follow [the Conventional Commits format](https://www.conventionalcommits.org/en/v1.0.0/#summary). This is enforced via `npm run lint`. + +In particular, breaking changes should clearly be noted as “BREAKING CHANGE:” in the commit message footer. Example: + +``` +fix(page): fix page.pizza method + +This patch fixes page.pizza so that it works with iframes. + +Issues: #123, #234 + +BREAKING CHANGE: page.pizza now delivers pizza at home by default. +To deliver to a different location, use the "deliver" option: + `page.pizza({deliver: 'work'})`. +``` + +## Writing Documentation + +All public API should have a descriptive entry in [`docs/api.md`](https://github.com/puppeteer/puppeteer/blob/main/docs/api.md). There's a [documentation linter](https://github.com/puppeteer/puppeteer/tree/main/utils/doclint) which makes sure documentation is aligned with the codebase. + +To run the documentation linter, use: + +```bash +npm run doc +``` + +## Adding New Dependencies + +For all dependencies (both installation and development): +- **Do not add** a dependency if the desired functionality is easily implementable. +- If adding a dependency, it should be well-maintained and trustworthy. + +A barrier for introducing new installation dependencies is especially high: +- **Do not add** installation dependency unless it's critical to project success. + +There are additional considerations for dependencies that are environment agonistic. See the [`vendor/README.md`](https://github.com/puppeteer/puppeteer/blob/main/vendor/README.md) for details. + + +## Running & Writing Tests + +- Every feature should be accompanied by a test. +- Every public api event/method should be accompanied by a test. +- Tests should not depend on external services. +- Tests should work on all three platforms: Mac, Linux and Win. This is especially important for screenshot tests. + +Puppeteer tests are located in the test directory ([`test`](https://github.com/puppeteer/puppeteer/blob/main/test/) and are written using Mocha. See [`test/README.md`](https://github.com/puppeteer/puppeteer/blob/main/test/) for more details. + +Despite being named 'unit', these are integration tests, making sure public API methods and events work as expected. + +- To run all tests: + +```bash +npm run unit +``` + +- To run a specific test, substitute the `it` with `it.only`: + +```js + ... + it.only('should work', async function({server, page}) { + const response = await page.goto(server.EMPTY_PAGE); + expect(response.ok).toBe(true); + }); +``` + +- To disable a specific test, substitute the `it` with `xit` (mnemonic rule: '*cross it*'): + +```js + ... + // Using "xit" to skip specific test + xit('should work', async function({server, page}) { + const response = await page.goto(server.EMPTY_PAGE); + expect(response.ok).toBe(true); + }); +``` + +- To run tests in non-headless mode: + +```bash +HEADLESS=false npm run unit +``` + +- To run Firefox tests, firstly ensure you have Firefox installed locally (you only need to do this once, not on every test run) and then you can run the tests: + +```bash +PUPPETEER_PRODUCT=firefox node install.js +PUPPETEER_PRODUCT=firefox npm run unit +``` + +- To run tests with custom browser executable: + +```bash +BINARY=<path-to-executable> npm run unit +``` + +## Public API Coverage + +Every public API method or event should be called at least once in tests. To ensure this, there's a `coverage` command which tracks calls to public API and reports back if some methods/events were not called. + +Run coverage: + +```bash +npm run coverage +``` + +## Debugging Puppeteer + +See [Debugging Tips](README.md#debugging-tips) in the readme. + +# For Project Maintainers + +## Rolling new Chromium version + +The following steps are needed to update the Chromium version. + +1. Find a suitable Chromium revision + Not all revisions have builds for all platforms, so we need to find one that does. + To do so, run `utils/check_availability.js -rb` to find the latest suitable beta Chromium revision (see `utils/check_availability.js -help` for more options). +1. Update `src/revisions.ts` with the found revision number. +1. Run `npm run ensure-correct-devtools-protocol-revision`. + If it fails, update `package.json` with the expected `devtools-protocol` version. +1. Run `npm run tsc` and `npm install` and ensure that all tests pass. If a test fails, [bisect](#bisecting-upstream-changes) the upstream cause of the failure, and either update the test expectations accordingly (if it was an intended change) or work around the changes in Puppeteer (if it’s not desirable to change Puppeteer’s observable behavior). +1. Update `versions.js` with the new Chromium-to-Puppeteer version mapping. +1. Commit and push your changes and open a pull request. + +### Bisecting upstream changes + +Sometimes, performing a Chromium roll causes tests to fail. To figure out the cause, you need to bisect Chromium revisions to figure out the earliest possible revision that changed the behavior. The script in `utils/bisect.js` can be helpful here. Given a Node.js script that calls `process.exit(1)` for bad revisions, run this from the Puppeteer repository’s root directory: + +```sh +node utils/bisect.js --good 686378 --bad 706915 script.js +``` + +## Releasing to npm + +Releasing to npm consists of the following phases: + +1. Source Code: mark a release. + 1. Run `npm run release` to bump the version number in `package.json` and populate the changelog. + 1. Run `npm run doc` to update the docs accordingly. + 1. Send a PR titled `'chore(release): mark vXXX.YYY.ZZZ'` ([example](https://github.com/puppeteer/puppeteer/pull/5078)). + 1. Make sure the PR passes **all checks**. + - **WHY**: there are linters in place that help to avoid unnecessary errors, e.g. [like this](https://github.com/puppeteer/puppeteer/pull/2446) + 1. Merge the PR. + 1. Once merged, publish the release notes from `CHANGELOG.md` using [GitHub’s “draft new release tag” option](https://github.com/puppeteer/puppeteer/releases/new). + - **NOTE**: tag names are prefixed with `'v'`, e.g. for version `1.4.0` the tag is `v1.4.0`. + 1. As soon as the Git tag is created by completing the previous step, our CI automatically `npm publish`es the new releases for both the `puppeteer` and `puppeteer-core` packages. +1. Source Code: mark post-release. + 1. Bump `package.json` version to the `-post` version, run `npm run doc` to update the “released APIs” section at the top of `docs/api.md` accordingly, and send a PR titled `'chore: bump version to vXXX.YYY.ZZZ-post'` ([example](https://github.com/puppeteer/puppeteer/commit/d02440d1eac98028e29f4e1cf55413062a259156)) + - **NOTE**: no other commits should be landed in-between release commit and bump commit. diff --git a/remote/test/puppeteer/LICENSE b/remote/test/puppeteer/LICENSE new file mode 100644 index 0000000000..d2c171df74 --- /dev/null +++ b/remote/test/puppeteer/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + https://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 + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/remote/test/puppeteer/README.md b/remote/test/puppeteer/README.md new file mode 100644 index 0000000000..d50b8245e0 --- /dev/null +++ b/remote/test/puppeteer/README.md @@ -0,0 +1,453 @@ +# Puppeteer + +<!-- [START badges] --> +[![Build status](https://img.shields.io/travis/com/puppeteer/puppeteer/main.svg)](https://travis-ci.com/puppeteer/puppeteer) [![npm puppeteer package](https://img.shields.io/npm/v/puppeteer.svg)](https://npmjs.org/package/puppeteer) [![Issue resolution status](https://isitmaintained.com/badge/resolution/puppeteer/puppeteer.svg)](https://github.com/puppeteer/puppeteer/issues) +<!-- [END badges] --> + +<img src="https://user-images.githubusercontent.com/10379601/29446482-04f7036a-841f-11e7-9872-91d1fc2ea683.png" height="200" align="right"> + +###### [API](https://github.com/puppeteer/puppeteer/blob/v5.5.0/docs/api.md) | [FAQ](#faq) | [Contributing](https://github.com/puppeteer/puppeteer/blob/main/CONTRIBUTING.md) | [Troubleshooting](https://github.com/puppeteer/puppeteer/blob/main/docs/troubleshooting.md) + +> Puppeteer is a Node library which provides a high-level API to control Chrome or Chromium over the [DevTools Protocol](https://chromedevtools.github.io/devtools-protocol/). Puppeteer runs [headless](https://developers.google.com/web/updates/2017/04/headless-chrome) by default, but can be configured to run full (non-headless) Chrome or Chromium. + +<!-- [START usecases] --> +###### What can I do? + +Most things that you can do manually in the browser can be done using Puppeteer! Here are a few examples to get you started: + +* Generate screenshots and PDFs of pages. +* Crawl a SPA (Single-Page Application) and generate pre-rendered content (i.e. "SSR" (Server-Side Rendering)). +* Automate form submission, UI testing, keyboard input, etc. +* Create an up-to-date, automated testing environment. Run your tests directly in the latest version of Chrome using the latest JavaScript and browser features. +* Capture a [timeline trace](https://developers.google.com/web/tools/chrome-devtools/evaluate-performance/reference) of your site to help diagnose performance issues. +* Test Chrome Extensions. +<!-- [END usecases] --> + +Give it a spin: https://try-puppeteer.appspot.com/ + +<!-- [START getstarted] --> +## Getting Started + +### Installation + +To use Puppeteer in your project, run: + +```bash +npm i puppeteer +# or "yarn add puppeteer" +``` + +Note: When you install Puppeteer, it downloads a recent version of Chromium (~170MB Mac, ~282MB Linux, ~280MB Win) that is guaranteed to work with the API. To skip the download, or to download a different browser, see [Environment variables](https://github.com/puppeteer/puppeteer/blob/v5.5.0/docs/api.md#environment-variables). + + +### puppeteer-core + +Since version 1.7.0 we publish the [`puppeteer-core`](https://www.npmjs.com/package/puppeteer-core) package, +a version of Puppeteer that doesn't download any browser by default. + +```bash +npm i puppeteer-core +# or "yarn add puppeteer-core" +``` + +`puppeteer-core` is intended to be a lightweight version of Puppeteer for launching an existing browser installation or for connecting to a remote one. Be sure that the version of puppeteer-core you install is compatible with the +browser you intend to connect to. + +See [puppeteer vs puppeteer-core](https://github.com/puppeteer/puppeteer/blob/main/docs/api.md#puppeteer-vs-puppeteer-core). + +### Usage + +Puppeteer follows the latest [maintenance LTS](https://github.com/nodejs/Release#release-schedule) version of Node. + +Note: Prior to v1.18.1, Puppeteer required at least Node v6.4.0. Versions from v1.18.1 to v2.1.0 rely on +Node 8.9.0+. Starting from v3.0.0 Puppeteer starts to rely on Node 10.18.1+. All examples below use async/await which is only supported in Node v7.6.0 or greater. + +Puppeteer will be familiar to people using other browser testing frameworks. You create an instance +of `Browser`, open pages, and then manipulate them with [Puppeteer's API](https://github.com/puppeteer/puppeteer/blob/v5.5.0/docs/api.md#). + +**Example** - navigating to https://example.com and saving a screenshot as *example.png*: + +Save file as **example.js** + +```js +const puppeteer = require('puppeteer'); + +(async () => { + const browser = await puppeteer.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 +``` + +Puppeteer sets an initial page size to 800×600px, which defines the screenshot size. The page size can be customized with [`Page.setViewport()`](https://github.com/puppeteer/puppeteer/blob/v5.5.0/docs/api.md#pagesetviewportviewport). + +**Example** - create a PDF. + +Save file as **hn.js** + +```js +const puppeteer = require('puppeteer'); + +(async () => { + const browser = await puppeteer.launch(); + const page = await browser.newPage(); + await page.goto('https://news.ycombinator.com', {waitUntil: 'networkidle2'}); + await page.pdf({path: 'hn.pdf', format: 'A4'}); + + await browser.close(); +})(); +``` + +Execute script on the command line + +```bash +node hn.js +``` + +See [`Page.pdf()`](https://github.com/puppeteer/puppeteer/blob/v5.5.0/docs/api.md#pagepdfoptions) for more information about creating pdfs. + +**Example** - evaluate script in the context of the page + +Save file as **get-dimensions.js** + +```js +const puppeteer = require('puppeteer'); + +(async () => { + const browser = await puppeteer.launch(); + const page = await browser.newPage(); + await page.goto('https://example.com'); + + // Get the "viewport" of the page, as reported by the page. + const dimensions = await page.evaluate(() => { + return { + width: document.documentElement.clientWidth, + height: document.documentElement.clientHeight, + deviceScaleFactor: window.devicePixelRatio + }; + }); + + console.log('Dimensions:', dimensions); + + await browser.close(); +})(); +``` + +Execute script on the command line + +```bash +node get-dimensions.js +``` + +See [`Page.evaluate()`](https://github.com/puppeteer/puppeteer/blob/v5.5.0/docs/api.md#pageevaluatepagefunction-args) for more information on `evaluate` and related methods like `evaluateOnNewDocument` and `exposeFunction`. + +<!-- [END getstarted] --> + +<!-- [START runtimesettings] --> +## Default runtime settings + +**1. Uses Headless mode** + +Puppeteer launches Chromium in [headless mode](https://developers.google.com/web/updates/2017/04/headless-chrome). To launch a full version of Chromium, set the [`headless` option](https://github.com/puppeteer/puppeteer/blob/v5.5.0/docs/api.md#puppeteerlaunchoptions) when launching a browser: + +```js +const browser = await puppeteer.launch({headless: false}); // default is true +``` + +**2. Runs a bundled version of Chromium** + +By default, Puppeteer downloads and uses a specific version of Chromium so its API +is guaranteed to work out of the box. To use Puppeteer with a different version of Chrome or Chromium, +pass in the executable's path when creating a `Browser` instance: + +```js +const browser = await puppeteer.launch({executablePath: '/path/to/Chrome'}); +``` + +You can also use Puppeteer with Firefox Nightly (experimental support). See [`Puppeteer.launch()`](https://github.com/puppeteer/puppeteer/blob/v5.5.0/docs/api.md#puppeteerlaunchoptions) for more information. + +See [`this article`](https://www.howtogeek.com/202825/what%E2%80%99s-the-difference-between-chromium-and-chrome/) for a description of the differences between Chromium and Chrome. [`This article`](https://chromium.googlesource.com/chromium/src/+/master/docs/chromium_browser_vs_google_chrome.md) describes some differences for Linux users. + +**3. Creates a fresh user profile** + +Puppeteer creates its own browser user profile which it **cleans up on every run**. + +<!-- [END runtimesettings] --> + +## Resources + +- [API Documentation](https://github.com/puppeteer/puppeteer/blob/v5.5.0/docs/api.md) +- [Examples](https://github.com/puppeteer/puppeteer/tree/main/examples/) +- [Community list of Puppeteer resources](https://github.com/transitive-bullshit/awesome-puppeteer) + + +<!-- [START debugging] --> + +## Debugging tips + +1. Turn off headless mode - sometimes it's useful to see what the browser is + displaying. Instead of launching in headless mode, launch a full version of + the browser using `headless: false`: + + ```js + const browser = await puppeteer.launch({headless: false}); + ``` + +2. Slow it down - the `slowMo` option slows down Puppeteer operations by the + specified amount of milliseconds. It's another way to help see what's going on. + + ```js + const browser = await puppeteer.launch({ + headless: false, + slowMo: 250 // slow down by 250ms + }); + ``` + +3. Capture console output - You can listen for the `console` event. + This is also handy when debugging code in `page.evaluate()`: + + ```js + page.on('console', msg => console.log('PAGE LOG:', msg.text())); + + await page.evaluate(() => console.log(`url is ${location.href}`)); + ``` + +4. Use debugger in application code browser + + There are two execution context: node.js that is running test code, and the browser + running application code being tested. This lets you debug code in the + application code browser; ie code inside `evaluate()`. + + - Use `{devtools: true}` when launching Puppeteer: + + ```js + const browser = await puppeteer.launch({devtools: true}); + ``` + + - Change default test timeout: + + jest: `jest.setTimeout(100000);` + + jasmine: `jasmine.DEFAULT_TIMEOUT_INTERVAL = 100000;` + + mocha: `this.timeout(100000);` (don't forget to change test to use [function and not '=>'](https://stackoverflow.com/a/23492442)) + + - Add an evaluate statement with `debugger` inside / add `debugger` to an existing evaluate statement: + + ```js + await page.evaluate(() => {debugger;}); + ``` + + The test will now stop executing in the above evaluate statement, and chromium will stop in debug mode. + +5. Use debugger in node.js + + This will let you debug test code. For example, you can step over `await page.click()` in the node.js script and see the click happen in the application code browser. + + Note that you won't be able to run `await page.click()` in + DevTools console due to this [Chromium bug](https://bugs.chromium.org/p/chromium/issues/detail?id=833928). So if + you want to try something out, you have to add it to your test file. + + - Add `debugger;` to your test, eg: + + ```js + debugger; + await page.click('a[target=_blank]'); + ``` + + - Set `headless` to `false` + - Run `node --inspect-brk`, eg `node --inspect-brk node_modules/.bin/jest tests` + - In Chrome open `chrome://inspect/#devices` and click `inspect` + - In the newly opened test browser, type `F8` to resume test execution + - Now your `debugger` will be hit and you can debug in the test browser + + +6. Enable verbose logging - internal DevTools protocol traffic + will be logged via the [`debug`](https://github.com/visionmedia/debug) module under the `puppeteer` namespace. + + # Basic verbose logging + env DEBUG="puppeteer:*" node script.js + + # Protocol traffic can be rather noisy. This example filters out all Network domain messages + env DEBUG="puppeteer:*" env DEBUG_COLORS=true node script.js 2>&1 | grep -v '"Network' + +7. Debug your Puppeteer (node) code easily, using [ndb](https://github.com/GoogleChromeLabs/ndb) + + - `npm install -g ndb` (or even better, use [npx](https://github.com/zkat/npx)!) + + - add a `debugger` to your Puppeteer (node) code + + - add `ndb` (or `npx ndb`) before your test command. For example: + + `ndb jest` or `ndb mocha` (or `npx ndb jest` / `npx ndb mocha`) + + - debug your test inside chromium like a boss! + +<!-- [END debugging] --> + + +<!-- [START typescript] --> +## Usage with TypeScript + +We have recently completed a migration to move the Puppeteer source code from JavaScript to TypeScript and we're currently working on shipping type definitions for TypeScript with Puppeteer's npm package. + +Until this work is complete we recommend installing the Puppeteer type definitions from the [DefinitelyTyped](https://definitelytyped.org/) repository: + +```bash +npm install --save-dev @types/puppeteer +``` + +The types that you'll see appearing in the Puppeteer source code are based off the great work of those who have contributed to the `@types/puppeteer` package. We really appreciate the hard work those people put in to providing high quality TypeScript definitions for Puppeteer's users. + +<!-- [END typescript] --> + + +## Contributing to Puppeteer + +Check out [contributing guide](https://github.com/puppeteer/puppeteer/blob/main/CONTRIBUTING.md) to get an overview of Puppeteer development. + +<!-- [START faq] --> + +# FAQ + +#### Q: Who maintains Puppeteer? + +The Chrome DevTools team maintains the library, but we'd love your help and expertise on the project! +See [Contributing](https://github.com/puppeteer/puppeteer/blob/main/CONTRIBUTING.md). + +#### Q: What is the status of cross-browser support? + +Official Firefox support is currently experimental. The ongoing collaboration with Mozilla aims to support common end-to-end testing use cases, for which developers expect cross-browser coverage. The Puppeteer team needs input from users to stabilize Firefox support and to bring missing APIs to our attention. + +From Puppeteer v2.1.0 onwards you can specify [`puppeteer.launch({product: 'firefox'})`](https://github.com/puppeteer/puppeteer/blob/v5.5.0/docs/api.md#puppeteerlaunchoptions) to run your Puppeteer scripts in Firefox Nightly, without any additional custom patches. While [an older experiment](https://www.npmjs.com/package/puppeteer-firefox) required a patched version of Firefox, [the current approach](https://wiki.mozilla.org/Remote) works with “stock” Firefox. + +We will continue to collaborate with other browser vendors to bring Puppeteer support to browsers such as Safari. +This effort includes exploration of a standard for executing cross-browser commands (instead of relying on the non-standard DevTools Protocol used by Chrome). + +#### Q: What are Puppeteer’s goals and principles? + +The goals of the project are: + +- Provide a slim, canonical library that highlights the capabilities of the [DevTools Protocol](https://chromedevtools.github.io/devtools-protocol/). +- Provide a reference implementation for similar testing libraries. Eventually, these other frameworks could adopt Puppeteer as their foundational layer. +- Grow the adoption of headless/automated browser testing. +- Help dogfood new DevTools Protocol features...and catch bugs! +- Learn more about the pain points of automated browser testing and help fill those gaps. + +We adapt [Chromium principles](https://www.chromium.org/developers/core-principles) to help us drive product decisions: +- **Speed**: Puppeteer has almost zero performance overhead over an automated page. +- **Security**: Puppeteer operates off-process with respect to Chromium, making it safe to automate potentially malicious pages. +- **Stability**: Puppeteer should not be flaky and should not leak memory. +- **Simplicity**: Puppeteer provides a high-level API that’s easy to use, understand, and debug. + +#### Q: Is Puppeteer replacing Selenium/WebDriver? + +**No**. Both projects are valuable for very different reasons: +- Selenium/WebDriver focuses on cross-browser automation; its value proposition is a single standard API that works across all major browsers. +- Puppeteer focuses on Chromium; its value proposition is richer functionality and higher reliability. + +That said, you **can** use Puppeteer to run tests against Chromium, e.g. using the community-driven [jest-puppeteer](https://github.com/smooth-code/jest-puppeteer). While this probably shouldn’t be your only testing solution, it does have a few good points compared to WebDriver: + +- Puppeteer requires zero setup and comes bundled with the Chromium version it works best with, making it [very easy to start with](https://github.com/puppeteer/puppeteer/#getting-started). At the end of the day, it’s better to have a few tests running chromium-only, than no tests at all. +- Puppeteer has event-driven architecture, which removes a lot of potential flakiness. There’s no need for evil “sleep(1000)” calls in puppeteer scripts. +- Puppeteer runs headless by default, which makes it fast to run. Puppeteer v1.5.0 also exposes browser contexts, making it possible to efficiently parallelize test execution. +- Puppeteer shines when it comes to debugging: flip the “headless” bit to false, add “slowMo”, and you’ll see what the browser is doing. You can even open Chrome DevTools to inspect the test environment. + +#### Q: Why doesn’t Puppeteer v.XXX work with Chromium v.YYY? + +We see Puppeteer as an **indivisible entity** with Chromium. Each version of Puppeteer bundles a specific version of Chromium – **the only** version it is guaranteed to work with. + +This is not an artificial constraint: A lot of work on Puppeteer is actually taking place in the Chromium repository. Here’s a typical story: +- A Puppeteer bug is reported: https://github.com/puppeteer/puppeteer/issues/2709 +- It turned out this is an issue with the DevTools protocol, so we’re fixing it in Chromium: https://chromium-review.googlesource.com/c/chromium/src/+/1102154 +- Once the upstream fix is landed, we roll updated Chromium into Puppeteer: https://github.com/puppeteer/puppeteer/pull/2769 + +However, oftentimes it is desirable to use Puppeteer with the official Google Chrome rather than Chromium. For this to work, you should install a `puppeteer-core` version that corresponds to the Chrome version. + +For example, in order to drive Chrome 71 with puppeteer-core, use `chrome-71` npm tag: +```bash +npm install puppeteer-core@chrome-71 +``` + +#### Q: Which Chromium version does Puppeteer use? + +Look for the `chromium` entry in [revisions.ts](https://github.com/puppeteer/puppeteer/blob/main/src/revisions.ts). To find the corresponding Chromium commit and version number, search for the revision prefixed by an `r` in [OmahaProxy](https://omahaproxy.appspot.com/)'s "Find Releases" section. + + +#### Q: Which Firefox version does Puppeteer use? + +Since Firefox support is experimental, Puppeteer downloads the latest [Firefox Nightly](https://wiki.mozilla.org/Nightly) when the `PUPPETEER_PRODUCT` environment variable is set to `firefox`. That's also why the value of `firefox` in [revisions.ts](https://github.com/puppeteer/puppeteer/blob/main/src/revisions.ts) is `latest` -- Puppeteer isn't tied to a particular Firefox version. + +To fetch Firefox Nightly as part of Puppeteer installation: + +```bash +PUPPETEER_PRODUCT=firefox npm i puppeteer +# or "yarn add puppeteer" +``` + +#### Q: What’s considered a “Navigation”? + +From Puppeteer’s standpoint, **“navigation” is anything that changes a page’s URL**. +Aside from regular navigation where the browser hits the network to fetch a new document from the web server, this includes [anchor navigations](https://www.w3.org/TR/html5/single-page.html#scroll-to-fragid) and [History API](https://developer.mozilla.org/en-US/docs/Web/API/History_API) usage. + +With this definition of “navigation,” **Puppeteer works seamlessly with single-page applications.** + +#### Q: What’s the difference between a “trusted" and "untrusted" input event? + +In browsers, input events could be divided into two big groups: trusted vs. untrusted. + +- **Trusted events**: events generated by users interacting with the page, e.g. using a mouse or keyboard. +- **Untrusted event**: events generated by Web APIs, e.g. `document.createEvent` or `element.click()` methods. + +Websites can distinguish between these two groups: +- using an [`Event.isTrusted`](https://developer.mozilla.org/en-US/docs/Web/API/Event/isTrusted) event flag +- sniffing for accompanying events. For example, every trusted `'click'` event is preceded by `'mousedown'` and `'mouseup'` events. + +For automation purposes it’s important to generate trusted events. **All input events generated with Puppeteer are trusted and fire proper accompanying events.** If, for some reason, one needs an untrusted event, it’s always possible to hop into a page context with `page.evaluate` and generate a fake event: + +```js +await page.evaluate(() => { + document.querySelector('button[type=submit]').click(); +}); +``` + +#### Q: What features does Puppeteer not support? + +You may find that Puppeteer does not behave as expected when controlling pages that incorporate audio and video. (For example, [video playback/screenshots is likely to fail](https://github.com/puppeteer/puppeteer/issues/291).) There are two reasons for this: + +* Puppeteer is bundled with Chromium — not Chrome — and so by default, it inherits all of [Chromium's media-related limitations](https://www.chromium.org/audio-video). This means that Puppeteer does not support licensed formats such as AAC or H.264. (However, it is possible to force Puppeteer to use a separately-installed version Chrome instead of Chromium via the [`executablePath` option to `puppeteer.launch`](https://github.com/puppeteer/puppeteer/blob/v5.5.0/docs/api.md#puppeteerlaunchoptions). You should only use this configuration if you need an official release of Chrome that supports these media formats.) +* Since Puppeteer (in all configurations) controls a desktop version of Chromium/Chrome, features that are only supported by the mobile version of Chrome are not supported. This means that Puppeteer [does not support HTTP Live Streaming (HLS)](https://caniuse.com/#feat=http-live-streaming). + +#### Q: I am having trouble installing / running Puppeteer in my test environment. Where should I look for help? +We have a [troubleshooting](https://github.com/puppeteer/puppeteer/blob/main/docs/troubleshooting.md) guide for various operating systems that lists the required dependencies. + +#### Q: How do I try/test a prerelease version of Puppeteer? + +You can check out this repo or install the latest prerelease from npm: + +```bash +npm i --save puppeteer@next +``` + +Please note that prerelease may be unstable and contain bugs. + +#### Q: I have more questions! Where do I ask? + +There are many ways to get help on Puppeteer: +- [bugtracker](https://github.com/puppeteer/puppeteer/issues) +- [Stack Overflow](https://stackoverflow.com/questions/tagged/puppeteer) +- [slack channel](https://join.slack.com/t/puppeteer/shared_invite/enQtMzU4MjIyMDA5NTM4LWI0YTE0MjM0NWQzYmE2MTRmNjM1ZTBkN2MxNmJmNTIwNTJjMmFhOWFjMGExMDViYjk2YjU2ZmYzMmE1NmExYzc) + +Make sure to search these channels before posting your question. + + +<!-- [END faq] --> diff --git a/remote/test/puppeteer/api-extractor.json b/remote/test/puppeteer/api-extractor.json new file mode 100644 index 0000000000..3e7b9e66f6 --- /dev/null +++ b/remote/test/puppeteer/api-extractor.json @@ -0,0 +1,45 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "mainEntryPointFilePath": "<projectFolder>/lib/cjs/puppeteer/api-docs-entry.d.ts", + "bundledPackages": [ "devtools-protocol" ], + + "apiReport": { + "enabled": false + }, + + "docModel": { + "enabled": true + }, + + "dtsRollup": { + "enabled": false + }, + + "tsdocMetadata": { + "enabled": false + }, + + "messages": { + "compilerMessageReporting": { + "default": { + "logLevel": "warning" + } + }, + + "extractorMessageReporting": { + "ae-internal-missing-underscore": { + "logLevel": "none" + }, + "default": { + "logLevel": "warning" + } + }, + + "tsdocMessageReporting": { + "default": { + "logLevel": "warning" + } + } + } + +} diff --git a/remote/test/puppeteer/cjs-entry-core.js b/remote/test/puppeteer/cjs-entry-core.js new file mode 100644 index 0000000000..446726fafa --- /dev/null +++ b/remote/test/puppeteer/cjs-entry-core.js @@ -0,0 +1,29 @@ +/** + * Copyright 2020 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. + */ + +/** + * We use `export default puppeteer` in `src/index.ts` to expose the library But + * TypeScript in CJS mode compiles that to `exports.default = `. This means that + * our CJS Node users would have to use `require('puppeteer').default` which + * isn't very nice. + * + * So instead we expose this file as our entry point. This requires the compiled + * Puppeteer output and re-exports the `default` export via `module.exports.` + * This means that we can publish to CJS and ESM whilst maintaining the expected + * import behaviour for CJS and ESM users. + */ +const puppeteerExport = require('./lib/cjs/puppeteer/node-puppeteer-core'); +module.exports = puppeteerExport.default; diff --git a/remote/test/puppeteer/cjs-entry.js b/remote/test/puppeteer/cjs-entry.js new file mode 100644 index 0000000000..d1840a9bea --- /dev/null +++ b/remote/test/puppeteer/cjs-entry.js @@ -0,0 +1,29 @@ +/** + * Copyright 2020 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. + */ + +/** + * We use `export default puppeteer` in `src/index.ts` to expose the library But + * TypeScript in CJS mode compiles that to `exports.default = `. This means that + * our CJS Node users would have to use `require('puppeteer').default` which + * isn't very nice. + * + * So instead we expose this file as our entry point. This requires the compiled + * Puppeteer output and re-exports the `default` export via `module.exports.` + * This means that we can publish to CJS and ESM whilst maintaining the expected + * import behaviour for CJS and ESM users. + */ +const puppeteerExport = require('./lib/cjs/puppeteer/node'); +module.exports = puppeteerExport.default; diff --git a/remote/test/puppeteer/commitlint.config.js b/remote/test/puppeteer/commitlint.config.js new file mode 100644 index 0000000000..2e216cd897 --- /dev/null +++ b/remote/test/puppeteer/commitlint.config.js @@ -0,0 +1,22 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +module.exports = { + extends: ['@commitlint/config-conventional'], + rules: { + 'body-max-line-length': [0, 'always', 100], + 'footer-max-line-length': [0, 'always', 100], + }, +}; diff --git a/remote/test/puppeteer/examples/README.md b/remote/test/puppeteer/examples/README.md new file mode 100644 index 0000000000..8afb08555f --- /dev/null +++ b/remote/test/puppeteer/examples/README.md @@ -0,0 +1,38 @@ +# Running the examples + +Assuming you have a checkout of the Puppeteer repo and have run npm i (or yarn) to install the dependencies, the examples can be run from the root folder like so: + +```sh +NODE_PATH=../ node examples/search.js +``` + +## Larger examples + +More complex and use case driven examples can be found at [github.com/GoogleChromeLabs/puppeteer-examples](https://github.com/GoogleChromeLabs/puppeteer-examples). + +# Other resources + +> Other useful tools, articles, and projects that use Puppeteer. + +## Rendering and web scraping + +- [Puppetron](https://github.com/cheeaun/puppetron) - Demo site that shows how to use Puppeteer and Headless Chrome to render pages. Inspired by [GoogleChrome/rendertron](https://github.com/GoogleChrome/rendertron). +- [Thal](https://medium.com/@e_mad_ehsan/getting-started-with-puppeteer-and-chrome-headless-for-web-scrapping-6bf5979dee3e "An article on medium") - Getting started with Puppeteer and Chrome Headless for Web Scraping. +- [pupperender](https://github.com/LasaleFamine/pupperender) - Express middleware that checks the User-Agent header of incoming requests, and if it matches one of a configurable set of bots, render the page using Puppeteer. Useful for PWA rendering. +- [headless-chrome-crawler](https://github.com/yujiosaka/headless-chrome-crawler) - Crawler that provides simple APIs to manipulate Headless Chrome and allows you to crawl dynamic websites. +- [puppeteer-examples](https://github.com/checkly/puppeteer-examples) - Puppeteer Headless Chrome examples for real life use cases such as getting useful info from the web pages or common login scenarios. +- [browserless](https://github.com/joelgriffith/browserless) - Headless Chrome as a service letting you execute Puppeteer scripts remotely. Provides a docker image with configuration for concurrency, launch arguments and more. +- [Puppeteer Sandbox](https://puppeteersandbox.com) - Puppeteer sandbox environment as a service. Runs Puppeteer scripts and allows saving and embedding them in external sites and markdown files. + +## Testing + +- [angular-puppeteer-demo](https://github.com/Quramy/angular-puppeteer-demo) - Demo repository explaining how to use Puppeteer in Karma. +- [mocha-headless-chrome](https://github.com/direct-adv-interfaces/mocha-headless-chrome) - Tool which runs client-side **mocha** tests in the command line through headless Chrome. +- [puppeteer-to-istanbul-example](https://github.com/bcoe/puppeteer-to-istanbul-example) - Demo repository demonstrating how to output Puppeteer coverage in Istanbul format. +- [jest-puppeteer](https://github.com/smooth-code/jest-puppeteer) - (almost) Zero configuration tool for setting up and running Jest and Puppeteer easily. Also includes an assertion library for Puppeteer. +- [puppeteer-har](https://github.com/Everettss/puppeteer-har) - Generate HAR file with puppeteer. +- [puppetry](https://puppetry.app/) - A desktop app to build Puppeteer/Jest driven tests without coding. +- [cucumber-puppeteer-example](https://github.com/mlampedx/cucumber-puppeteer-example) - Example repository demonstrating how to use Puppeeteer and Cucumber for integration testing. + +## Services +- [Checkly](https://checklyhq.com) - Monitoring SaaS that uses Puppeteer to check availability and correctness of web pages and apps. diff --git a/remote/test/puppeteer/examples/block-images.js b/remote/test/puppeteer/examples/block-images.js new file mode 100644 index 0000000000..298e473e82 --- /dev/null +++ b/remote/test/puppeteer/examples/block-images.js @@ -0,0 +1,33 @@ +/** + * Copyright 2017 Google Inc., PhantomJS Authors 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'); + +(async () => { + const browser = await puppeteer.launch(); + const page = await browser.newPage(); + await page.setRequestInterception(true); + page.on('request', (request) => { + if (request.resourceType() === 'image') request.abort(); + else request.continue(); + }); + await page.goto('https://news.google.com/news/'); + await page.screenshot({ path: 'news.png', fullPage: true }); + + await browser.close(); +})(); diff --git a/remote/test/puppeteer/examples/cross-browser.js b/remote/test/puppeteer/examples/cross-browser.js new file mode 100644 index 0000000000..f709abf5bb --- /dev/null +++ b/remote/test/puppeteer/examples/cross-browser.js @@ -0,0 +1,48 @@ +const puppeteer = require('puppeteer'); + +/** + * To have Puppeteer fetch a Firefox binary for you, first run: + * + * PUPPETEER_PRODUCT=firefox npm install + * + * To get additional logging about which browser binary is executed, + * run this example as: + * + * DEBUG=puppeteer:launcher NODE_PATH=../ node examples/cross-browser.js + * + * You can set a custom binary with the `executablePath` launcher option. + * + * + */ + +const firefoxOptions = { + product: 'firefox', + extraPrefsFirefox: { + // Enable additional Firefox logging from its protocol implementation + // 'remote.log.level': 'Trace', + }, + // Make browser logs visible + dumpio: true, +}; + +(async () => { + const browser = await puppeteer.launch(firefoxOptions); + + const page = await browser.newPage(); + console.log(await browser.version()); + + await page.goto('https://news.ycombinator.com/'); + + // Extract articles from the page. + const resultsSelector = '.storylink'; + const links = await page.evaluate((resultsSelector) => { + const anchors = Array.from(document.querySelectorAll(resultsSelector)); + return anchors.map((anchor) => { + const title = anchor.textContent.trim(); + return `${title} - ${anchor.href}`; + }); + }, resultsSelector); + console.log(links.join('\n')); + + await browser.close(); +})(); diff --git a/remote/test/puppeteer/examples/custom-event.js b/remote/test/puppeteer/examples/custom-event.js new file mode 100644 index 0000000000..bf196345ba --- /dev/null +++ b/remote/test/puppeteer/examples/custom-event.js @@ -0,0 +1,50 @@ +/** + * 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'); + +(async () => { + const browser = await puppeteer.launch(); + const page = await browser.newPage(); + + // Define a window.onCustomEvent function on the page. + await page.exposeFunction('onCustomEvent', (e) => { + console.log(`${e.type} fired`, e.detail || ''); + }); + + /** + * Attach an event listener to page to capture a custom event on page load/navigation. + * @param {string} type Event name. + * @returns {!Promise} + */ + function listenFor(type) { + return page.evaluateOnNewDocument((type) => { + document.addEventListener(type, (e) => { + window.onCustomEvent({ type, detail: e.detail }); + }); + }, type); + } + + await listenFor('app-ready'); // Listen for "app-ready" custom event on page load. + + await page.goto('https://www.chromestatus.com/features', { + waitUntil: 'networkidle0', + }); + + await browser.close(); +})(); diff --git a/remote/test/puppeteer/examples/detect-sniff.js b/remote/test/puppeteer/examples/detect-sniff.js new file mode 100644 index 0000000000..76bb75b386 --- /dev/null +++ b/remote/test/puppeteer/examples/detect-sniff.js @@ -0,0 +1,44 @@ +/** + * Copyright 2017 Google Inc., PhantomJS Authors 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'); + +function sniffDetector() { + const userAgent = window.navigator.userAgent; + const platform = window.navigator.platform; + + window.navigator.__defineGetter__('userAgent', function () { + window.navigator.sniffed = true; + return userAgent; + }); + + window.navigator.__defineGetter__('platform', function () { + window.navigator.sniffed = true; + return platform; + }); +} + +(async () => { + const browser = await puppeteer.launch(); + const page = await browser.newPage(); + await page.evaluateOnNewDocument(sniffDetector); + await page.goto('https://www.google.com', { waitUntil: 'networkidle2' }); + console.log('Sniffed: ' + (await page.evaluate(() => !!navigator.sniffed))); + + await browser.close(); +})(); diff --git a/remote/test/puppeteer/examples/oopif.js b/remote/test/puppeteer/examples/oopif.js new file mode 100644 index 0000000000..52e2047b31 --- /dev/null +++ b/remote/test/puppeteer/examples/oopif.js @@ -0,0 +1,47 @@ +/** + * Copyright 2020 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'); + +async function attachFrame(frameId, url) { + const frame = document.createElement('iframe'); + frame.src = url; + frame.id = frameId; + document.body.appendChild(frame); + await new Promise((x) => (frame.onload = x)); + return frame; +} + +(async () => { + // Launch browser in non-headless mode. + const browser = await puppeteer.launch({ headless: false }); + const page = await browser.newPage(); + + // Load a page from one origin: + await page.goto('http://example.org/'); + + // Inject iframe with the another origin. + await page.evaluateHandle(attachFrame, 'frame1', 'https://example.com/'); + + // At this point there should be a message in the output: + // puppeteer:frame The frame '...' moved to another session. Out-of-proccess + // iframes (OOPIF) are not supported by Puppeteer yet. + // https://github.com/puppeteer/puppeteer/issues/2548 + + await browser.close(); +})(); diff --git a/remote/test/puppeteer/examples/pdf.js b/remote/test/puppeteer/examples/pdf.js new file mode 100644 index 0000000000..9f7ac92e0a --- /dev/null +++ b/remote/test/puppeteer/examples/pdf.js @@ -0,0 +1,35 @@ +/** + * 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'); + +(async () => { + const browser = await puppeteer.launch(); + const page = await browser.newPage(); + await page.goto('https://news.ycombinator.com', { + waitUntil: 'networkidle2', + }); + // page.pdf() is currently supported only in headless mode. + // @see https://bugs.chromium.org/p/chromium/issues/detail?id=753118 + await page.pdf({ + path: 'hn.pdf', + format: 'letter', + }); + + await browser.close(); +})(); diff --git a/remote/test/puppeteer/examples/proxy.js b/remote/test/puppeteer/examples/proxy.js new file mode 100644 index 0000000000..5490822b8b --- /dev/null +++ b/remote/test/puppeteer/examples/proxy.js @@ -0,0 +1,35 @@ +/** + * 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'); + +(async () => { + const browser = await puppeteer.launch({ + // Launch chromium using a proxy server on port 9876. + // More on proxying: + // https://www.chromium.org/developers/design-documents/network-settings + args: [ + '--proxy-server=127.0.0.1:9876', + // Use proxy for localhost URLs + '--proxy-bypass-list=<-loopback>', + ], + }); + const page = await browser.newPage(); + await page.goto('https://google.com'); + await browser.close(); +})(); diff --git a/remote/test/puppeteer/examples/screenshot-fullpage.js b/remote/test/puppeteer/examples/screenshot-fullpage.js new file mode 100644 index 0000000000..8844cbcfee --- /dev/null +++ b/remote/test/puppeteer/examples/screenshot-fullpage.js @@ -0,0 +1,28 @@ +/** + * 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'); + +(async () => { + const browser = await puppeteer.launch(); + const page = await browser.newPage(); + await page.emulate(puppeteer.devices['iPhone 6']); + await page.goto('https://www.nytimes.com/'); + await page.screenshot({ path: 'full.png', fullPage: true }); + await browser.close(); +})(); diff --git a/remote/test/puppeteer/examples/screenshot.js b/remote/test/puppeteer/examples/screenshot.js new file mode 100644 index 0000000000..28b4dbb274 --- /dev/null +++ b/remote/test/puppeteer/examples/screenshot.js @@ -0,0 +1,27 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const puppeteer = require('puppeteer'); + +(async () => { + const browser = await puppeteer.launch(); + const page = await browser.newPage(); + await page.goto('http://example.com'); + await page.screenshot({ path: 'example.png' }); + await browser.close(); +})(); diff --git a/remote/test/puppeteer/examples/search.js b/remote/test/puppeteer/examples/search.js new file mode 100644 index 0000000000..828d155438 --- /dev/null +++ b/remote/test/puppeteer/examples/search.js @@ -0,0 +1,55 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @fileoverview Search developers.google.com/web for articles tagged + * "Headless Chrome" and scrape results from the results page. + */ + +'use strict'; + +const puppeteer = require('puppeteer'); + +(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('.devsite-searchbox input', 'Headless Chrome'); + + // Wait for suggest overlay to appear and click "show all results". + const allResultsSelector = '.devsite-suggest-all-results'; + await page.waitForSelector(allResultsSelector); + await page.click(allResultsSelector); + + // Wait for the results page to load and display the results. + const resultsSelector = '.gsc-results .gsc-thumbnail-inside a.gs-title'; + await page.waitForSelector(resultsSelector); + + // Extract the results from the page. + const links = await page.evaluate((resultsSelector) => { + const anchors = Array.from(document.querySelectorAll(resultsSelector)); + return anchors.map((anchor) => { + const title = anchor.textContent.split('|')[0].trim(); + return `${title} - ${anchor.href}`; + }); + }, resultsSelector); + console.log(links.join('\n')); + + await browser.close(); +})(); diff --git a/remote/test/puppeteer/install.js b/remote/test/puppeteer/install.js new file mode 100644 index 0000000000..11518a595f --- /dev/null +++ b/remote/test/puppeteer/install.js @@ -0,0 +1,89 @@ +/** + * 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. + */ + +/** + * This file is part of public API. + * + * By default, the `puppeteer` package runs this script during the installation + * process unless one of the env flags is provided. + * `puppeteer-core` package doesn't include this step at all. However, it's + * still possible to install a supported browser using this script when + * necessary. + */ + +const compileTypeScriptIfRequired = require('./typescript-if-required'); + +async function download() { + await compileTypeScriptIfRequired(); + // need to ensure TS is compiled before loading the installer + const { + downloadBrowser, + logPolitely, + } = require('./lib/cjs/puppeteer/node/install'); + + if (process.env.PUPPETEER_SKIP_DOWNLOAD) { + logPolitely( + '**INFO** Skipping browser download. "PUPPETEER_SKIP_DOWNLOAD" environment variable was found.' + ); + return; + } + if ( + process.env.NPM_CONFIG_PUPPETEER_SKIP_DOWNLOAD || + process.env.npm_config_puppeteer_skip_download + ) { + logPolitely( + '**INFO** Skipping browser download. "PUPPETEER_SKIP_DOWNLOAD" was set in npm config.' + ); + return; + } + if ( + process.env.NPM_PACKAGE_CONFIG_PUPPETEER_SKIP_DOWNLOAD || + process.env.npm_package_config_puppeteer_skip_download + ) { + logPolitely( + '**INFO** Skipping browser download. "PUPPETEER_SKIP_DOWNLOAD" was set in project config.' + ); + return; + } + if (process.env.PUPPETEER_SKIP_CHROMIUM_DOWNLOAD) { + logPolitely( + '**INFO** Skipping browser download. "PUPPETEER_SKIP_CHROMIUM_DOWNLOAD" environment variable was found.' + ); + return; + } + if ( + process.env.NPM_CONFIG_PUPPETEER_SKIP_CHROMIUM_DOWNLOAD || + process.env.npm_config_puppeteer_skip_chromium_download + ) { + logPolitely( + '**INFO** Skipping browser download. "PUPPETEER_SKIP_CHROMIUM_DOWNLOAD" was set in npm config.' + ); + return; + } + if ( + process.env.NPM_PACKAGE_CONFIG_PUPPETEER_SKIP_CHROMIUM_DOWNLOAD || + process.env.npm_package_config_puppeteer_skip_chromium_download + ) { + logPolitely( + '**INFO** Skipping browser download. "PUPPETEER_SKIP_CHROMIUM_DOWNLOAD" was set in project config.' + ); + return; + } + + downloadBrowser(); +} + +download(); diff --git a/remote/test/puppeteer/json-mocha-reporter.js b/remote/test/puppeteer/json-mocha-reporter.js new file mode 100644 index 0000000000..47fc74a9c5 --- /dev/null +++ b/remote/test/puppeteer/json-mocha-reporter.js @@ -0,0 +1,68 @@ +const mocha = require('mocha'); +module.exports = JSONExtra; + +const constants = mocha.Runner.constants; + +/* + +This is a copy of +https://github.com/mochajs/mocha/blob/master/lib/reporters/json-stream.js +with more event hooks. mocha does not support extending reporters or using +multiple reporters so a custom reporter is needed and it must be local +to the project. + +*/ + +function JSONExtra(runner, options) { + mocha.reporters.Base.call(this, runner, options); + const self = this; + + runner.once(constants.EVENT_RUN_BEGIN, function () { + writeEvent(['start', { total: runner.total }]); + }); + + runner.on(constants.EVENT_TEST_PASS, function (test) { + writeEvent(['pass', clean(test)]); + }); + + runner.on(constants.EVENT_TEST_FAIL, function (test, err) { + test = clean(test); + test.err = err.message; + test.stack = err.stack || null; + writeEvent(['fail', test]); + }); + + runner.once(constants.EVENT_RUN_END, function () { + writeEvent(['end', self.stats]); + }); + + runner.on(constants.EVENT_TEST_BEGIN, function (test) { + writeEvent(['test-start', clean(test)]); + }); + + runner.on(constants.EVENT_TEST_PENDING, function (test) { + writeEvent(['pending', clean(test)]); + }); +} + +function writeEvent(event) { + process.stdout.write(JSON.stringify(event) + '\n'); +} + +/** + * Returns an object literal representation of `test` + * free of cyclic properties, etc. + * + * @private + * @param {Object} test - Instance used as data source. + * @return {Object} object containing pared-down test instance data + */ +function clean(test) { + return { + title: test.title, + fullTitle: test.fullTitle(), + file: test.file, + duration: test.duration, + currentRetry: test.currentRetry(), + }; +} diff --git a/remote/test/puppeteer/mocha-config/base.js b/remote/test/puppeteer/mocha-config/base.js new file mode 100644 index 0000000000..dc0b04d73f --- /dev/null +++ b/remote/test/puppeteer/mocha-config/base.js @@ -0,0 +1,6 @@ +module.exports = { + reporter: 'dot', + // Allow `console.log`s to show up during test execution + logLevel: 'debug', + exit: !!process.env.CI, +}; diff --git a/remote/test/puppeteer/mocha-config/coverage-tests.js b/remote/test/puppeteer/mocha-config/coverage-tests.js new file mode 100644 index 0000000000..6814f404cf --- /dev/null +++ b/remote/test/puppeteer/mocha-config/coverage-tests.js @@ -0,0 +1,6 @@ +const base = require('./base'); + +module.exports = { + ...base, + spec: 'test/assert-coverage-test.js', +}; diff --git a/remote/test/puppeteer/mocha-config/doclint-tests.js b/remote/test/puppeteer/mocha-config/doclint-tests.js new file mode 100644 index 0000000000..279e28eb17 --- /dev/null +++ b/remote/test/puppeteer/mocha-config/doclint-tests.js @@ -0,0 +1,6 @@ +const base = require('./base'); + +module.exports = { + ...base, + spec: 'utils/doclint/**/*.spec.js', +}; diff --git a/remote/test/puppeteer/mocha-config/puppeteer-unit-tests.js b/remote/test/puppeteer/mocha-config/puppeteer-unit-tests.js new file mode 100644 index 0000000000..cb1bb7cae9 --- /dev/null +++ b/remote/test/puppeteer/mocha-config/puppeteer-unit-tests.js @@ -0,0 +1,28 @@ +/** + * Copyright 2020 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 base = require('./base'); + +module.exports = { + ...base, + require: ['./test/mocha-ts-require', './test/mocha-utils.ts'], + spec: 'test/*.spec.ts', + extension: ['js', 'ts'], + retries: process.env.CI ? 2 : 0, + parallel: !!process.env.PARALLEL, + timeout: 25 * 1000, + reporter: process.env.CI ? 'spec' : 'dot', +}; diff --git a/remote/test/puppeteer/moz.yaml b/remote/test/puppeteer/moz.yaml new file mode 100644 index 0000000000..3ebe8c1838 --- /dev/null +++ b/remote/test/puppeteer/moz.yaml @@ -0,0 +1,10 @@ +bugzilla: + component: Agent + product: Remote Protocol +origin: + description: Headless Chrome Node API + license: Apache-2.0 + name: puppeteer + release: a5dedb7 + url: https://github.com/mjzffr/puppeteer.git +schema: 1 diff --git a/remote/test/puppeteer/package-lock.json b/remote/test/puppeteer/package-lock.json new file mode 100644 index 0000000000..684b7f8733 --- /dev/null +++ b/remote/test/puppeteer/package-lock.json @@ -0,0 +1,9746 @@ +{ + "name": "puppeteer", + "version": "5.5.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@babel/code-frame": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz", + "integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==", + "dev": true, + "requires": { + "@babel/highlight": "^7.10.4" + } + }, + "@babel/generator": { + "version": "7.12.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.12.5.tgz", + "integrity": "sha512-m16TQQJ8hPt7E+OS/XVQg/7U184MLXtvuGbCdA7na61vha+ImkyyNM/9DDA0unYCVZn3ZOhng+qz48/KBOT96A==", + "dev": true, + "requires": { + "@babel/types": "^7.12.5", + "jsesc": "^2.5.1", + "source-map": "^0.5.0" + }, + "dependencies": { + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true + } + } + }, + "@babel/helper-function-name": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.10.4.tgz", + "integrity": "sha512-YdaSyz1n8gY44EmN7x44zBn9zQ1Ry2Y+3GTA+3vH6Mizke1Vw0aWDM66FOYEPw8//qKkmqOckrGgTYa+6sceqQ==", + "dev": true, + "requires": { + "@babel/helper-get-function-arity": "^7.10.4", + "@babel/template": "^7.10.4", + "@babel/types": "^7.10.4" + } + }, + "@babel/helper-get-function-arity": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.10.4.tgz", + "integrity": "sha512-EkN3YDB+SRDgiIUnNgcmiD361ti+AVbL3f3Henf6dqqUyr5dMsorno0lJWJuLhDhkI5sYEpgj6y9kB8AOU1I2A==", + "dev": true, + "requires": { + "@babel/types": "^7.10.4" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.11.0.tgz", + "integrity": "sha512-74Vejvp6mHkGE+m+k5vHY93FX2cAtrw1zXrZXRlG4l410Nm9PxfEiVTn1PjDPV5SnmieiueY4AFg2xqhNFuuZg==", + "dev": true, + "requires": { + "@babel/types": "^7.11.0" + } + }, + "@babel/helper-validator-identifier": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.4.tgz", + "integrity": "sha512-3U9y+43hz7ZM+rzG24Qe2mufW5KhvFg/NhnNph+i9mgCtdTCtMJuI1TMkrIUiK7Ix4PYlRF9I5dhqaLYA/ADXw==", + "dev": true + }, + "@babel/highlight": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.10.4.tgz", + "integrity": "sha512-i6rgnR/YgPEQzZZnbTHHuZdlE8qyoBNalD6F+q4vAFlcMEcqmkoG+mPqJYJCo63qPf74+Y1UZsl3l6f7/RIkmA==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.10.4", + "chalk": "^2.0.0", + "js-tokens": "^4.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", + "dev": true + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "@babel/parser": { + "version": "7.12.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.12.5.tgz", + "integrity": "sha512-FVM6RZQ0mn2KCf1VUED7KepYeUWoVShczewOCfm3nzoBybaih51h+sYVVGthW9M6lPByEPTQf+xm27PBdlpwmQ==", + "dev": true + }, + "@babel/runtime": { + "version": "7.12.5", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.12.5.tgz", + "integrity": "sha512-plcc+hbExy3McchJCEQG3knOsuh3HH+Prx1P6cLIkET/0dLuQDEnrT+s27Axgc9bqfsmNUNHfscgMUdBpC9xfg==", + "dev": true, + "requires": { + "regenerator-runtime": "^0.13.4" + } + }, + "@babel/template": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.10.4.tgz", + "integrity": "sha512-ZCjD27cGJFUB6nmCB1Enki3r+L5kJveX9pq1SvAUKoICy6CZ9yD8xO086YXdYhvNjBdnekm4ZnaP5yC8Cs/1tA==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.10.4", + "@babel/parser": "^7.10.4", + "@babel/types": "^7.10.4" + } + }, + "@babel/traverse": { + "version": "7.12.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.12.5.tgz", + "integrity": "sha512-xa15FbQnias7z9a62LwYAA5SZZPkHIXpd42C6uW68o8uTuua96FHZy1y61Va5P/i83FAAcMpW8+A/QayntzuqA==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.10.4", + "@babel/generator": "^7.12.5", + "@babel/helper-function-name": "^7.10.4", + "@babel/helper-split-export-declaration": "^7.11.0", + "@babel/parser": "^7.12.5", + "@babel/types": "^7.12.5", + "debug": "^4.1.0", + "globals": "^11.1.0", + "lodash": "^4.17.19" + }, + "dependencies": { + "globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true + } + } + }, + "@babel/types": { + "version": "7.12.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.12.6.tgz", + "integrity": "sha512-hwyjw6GvjBLiyy3W0YQf0Z5Zf4NpYejUnKFcfcUhZCSffoBBp30w6wP2Wn6pk31jMYZvcOrB/1b7cGXvEoKogA==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.10.4", + "lodash": "^4.17.19", + "to-fast-properties": "^2.0.0" + } + }, + "@commitlint/cli": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-11.0.0.tgz", + "integrity": "sha512-YWZWg1DuqqO5Zjh7vUOeSX76vm0FFyz4y0cpGMFhrhvUi5unc4IVfCXZ6337R9zxuBtmveiRuuhQqnRRer+13g==", + "dev": true, + "requires": { + "@babel/runtime": "^7.11.2", + "@commitlint/format": "^11.0.0", + "@commitlint/lint": "^11.0.0", + "@commitlint/load": "^11.0.0", + "@commitlint/read": "^11.0.0", + "chalk": "4.1.0", + "core-js": "^3.6.1", + "get-stdin": "8.0.0", + "lodash": "^4.17.19", + "resolve-from": "5.0.0", + "resolve-global": "1.0.0", + "yargs": "^15.1.0" + } + }, + "@commitlint/config-conventional": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-11.0.0.tgz", + "integrity": "sha512-SNDRsb5gLuDd2PL83yCOQX6pE7gevC79UPFx+GLbLfw6jGnnbO9/tlL76MLD8MOViqGbo7ZicjChO9Gn+7tHhA==", + "dev": true, + "requires": { + "conventional-changelog-conventionalcommits": "^4.3.1" + } + }, + "@commitlint/ensure": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-11.0.0.tgz", + "integrity": "sha512-/T4tjseSwlirKZdnx4AuICMNNlFvRyPQimbZIOYujp9DSO6XRtOy9NrmvWujwHsq9F5Wb80QWi4WMW6HMaENug==", + "dev": true, + "requires": { + "@commitlint/types": "^11.0.0", + "lodash": "^4.17.19" + } + }, + "@commitlint/execute-rule": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@commitlint/execute-rule/-/execute-rule-11.0.0.tgz", + "integrity": "sha512-g01p1g4BmYlZ2+tdotCavrMunnPFPhTzG1ZiLKTCYrooHRbmvqo42ZZn4QMStUEIcn+jfLb6BRZX3JzIwA1ezQ==", + "dev": true + }, + "@commitlint/format": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-11.0.0.tgz", + "integrity": "sha512-bpBLWmG0wfZH/svzqD1hsGTpm79TKJWcf6EXZllh2J/LSSYKxGlv967lpw0hNojme0sZd4a/97R3qA2QHWWSLg==", + "dev": true, + "requires": { + "@commitlint/types": "^11.0.0", + "chalk": "^4.0.0" + } + }, + "@commitlint/is-ignored": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-11.0.0.tgz", + "integrity": "sha512-VLHOUBN+sOlkYC4tGuzE41yNPO2w09sQnOpfS+pSPnBFkNUUHawEuA44PLHtDvQgVuYrMAmSWFQpWabMoP5/Xg==", + "dev": true, + "requires": { + "@commitlint/types": "^11.0.0", + "semver": "7.3.2" + } + }, + "@commitlint/lint": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-11.0.0.tgz", + "integrity": "sha512-Q8IIqGIHfwKr8ecVZyYh6NtXFmKw4YSEWEr2GJTB/fTZXgaOGtGFZDWOesCZllQ63f1s/oWJYtVv5RAEuwN8BQ==", + "dev": true, + "requires": { + "@commitlint/is-ignored": "^11.0.0", + "@commitlint/parse": "^11.0.0", + "@commitlint/rules": "^11.0.0", + "@commitlint/types": "^11.0.0" + } + }, + "@commitlint/load": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-11.0.0.tgz", + "integrity": "sha512-t5ZBrtgvgCwPfxmG811FCp39/o3SJ7L+SNsxFL92OR4WQxPcu6c8taD0CG2lzOHGuRyuMxZ7ps3EbngT2WpiCg==", + "dev": true, + "requires": { + "@commitlint/execute-rule": "^11.0.0", + "@commitlint/resolve-extends": "^11.0.0", + "@commitlint/types": "^11.0.0", + "chalk": "4.1.0", + "cosmiconfig": "^7.0.0", + "lodash": "^4.17.19", + "resolve-from": "^5.0.0" + } + }, + "@commitlint/message": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-11.0.0.tgz", + "integrity": "sha512-01ObK/18JL7PEIE3dBRtoMmU6S3ecPYDTQWWhcO+ErA3Ai0KDYqV5VWWEijdcVafNpdeUNrEMigRkxXHQLbyJA==", + "dev": true + }, + "@commitlint/parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-11.0.0.tgz", + "integrity": "sha512-DekKQAIYWAXIcyAZ6/PDBJylWJ1BROTfDIzr9PMVxZRxBPc1gW2TG8fLgjZfBP5mc0cuthPkVi91KQQKGri/7A==", + "dev": true, + "requires": { + "conventional-changelog-angular": "^5.0.0", + "conventional-commits-parser": "^3.0.0" + } + }, + "@commitlint/read": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-11.0.0.tgz", + "integrity": "sha512-37V0V91GSv0aDzMzJioKpCoZw6l0shk7+tRG8RkW1GfZzUIytdg3XqJmM+IaIYpaop0m6BbZtfq+idzUwJnw7g==", + "dev": true, + "requires": { + "@commitlint/top-level": "^11.0.0", + "fs-extra": "^9.0.0", + "git-raw-commits": "^2.0.0" + } + }, + "@commitlint/resolve-extends": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-11.0.0.tgz", + "integrity": "sha512-WinU6Uv6L7HDGLqn/To13KM1CWvZ09VHZqryqxXa1OY+EvJkfU734CwnOEeNlSCK7FVLrB4kmodLJtL1dkEpXw==", + "dev": true, + "requires": { + "import-fresh": "^3.0.0", + "lodash": "^4.17.19", + "resolve-from": "^5.0.0", + "resolve-global": "^1.0.0" + } + }, + "@commitlint/rules": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-11.0.0.tgz", + "integrity": "sha512-2hD9y9Ep5ZfoNxDDPkQadd2jJeocrwC4vJ98I0g8pNYn/W8hS9+/FuNpolREHN8PhmexXbkjrwyQrWbuC0DVaA==", + "dev": true, + "requires": { + "@commitlint/ensure": "^11.0.0", + "@commitlint/message": "^11.0.0", + "@commitlint/to-lines": "^11.0.0", + "@commitlint/types": "^11.0.0" + } + }, + "@commitlint/to-lines": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@commitlint/to-lines/-/to-lines-11.0.0.tgz", + "integrity": "sha512-TIDTB0Y23jlCNubDROUVokbJk6860idYB5cZkLWcRS9tlb6YSoeLn1NLafPlrhhkkkZzTYnlKYzCVrBNVes1iw==", + "dev": true + }, + "@commitlint/top-level": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@commitlint/top-level/-/top-level-11.0.0.tgz", + "integrity": "sha512-O0nFU8o+Ws+py5pfMQIuyxOtfR/kwtr5ybqTvR+C2lUPer2x6lnQU+OnfD7hPM+A+COIUZWx10mYQvkR3MmtAA==", + "dev": true, + "requires": { + "find-up": "^5.0.0" + }, + "dependencies": { + "find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "requires": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + } + }, + "locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "requires": { + "p-locate": "^5.0.0" + } + }, + "p-limit": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.0.2.tgz", + "integrity": "sha512-iwqZSOoWIW+Ew4kAGUlN16J4M7OB3ysMLSZtnhmqx7njIHFPlxWBX8xo3lVTyFVq6mI/lL9qt2IsN1sHwaxJkg==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "requires": { + "p-limit": "^3.0.2" + } + } + } + }, + "@commitlint/types": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-11.0.0.tgz", + "integrity": "sha512-VoNqai1vR5anRF5Tuh/+SWDFk7xi7oMwHrHrbm1BprYXjB2RJsWLhUrStMssDxEl5lW/z3EUdg8RvH/IUBccSQ==", + "dev": true + }, + "@eslint/eslintrc": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.2.1.tgz", + "integrity": "sha512-XRUeBZ5zBWLYgSANMpThFddrZZkEbGHgUdt5UJjZfnlN9BGCiUBrf+nvbRupSjMvqzwnQN0qwCmOxITt1cfywA==", + "dev": true, + "requires": { + "ajv": "^6.12.4", + "debug": "^4.1.1", + "espree": "^7.3.0", + "globals": "^12.1.0", + "ignore": "^4.0.6", + "import-fresh": "^3.2.1", + "js-yaml": "^3.13.1", + "lodash": "^4.17.19", + "minimatch": "^3.0.4", + "strip-json-comments": "^3.1.1" + }, + "dependencies": { + "ignore": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", + "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", + "dev": true + } + } + }, + "@jest/types": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-25.5.0.tgz", + "integrity": "sha512-OXD0RgQ86Tu3MazKo8bnrkDRaDXXMGUqd+kTtLtK1Zb7CRzQcaSRPPPV37SvYTdevXEBVxe0HXylEjs8ibkmCw==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^1.1.1", + "@types/yargs": "^15.0.0", + "chalk": "^3.0.0" + }, + "dependencies": { + "chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, + "@mdn/browser-compat-data": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@mdn/browser-compat-data/-/browser-compat-data-2.0.7.tgz", + "integrity": "sha512-GeeM827DlzFFidn1eKkMBiqXFD2oLsnZbaiGhByPl0vcapsRzUL+t9hDoov1swc9rB2jw64R+ihtzC8qOE9wXw==", + "dev": true, + "requires": { + "extend": "3.0.2" + } + }, + "@microsoft/api-documenter": { + "version": "7.9.7", + "resolved": "https://registry.npmjs.org/@microsoft/api-documenter/-/api-documenter-7.9.7.tgz", + "integrity": "sha512-wSlBDGbUd4Ld7YB8wmvsZ2yCwVdUh8zXmJHmggpZ13/TF/sForhhjd3MXfj0348h1RpVKE3foPxfxsDP6ON1Eg==", + "dev": true, + "requires": { + "@microsoft/api-extractor-model": "7.10.3", + "@microsoft/tsdoc": "0.12.19", + "@rushstack/node-core-library": "3.34.3", + "@rushstack/ts-command-line": "4.7.3", + "colors": "~1.2.1", + "js-yaml": "~3.13.1", + "resolve": "~1.17.0" + }, + "dependencies": { + "resolve": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.17.0.tgz", + "integrity": "sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w==", + "dev": true, + "requires": { + "path-parse": "^1.0.6" + } + } + } + }, + "@microsoft/api-extractor": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.10.4.tgz", + "integrity": "sha512-vod9Y8IHhBtB3hcKiOe4OLi/hNeWtgRh/mxGrye5SeHaJpu5urAAA9CxLxBT8fDe+pyr1ipzlaiM/eMm2vXKgw==", + "dev": true, + "requires": { + "@microsoft/api-extractor-model": "7.10.3", + "@microsoft/tsdoc": "0.12.19", + "@rushstack/node-core-library": "3.34.3", + "@rushstack/rig-package": "0.2.4", + "@rushstack/ts-command-line": "4.7.3", + "colors": "~1.2.1", + "lodash": "~4.17.15", + "resolve": "~1.17.0", + "semver": "~7.3.0", + "source-map": "~0.6.1", + "typescript": "~3.9.7" + }, + "dependencies": { + "resolve": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.17.0.tgz", + "integrity": "sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w==", + "dev": true, + "requires": { + "path-parse": "^1.0.6" + } + }, + "typescript": { + "version": "3.9.7", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.7.tgz", + "integrity": "sha512-BLbiRkiBzAwsjut4x/dsibSTB6yWpwT5qWmC2OfuCg3GgVQCSgMs4vEctYPhsaGtd0AeuuHMkjZ2h2WG8MSzRw==", + "dev": true + } + } + }, + "@microsoft/api-extractor-model": { + "version": "7.10.3", + "resolved": "https://registry.npmjs.org/@microsoft/api-extractor-model/-/api-extractor-model-7.10.3.tgz", + "integrity": "sha512-etP4NbZpj+zPCuO3YYigIYXkXq5zYhfE3vo/hrCj1OOd/159HDbSHnEQrNWRVy5TR79RAzHvkYAwtLYKeYP8Ag==", + "dev": true, + "requires": { + "@microsoft/tsdoc": "0.12.19", + "@rushstack/node-core-library": "3.34.3" + } + }, + "@microsoft/tsdoc": { + "version": "0.12.19", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.12.19.tgz", + "integrity": "sha512-IpgPxHrNxZiMNUSXqR1l/gePKPkfAmIKoDRP9hp7OwjU29ZR8WCJsOJ8iBKgw0Qk+pFwR+8Y1cy8ImLY6e9m4A==", + "dev": true + }, + "@nodelib/fs.scandir": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.3.tgz", + "integrity": "sha512-eGmwYQn3gxo4r7jdQnkrrN6bY478C3P+a/y72IJukF8LjB6ZHeB3c+Ehacj3sYeSmUXGlnA67/PmbM9CVwL7Dw==", + "dev": true, + "requires": { + "@nodelib/fs.stat": "2.0.3", + "run-parallel": "^1.1.9" + } + }, + "@nodelib/fs.stat": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.3.tgz", + "integrity": "sha512-bQBFruR2TAwoevBEd/NWMoAAtNGzTRgdrqnYCc7dhzfoNvqPzLyqlEQnzZ3kVnNrSp25iyxE00/3h2fqGAGArA==", + "dev": true + }, + "@nodelib/fs.walk": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.4.tgz", + "integrity": "sha512-1V9XOY4rDW0rehzbrcqAmHnz8e7SKvX27gh8Gt2WgB0+pdzdiLV83p72kZPU+jvMbS1qU5mauP2iOvO8rhmurQ==", + "dev": true, + "requires": { + "@nodelib/fs.scandir": "2.1.3", + "fastq": "^1.6.0" + } + }, + "@rollup/plugin-node-resolve": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-8.4.0.tgz", + "integrity": "sha512-LFqKdRLn0ShtQyf6SBYO69bGE1upV6wUhBX0vFOUnLAyzx5cwp8svA0eHUnu8+YU57XOkrMtfG63QOpQx25pHQ==", + "dev": true, + "requires": { + "@rollup/pluginutils": "^3.1.0", + "@types/resolve": "1.17.1", + "builtin-modules": "^3.1.0", + "deep-freeze": "^0.0.1", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.17.0" + } + }, + "@rollup/pluginutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz", + "integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==", + "dev": true, + "requires": { + "@types/estree": "0.0.39", + "estree-walker": "^1.0.1", + "picomatch": "^2.2.2" + } + }, + "@rushstack/node-core-library": { + "version": "3.34.3", + "resolved": "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-3.34.3.tgz", + "integrity": "sha512-WNXHEk5/uoZsbrKzGpYUzDDymJvZarRkByab4uS1fbEcTSDFSVB9e0rREzCkU9yDAQlRutbFwiTXLu3LVR5F6w==", + "dev": true, + "requires": { + "@types/node": "10.17.13", + "colors": "~1.2.1", + "fs-extra": "~7.0.1", + "import-lazy": "~4.0.0", + "jju": "~1.4.0", + "resolve": "~1.17.0", + "semver": "~7.3.0", + "timsort": "~0.3.0", + "z-schema": "~3.18.3" + }, + "dependencies": { + "@types/node": { + "version": "10.17.13", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.13.tgz", + "integrity": "sha512-pMCcqU2zT4TjqYFrWtYHKal7Sl30Ims6ulZ4UFXxI4xbtQqK/qqKwkDoBFCfooRqqmRu9vY3xaJRwxSh673aYg==", + "dev": true + }, + "fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + }, + "jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.6" + } + }, + "resolve": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.17.0.tgz", + "integrity": "sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w==", + "dev": true, + "requires": { + "path-parse": "^1.0.6" + } + }, + "universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true + } + } + }, + "@rushstack/rig-package": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@rushstack/rig-package/-/rig-package-0.2.4.tgz", + "integrity": "sha512-/UXb6N0m0l+5kU4mLmRxyw3we+/w1fesgvfg96xllty5LyQxKDvkscmjlvCU/Yx55WO1tVxN4/7YlNEB2DcHyA==", + "dev": true, + "requires": { + "@types/node": "10.17.13", + "resolve": "~1.17.0", + "strip-json-comments": "~3.1.1" + }, + "dependencies": { + "@types/node": { + "version": "10.17.13", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.13.tgz", + "integrity": "sha512-pMCcqU2zT4TjqYFrWtYHKal7Sl30Ims6ulZ4UFXxI4xbtQqK/qqKwkDoBFCfooRqqmRu9vY3xaJRwxSh673aYg==", + "dev": true + }, + "resolve": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.17.0.tgz", + "integrity": "sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w==", + "dev": true, + "requires": { + "path-parse": "^1.0.6" + } + } + } + }, + "@rushstack/ts-command-line": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-4.7.3.tgz", + "integrity": "sha512-8FNrUSbMgKLgRVcsg1STsIC2xAdyes7qJtVwg36hSnBAMZgCCIM+Z36nnxyrnYTS/6qwiXv7fwVaUxXH+SyiAQ==", + "dev": true, + "requires": { + "@types/argparse": "1.0.38", + "argparse": "~1.0.9", + "colors": "~1.2.1", + "string-argv": "~0.3.1" + } + }, + "@sindresorhus/is": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-0.7.0.tgz", + "integrity": "sha512-ONhaKPIufzzrlNbqtWFFd+jlnemX6lJAgq9ZeiZtS7I1PIf/la7CW4m83rTXRnVnsMbW2k56pGYu7AUFJD9Pow==", + "dev": true + }, + "@sinonjs/commons": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.1.tgz", + "integrity": "sha512-892K+kWUUi3cl+LlqEWIDrhvLgdL79tECi8JZUyq6IviKy/DNhuzCRlbHUjxK89f4ypPMMaFnFuR9Ie6DoIMsw==", + "dev": true, + "requires": { + "type-detect": "4.0.8" + } + }, + "@sinonjs/fake-timers": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-6.0.1.tgz", + "integrity": "sha512-MZPUxrmFubI36XS1DI3qmI0YdN1gks62JtFZvxR67ljjSNCeK6U08Zx4msEWOXuofgqUt6zPHSi1H9fbjR/NRA==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.7.0" + } + }, + "@sinonjs/formatio": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/formatio/-/formatio-5.0.1.tgz", + "integrity": "sha512-KaiQ5pBf1MpS09MuA0kp6KBQt2JUOQycqVG1NZXvzeaXe5LGFqAKueIS0bw4w0P9r7KuBSVdUk5QjXsUdu2CxQ==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1", + "@sinonjs/samsam": "^5.0.2" + } + }, + "@sinonjs/samsam": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-5.3.0.tgz", + "integrity": "sha512-hXpcfx3aq+ETVBwPlRFICld5EnrkexXuXDwqUNhDdr5L8VjvMeSRwyOa0qL7XFmR+jVWR4rUZtnxlG7RX72sBg==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.6.0", + "lodash.get": "^4.4.2", + "type-detect": "^4.0.8" + } + }, + "@sinonjs/text-encoding": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.1.tgz", + "integrity": "sha512-+iTbntw2IZPb/anVDbypzfQa+ay64MW0Zo8aJ8gZPWMMK6/OubMVb6lUPMagqjOPnmtauXnFCACVl3O7ogjeqQ==", + "dev": true + }, + "@szmarczak/http-timer": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.5.tgz", + "integrity": "sha512-PyRA9sm1Yayuj5OIoJ1hGt2YISX45w9WcFbh6ddT0Z/0yaFxOtGLInr4jUfU1EAFVs0Yfyfev4RNwBlUaHdlDQ==", + "dev": true, + "requires": { + "defer-to-connect": "^2.0.0" + } + }, + "@types/accepts": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/accepts/-/accepts-1.3.5.tgz", + "integrity": "sha512-jOdnI/3qTpHABjM5cx1Hc0sKsPoYCp+DP/GJRGtDlPd7fiV9oXGGIcjW/ZOxLIvjGz8MA+uMZI9metHlgqbgwQ==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/argparse": { + "version": "1.0.38", + "resolved": "https://registry.npmjs.org/@types/argparse/-/argparse-1.0.38.tgz", + "integrity": "sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==", + "dev": true + }, + "@types/babel__code-frame": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@types/babel__code-frame/-/babel__code-frame-7.0.2.tgz", + "integrity": "sha512-imO+jT/yjOKOAS5GQZ8SDtwiIloAGGr6OaZDKB0V5JVaSfGZLat5K5/ZRtyKW6R60XHV3RHYPTFfhYb+wDKyKg==", + "dev": true + }, + "@types/body-parser": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.0.tgz", + "integrity": "sha512-W98JrE0j2K78swW4ukqMleo8R7h/pFETjM2DQ90MF6XK2i4LO4W3gQ71Lt4w3bfm2EvVSyWHplECvB5sK22yFQ==", + "dev": true, + "requires": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "@types/cacheable-request": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.1.tgz", + "integrity": "sha512-ykFq2zmBGOCbpIXtoVbz4SKY5QriWPh3AjyU4G74RYbtt5yOc5OfaY75ftjg7mikMOla1CTGpX3lLbuJh8DTrQ==", + "dev": true, + "requires": { + "@types/http-cache-semantics": "*", + "@types/keyv": "*", + "@types/node": "*", + "@types/responselike": "*" + } + }, + "@types/connect": { + "version": "3.4.33", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.33.tgz", + "integrity": "sha512-2+FrkXY4zllzTNfJth7jOqEHC+enpLeGslEhpnTAkg21GkRrWV4SsAtqchtT4YS9/nODBU2/ZfsBY2X4J/dX7A==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/content-disposition": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@types/content-disposition/-/content-disposition-0.5.3.tgz", + "integrity": "sha512-P1bffQfhD3O4LW0ioENXUhZ9OIa0Zn+P7M+pWgkCKaT53wVLSq0mrKksCID/FGHpFhRSxRGhgrQmfhRuzwtKdg==", + "dev": true + }, + "@types/cookies": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@types/cookies/-/cookies-0.7.5.tgz", + "integrity": "sha512-3+TAFSm78O7/bAeYdB8FoYGntuT87vVP9JKuQRL8sRhv9313LP2SpHHL50VeFtnyjIcb3UELddMk5Yt0eOSOkg==", + "dev": true, + "requires": { + "@types/connect": "*", + "@types/express": "*", + "@types/keygrip": "*", + "@types/node": "*" + } + }, + "@types/debug": { + "version": "0.0.31", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-0.0.31.tgz", + "integrity": "sha512-LS1MCPaQKqspg7FvexuhmDbWUhE2yIJ+4AgVIyObfc06/UKZ8REgxGNjZc82wPLWmbeOm7S+gSsLgo75TanG4A==", + "dev": true + }, + "@types/estree": { + "version": "0.0.39", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", + "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==", + "dev": true + }, + "@types/express": { + "version": "4.17.9", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.9.tgz", + "integrity": "sha512-SDzEIZInC4sivGIFY4Sz1GG6J9UObPwCInYJjko2jzOf/Imx/dlpume6Xxwj1ORL82tBbmN4cPDIDkLbWHk9hw==", + "dev": true, + "requires": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "*", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "@types/express-serve-static-core": { + "version": "4.17.13", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.13.tgz", + "integrity": "sha512-RgDi5a4nuzam073lRGKTUIaL3eF2+H7LJvJ8eUnCI0wA6SNjXc44DCmWNiTLs/AZ7QlsFWZiw/gTG3nSQGL0fA==", + "dev": true, + "requires": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*" + } + }, + "@types/glob": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.1.3.tgz", + "integrity": "sha512-SEYeGAIQIQX8NN6LDKprLjbrd5dARM5EXsd8GI/A5l0apYI1fGMWgPHSe4ZKL4eozlAyI+doUE9XbYS4xCkQ1w==", + "dev": true, + "requires": { + "@types/minimatch": "*", + "@types/node": "*" + } + }, + "@types/http-assert": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@types/http-assert/-/http-assert-1.5.1.tgz", + "integrity": "sha512-PGAK759pxyfXE78NbKxyfRcWYA/KwW17X290cNev/qAsn9eQIxkH4shoNBafH37wewhDG/0p1cHPbK6+SzZjWQ==", + "dev": true + }, + "@types/http-cache-semantics": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.0.tgz", + "integrity": "sha512-c3Xy026kOF7QOTn00hbIllV1dLR9hG9NkSrLQgCVs8NF6sBU+VGWjD3wLPhmh1TYAc7ugCFsvHYMN4VcBN1U1A==", + "dev": true + }, + "@types/http-errors": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-1.8.0.tgz", + "integrity": "sha512-2aoSC4UUbHDj2uCsCxcG/vRMXey/m17bC7UwitVm5hn22nI8O8Y9iDpA76Orc+DWkQ4zZrOKEshCqR/jSuXAHA==", + "dev": true + }, + "@types/istanbul-lib-coverage": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz", + "integrity": "sha512-sz7iLqvVUg1gIedBOvlkxPlc8/uVzyS5OwGz1cKjXzkl3FpL3al0crU8YGU1WoHkxn0Wxbw5tyi6hvzJKNzFsw==", + "dev": true + }, + "@types/istanbul-lib-report": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", + "integrity": "sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "*" + } + }, + "@types/istanbul-reports": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-1.1.2.tgz", + "integrity": "sha512-P/W9yOX/3oPZSpaYOCQzGqgCQRXn0FFO/V8bWrCQs+wLmvVVxk6CRBXALEvNs9OHIatlnlFokfhuDo2ug01ciw==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "*", + "@types/istanbul-lib-report": "*" + } + }, + "@types/json-schema": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.6.tgz", + "integrity": "sha512-3c+yGKvVP5Y9TYBEibGNR+kLtijnj7mYrXRg+WpFb2X9xm04g/DXYkfg4hmzJQosc9snFNUPkbYIhu+KAm6jJw==", + "dev": true + }, + "@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha1-7ihweulOEdK4J7y+UnC86n8+ce4=", + "dev": true + }, + "@types/keygrip": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.2.tgz", + "integrity": "sha512-GJhpTepz2udxGexqos8wgaBx4I/zWIDPh/KOGEwAqtuGDkOUJu5eFvwmdBX4AmB8Odsr+9pHCQqiAqDL/yKMKw==", + "dev": true + }, + "@types/keyv": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.1.tgz", + "integrity": "sha512-MPtoySlAZQ37VoLaPcTHCu1RWJ4llDkULYZIzOYxlhxBqYPB0RsRlmMU0R6tahtFe27mIdkHV+551ZWV4PLmVw==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/koa": { + "version": "2.11.6", + "resolved": "https://registry.npmjs.org/@types/koa/-/koa-2.11.6.tgz", + "integrity": "sha512-BhyrMj06eQkk04C97fovEDQMpLpd2IxCB4ecitaXwOKGq78Wi2tooaDOWOFGajPk8IkQOAtMppApgSVkYe1F/A==", + "dev": true, + "requires": { + "@types/accepts": "*", + "@types/content-disposition": "*", + "@types/cookies": "*", + "@types/http-assert": "*", + "@types/http-errors": "*", + "@types/keygrip": "*", + "@types/koa-compose": "*", + "@types/node": "*" + } + }, + "@types/koa-compose": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/@types/koa-compose/-/koa-compose-3.2.5.tgz", + "integrity": "sha512-B8nG/OoE1ORZqCkBVsup/AKcvjdgoHnfi4pZMn5UwAPCbhk/96xyv284eBYW8JlQbQ7zDmnpFr68I/40mFoIBQ==", + "dev": true, + "requires": { + "@types/koa": "*" + } + }, + "@types/mime": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-2.0.3.tgz", + "integrity": "sha512-Jus9s4CDbqwocc5pOAnh8ShfrnMcPHuJYzVcSUU7lrh8Ni5HuIqX3oilL86p3dlTrk0LzHRCgA/GQ7uNCw6l2Q==", + "dev": true + }, + "@types/minimatch": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz", + "integrity": "sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA==", + "dev": true + }, + "@types/minimist": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.1.tgz", + "integrity": "sha512-fZQQafSREFyuZcdWFAExYjBiCL7AUCdgsk80iO0q4yihYYdcIiH28CcuPTGFgLOCC8RlW49GSQxdHwZP+I7CNg==", + "dev": true + }, + "@types/mocha": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-7.0.2.tgz", + "integrity": "sha512-ZvO2tAcjmMi8V/5Z3JsyofMe3hasRcaw88cto5etSVMwVQfeivGAlEYmaQgceUSVYFofVjT+ioHsATjdWcFt1w==", + "dev": true + }, + "@types/node": { + "version": "14.14.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.9.tgz", + "integrity": "sha512-JsoLXFppG62tWTklIoO4knA+oDTYsmqWxHRvd4lpmfQRNhX6osheUOWETP2jMoV/2bEHuMra8Pp3Dmo/stBFcw==" + }, + "@types/normalize-package-data": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz", + "integrity": "sha512-f5j5b/Gf71L+dbqxIpQ4Z2WlmI/mPJ0fOkGGmFgtb6sAu97EPczzbS3/tJKxmcYDj55OX6ssqwDAWOHIYDRDGA==", + "dev": true + }, + "@types/parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==", + "dev": true + }, + "@types/parse5": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@types/parse5/-/parse5-5.0.3.tgz", + "integrity": "sha512-kUNnecmtkunAoQ3CnjmMkzNU/gtxG8guhi+Fk2U/kOpIKjIMKnXGp4IJCgQJrXSgMsWYimYG4TGjz/UzbGEBTw==", + "dev": true + }, + "@types/proxy-from-env": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@types/proxy-from-env/-/proxy-from-env-1.0.1.tgz", + "integrity": "sha512-luG++TFHyS61eKcfkR1CVV6a1GMNXDjtqEQIIfaSHax75xp0HU3SlezjOi1yqubJwrG8e9DeW59n6wTblIDwFg==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/puppeteer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@types/puppeteer/-/puppeteer-5.4.0.tgz", + "integrity": "sha512-zTYDLjnHjgzokrwKt7N0rgn7oZPYo1J0m8Ghu+gXqzLCEn8RWbELa2uprE2UFJ0jU/Sk0x9jXXdOH/5QQLFHhQ==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/puppeteer-core": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/puppeteer-core/-/puppeteer-core-2.0.0.tgz", + "integrity": "sha512-JvoEb7KgEkUet009ZDrtpUER3hheXoHgQByuYpJZ5WWT7LWwMH+0NTqGQXGgoOKzs+G5NA1T4DZwXK79Bhnejw==", + "dev": true, + "requires": { + "@types/puppeteer": "*" + } + }, + "@types/qs": { + "version": "6.9.5", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.5.tgz", + "integrity": "sha512-/JHkVHtx/REVG0VVToGRGH2+23hsYLHdyG+GrvoUGlGAd0ErauXDyvHtRI/7H7mzLm+tBCKA7pfcpkQ1lf58iQ==", + "dev": true + }, + "@types/range-parser": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.3.tgz", + "integrity": "sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA==", + "dev": true + }, + "@types/resolve": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz", + "integrity": "sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/responselike": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.0.tgz", + "integrity": "sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/rimraf": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/rimraf/-/rimraf-2.0.4.tgz", + "integrity": "sha512-8gBudvllD2A/c0CcEX/BivIDorHFt5UI5m46TsNj8DjWCCTTZT74kEe4g+QsY7P/B9WdO98d82zZgXO/RQzu2Q==", + "dev": true, + "requires": { + "@types/glob": "*", + "@types/node": "*" + } + }, + "@types/serve-static": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.8.tgz", + "integrity": "sha512-MoJhSQreaVoL+/hurAZzIm8wafFR6ajiTM1m4A0kv6AGeVBl4r4pOV8bGFrjjq1sGxDTnCoF8i22o0/aE5XCyA==", + "dev": true, + "requires": { + "@types/mime": "*", + "@types/node": "*" + } + }, + "@types/sinon": { + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-9.0.8.tgz", + "integrity": "sha512-IVnI820FZFMGI+u1R+2VdRaD/82YIQTdqLYC9DLPszZuynAJDtCvCtCs3bmyL66s7FqRM3+LPX7DhHnVTaagDw==", + "dev": true, + "requires": { + "@types/sinonjs__fake-timers": "*" + } + }, + "@types/sinonjs__fake-timers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-6.0.2.tgz", + "integrity": "sha512-dIPoZ3g5gcx9zZEszaxLSVTvMReD3xxyyDnQUjA6IYDG9Ba2AV0otMPs+77sG9ojB4Qr2N2Vk5RnKeuA0X/0bg==", + "dev": true + }, + "@types/stack-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-1.0.1.tgz", + "integrity": "sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw==", + "dev": true + }, + "@types/tar-fs": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/@types/tar-fs/-/tar-fs-1.16.3.tgz", + "integrity": "sha512-Y+fdeg11tb9J3UNIatNtrTPM1i8U+WLv2mMhZ3W13mtU19stCgrXJ4iXLkTpoF8jqHi3T/qTS8+fQ3IPzXxpuA==", + "dev": true, + "requires": { + "@types/node": "*", + "@types/tar-stream": "*" + } + }, + "@types/tar-stream": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/tar-stream/-/tar-stream-2.1.0.tgz", + "integrity": "sha512-s1UQxQUVMHbSkCC0X4qdoiWgHF8DoyY1JjQouFsnk/8ysoTdBaiCHud/exoAZzKDbzAXVc+ah6sczxGVMAohFw==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/ws": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-7.4.0.tgz", + "integrity": "sha512-Y29uQ3Uy+58bZrFLhX36hcI3Np37nqWE7ky5tjiDoy1GDZnIwVxS0CgF+s+1bXMzjKBFy+fqaRfb708iNzdinw==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/yargs": { + "version": "15.0.10", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.10.tgz", + "integrity": "sha512-z8PNtlhrj7eJNLmrAivM7rjBESG6JwC5xP3RVk12i/8HVP7Xnx/sEmERnRImyEuUaJfO942X0qMOYsoupaJbZQ==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "@types/yargs-parser": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-15.0.0.tgz", + "integrity": "sha512-FA/BWv8t8ZWJ+gEOnLLd8ygxH/2UFbAvgEonyfN6yWGLKc7zVjbpl2Y4CTjid9h2RfgPP6SEt6uHwEOply00yw==", + "dev": true + }, + "@types/yauzl": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.9.1.tgz", + "integrity": "sha512-A1b8SU4D10uoPjwb0lnHmmu8wZhR9d+9o2PKBQT2jU5YPTKsxac6M2qGAdY7VcL+dHHhARVUDmeg0rOrcd9EjA==", + "optional": true, + "requires": { + "@types/node": "*" + } + }, + "@typescript-eslint/eslint-plugin": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.8.1.tgz", + "integrity": "sha512-d7LeQ7dbUrIv5YVFNzGgaW3IQKMmnmKFneRWagRlGYOSfLJVaRbj/FrBNOBC1a3tVO+TgNq1GbHvRtg1kwL0FQ==", + "dev": true, + "requires": { + "@typescript-eslint/experimental-utils": "4.8.1", + "@typescript-eslint/scope-manager": "4.8.1", + "debug": "^4.1.1", + "functional-red-black-tree": "^1.0.1", + "regexpp": "^3.0.0", + "semver": "^7.3.2", + "tsutils": "^3.17.1" + } + }, + "@typescript-eslint/experimental-utils": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-4.8.1.tgz", + "integrity": "sha512-WigyLn144R3+lGATXW4nNcDJ9JlTkG8YdBWHkDlN0lC3gUGtDi7Pe3h5GPvFKMcRz8KbZpm9FJV9NTW8CpRHpg==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.3", + "@typescript-eslint/scope-manager": "4.8.1", + "@typescript-eslint/types": "4.8.1", + "@typescript-eslint/typescript-estree": "4.8.1", + "eslint-scope": "^5.0.0", + "eslint-utils": "^2.0.0" + } + }, + "@typescript-eslint/parser": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-4.8.1.tgz", + "integrity": "sha512-QND8XSVetATHK9y2Ltc/XBl5Ro7Y62YuZKnPEwnNPB8E379fDsvzJ1dMJ46fg/VOmk0hXhatc+GXs5MaXuL5Uw==", + "dev": true, + "requires": { + "@typescript-eslint/scope-manager": "4.8.1", + "@typescript-eslint/types": "4.8.1", + "@typescript-eslint/typescript-estree": "4.8.1", + "debug": "^4.1.1" + } + }, + "@typescript-eslint/scope-manager": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-4.8.1.tgz", + "integrity": "sha512-r0iUOc41KFFbZdPAdCS4K1mXivnSZqXS5D9oW+iykQsRlTbQRfuFRSW20xKDdYiaCoH+SkSLeIF484g3kWzwOQ==", + "dev": true, + "requires": { + "@typescript-eslint/types": "4.8.1", + "@typescript-eslint/visitor-keys": "4.8.1" + } + }, + "@typescript-eslint/types": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.8.1.tgz", + "integrity": "sha512-ave2a18x2Y25q5K05K/U3JQIe2Av4+TNi/2YuzyaXLAsDx6UZkz1boZ7nR/N6Wwae2PpudTZmHFXqu7faXfHmA==", + "dev": true + }, + "@typescript-eslint/typescript-estree": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-4.8.1.tgz", + "integrity": "sha512-bJ6Fn/6tW2g7WIkCWh3QRlaSU7CdUUK52shx36/J7T5oTQzANvi6raoTsbwGM11+7eBbeem8hCCKbyvAc0X3sQ==", + "dev": true, + "requires": { + "@typescript-eslint/types": "4.8.1", + "@typescript-eslint/visitor-keys": "4.8.1", + "debug": "^4.1.1", + "globby": "^11.0.1", + "is-glob": "^4.0.1", + "lodash": "^4.17.15", + "semver": "^7.3.2", + "tsutils": "^3.17.1" + } + }, + "@typescript-eslint/visitor-keys": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.8.1.tgz", + "integrity": "sha512-3nrwXFdEYALQh/zW8rFwP4QltqsanCDz4CwWMPiIZmwlk9GlvBeueEIbq05SEq4ganqM0g9nh02xXgv5XI3PeQ==", + "dev": true, + "requires": { + "@typescript-eslint/types": "4.8.1", + "eslint-visitor-keys": "^2.0.0" + } + }, + "@ungap/promise-all-settled": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz", + "integrity": "sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q==", + "dev": true + }, + "@web/browser-logs": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@web/browser-logs/-/browser-logs-0.1.5.tgz", + "integrity": "sha512-WXIvoXtugpSYQuredglsbbqsLRXpclFG4nLRebhIRARUNpfOK+1xTglh8HJjdk7bEHTX9jIbnyhtQVxHuo97fg==", + "dev": true + }, + "@web/config-loader": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@web/config-loader/-/config-loader-0.1.2.tgz", + "integrity": "sha512-uh+VL79V/TJjrleAQM66Rke8jKu/J/sqzhiJ08iwGcUKV5lqUbLxGoC4RiNRwcEh8dna9HLBPpi935EuAD5tnw==", + "dev": true, + "requires": { + "semver": "^7.3.2" + } + }, + "@web/dev-server-core": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/@web/dev-server-core/-/dev-server-core-0.2.17.tgz", + "integrity": "sha512-fBxj5cqf+PjkBUMxnMZ4YS9vfARxVfl4cB20nc0y8jwy5zGAE7RPUcVSRoKP+1uWHnXos0oEqth0hphzyJxBbw==", + "dev": true, + "requires": { + "@types/koa": "^2.11.6", + "@types/ws": "^7.2.6", + "@web/parse5-utils": "^1.0.0", + "chokidar": "^3.4.0", + "clone": "^2.1.2", + "es-module-lexer": "^0.3.24", + "get-stream": "^6.0.0", + "is-stream": "^2.0.0", + "isbinaryfile": "^4.0.6", + "koa": "^2.13.0", + "koa-etag": "^3.0.0", + "koa-static": "^5.0.0", + "lru-cache": "^5.1.1", + "mime-types": "^2.1.27", + "parse5": "^6.0.0", + "picomatch": "^2.2.2", + "ws": "^7.3.1" + }, + "dependencies": { + "get-stream": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.0.tgz", + "integrity": "sha512-A1B3Bh1UmL0bidM/YX2NsCOTnGJePL9rO/M+Mw3m9f2gUpfokS0hi5Eah0WSUEWZdZhIZtMjkIYS7mDfOqNHbg==", + "dev": true + }, + "lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "requires": { + "yallist": "^3.0.2" + } + }, + "yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + } + } + }, + "@web/dev-server-esbuild": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@web/dev-server-esbuild/-/dev-server-esbuild-0.2.6.tgz", + "integrity": "sha512-nOtQEI63kPux9vPRcQb2P5323RxuWdSu9GMMiVVRqPqDVxSa8IJHhnzV5yRidGnea9TsgV+caFeTTW0leod34g==", + "dev": true, + "requires": { + "@mdn/browser-compat-data": "^2.0.2", + "@web/dev-server-core": "^0.2.2", + "esbuild": "^0.7.15", + "parse5": "^6.0.1", + "ua-parser-js": "^0.7.21" + } + }, + "@web/dev-server-rollup": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@web/dev-server-rollup/-/dev-server-rollup-0.2.11.tgz", + "integrity": "sha512-cqeXC93BElaKIV0Bel05M86Or5HQKem7FuBjfoMMlgp0irt6ZYCovYe7l2rG0Hw+/MCYaNs+uxjMK5UYLuWMBw==", + "dev": true, + "requires": { + "@web/dev-server-core": "^0.2.17", + "chalk": "^4.1.0", + "parse5": "^6.0.1", + "rollup": "^2.33.2", + "whatwg-url": "^8.1.0" + } + }, + "@web/parse5-utils": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@web/parse5-utils/-/parse5-utils-1.1.2.tgz", + "integrity": "sha512-/JQHbK53BmYiFK2igr2B+Psl2Ivp2ju75Nx1InZweTbxLQNGG9yUBaudER85aqebIH6smkPkKwVtpdBXBiwy1A==", + "dev": true, + "requires": { + "@types/parse5": "^5.0.3", + "parse5": "^6.0.1" + } + }, + "@web/test-runner": { + "version": "0.9.12", + "resolved": "https://registry.npmjs.org/@web/test-runner/-/test-runner-0.9.12.tgz", + "integrity": "sha512-ufpYHVN7GQU3ZN6qAXnstlSVnZ0QAj/gIKdiBwl8eOK1Ucg9wxknpaidTouX/nqdey9MI8qCYIl5wRtfo5iHMg==", + "dev": true, + "requires": { + "@rollup/plugin-node-resolve": "^8.4.0", + "@web/config-loader": "^0.1.2", + "@web/dev-server-rollup": "^0.2.9", + "@web/test-runner-chrome": "^0.7.3", + "@web/test-runner-cli": "^0.6.13", + "@web/test-runner-commands": "^0.2.1", + "@web/test-runner-core": "^0.8.11", + "@web/test-runner-mocha": "^0.5.1", + "@web/test-runner-saucelabs": "^0.1.1", + "command-line-args": "^5.1.1", + "deepmerge": "^4.2.2", + "globby": "^11.0.1" + } + }, + "@web/test-runner-chrome": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@web/test-runner-chrome/-/test-runner-chrome-0.7.3.tgz", + "integrity": "sha512-wf4SZ8nPTontLML/QwAmsR/+e9+rcuPiirbz63ulsBpX939Kn6LuOPPF4axL66gxJJ/BP++PJeCWtsCnowzveA==", + "dev": true, + "requires": { + "@types/puppeteer-core": "^2.0.0", + "@web/test-runner-core": "^0.8.11", + "@web/test-runner-coverage-v8": "^0.2.3", + "chrome-launcher": "^0.13.4", + "puppeteer-core": "^5.3.1" + } + }, + "@web/test-runner-cli": { + "version": "0.6.13", + "resolved": "https://registry.npmjs.org/@web/test-runner-cli/-/test-runner-cli-0.6.13.tgz", + "integrity": "sha512-c2Hm+n0CxpyHoch+17ODmpepYmTWZ1YleG3J7nCwLGzvnI3umv5pi/82AFcuMYJnW+9R2YYfhW+8bHKX7Mq6Cw==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.10.4", + "@types/babel__code-frame": "^7.0.2", + "@web/browser-logs": "^0.1.2", + "@web/config-loader": "^0.1.1", + "@web/test-runner-chrome": "^0.7.3", + "@web/test-runner-core": "^0.8.11", + "camelcase": "^6.0.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "command-line-args": "^5.1.1", + "command-line-usage": "^6.1.0", + "convert-source-map": "^1.7.0", + "deepmerge": "^4.2.2", + "diff": "^4.0.2", + "globby": "^11.0.1", + "ip": "^1.1.5", + "istanbul-lib-report": "^3.0.0", + "istanbul-reports": "^3.0.2", + "log-update": "^4.0.0", + "open": "^7.0.4", + "portfinder": "^1.0.28", + "source-map": "^0.7.3" + }, + "dependencies": { + "camelcase": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.2.0.tgz", + "integrity": "sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg==", + "dev": true + }, + "source-map": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", + "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==", + "dev": true + } + } + }, + "@web/test-runner-commands": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@web/test-runner-commands/-/test-runner-commands-0.2.1.tgz", + "integrity": "sha512-usbgVGIihxfHIPv1FPDueEzET51SM8X6ZVZY+k8MgFzab018JyhqvkDVEgRVmSfg1gWKvqRdA5Qpr11b+GGBeA==", + "dev": true, + "requires": { + "@web/test-runner-core": "^0.8.4" + } + }, + "@web/test-runner-core": { + "version": "0.8.11", + "resolved": "https://registry.npmjs.org/@web/test-runner-core/-/test-runner-core-0.8.11.tgz", + "integrity": "sha512-NyCm9tNaUFkO9P7sI9/z/E7CSP4sxlEnygHmo9mqbWOGW3aNsasqEcYHxQgGMW1HJrohDCigWRJZhMSFNOYd1Q==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.10.4", + "@web/browser-logs": "^0.1.5", + "@web/dev-server-core": "^0.2.11", + "co-body": "^6.0.0", + "debounce": "^1.2.0", + "dependency-graph": "^0.9.0", + "globby": "^11.0.1", + "istanbul-lib-coverage": "^3.0.0", + "picomatch": "^2.2.2", + "uuid": "^8.1.0" + } + }, + "@web/test-runner-coverage-v8": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@web/test-runner-coverage-v8/-/test-runner-coverage-v8-0.2.3.tgz", + "integrity": "sha512-vfDjbENCJsDxY2NwAclOKC5y9MkI+KIx0U/JSPZsowzx2dYZHOqKQfRknZa2nyC9wf3pqbBUrgy0g231t5nkyQ==", + "dev": true, + "requires": { + "@web/test-runner-core": "^0.8.11", + "istanbul-lib-coverage": "^3.0.0", + "v8-to-istanbul": "^7.0.0" + } + }, + "@web/test-runner-mocha": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@web/test-runner-mocha/-/test-runner-mocha-0.5.1.tgz", + "integrity": "sha512-/8VBh3nP2sE/a320CfRI/J1TLT8ChPy0xGBafh3gg59+jOTtHCfZzHet7nre/VTOOvu1H24XEZiyhMFud6WVXQ==", + "dev": true, + "requires": { + "@types/mocha": "^8.0.1", + "@web/test-runner-core": "^0.8.4" + }, + "dependencies": { + "@types/mocha": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-8.0.4.tgz", + "integrity": "sha512-M4BwiTJjHmLq6kjON7ZoI2JMlBvpY3BYSdiP6s/qCT3jb1s9/DeJF0JELpAxiVSIxXDzfNKe+r7yedMIoLbknQ==", + "dev": true + } + } + }, + "@web/test-runner-saucelabs": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@web/test-runner-saucelabs/-/test-runner-saucelabs-0.1.3.tgz", + "integrity": "sha512-f0i3vn96WKs9zwpghUCkuYztALaNv5m5on8NTj7JoLY00FhVpdxbeanpB4Ej5i9xTu6aelmKoILPK88KIE+dAw==", + "dev": true, + "requires": { + "@web/dev-server-esbuild": "^0.2.4", + "@web/test-runner-selenium": "^0.3.3", + "ip": "^1.1.5", + "saucelabs": "^4.5.0", + "selenium-webdriver": "^4.0.0-alpha.7", + "uuid": "^8.3.0" + } + }, + "@web/test-runner-selenium": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@web/test-runner-selenium/-/test-runner-selenium-0.3.3.tgz", + "integrity": "sha512-KyHH7ZZ0SkyAXmwpx+lTCuDs6/YXnqHn5mSdhNw2oYA6ZHrPWOdw8bvLXJItfLBGkJc0nn6hs9bqYp9qdyHHVA==", + "dev": true, + "requires": { + "@web/test-runner-core": "^0.8.4", + "selenium-webdriver": "^4.0.0-alpha.7" + } + }, + "JSONStream": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.5.tgz", + "integrity": "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==", + "dev": true, + "requires": { + "jsonparse": "^1.2.0", + "through": ">=2.2.7 <3" + } + }, + "accepts": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz", + "integrity": "sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==", + "dev": true, + "requires": { + "mime-types": "~2.1.24", + "negotiator": "0.6.2" + } + }, + "acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "dev": true + }, + "acorn-jsx": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.1.tgz", + "integrity": "sha512-K0Ptm/47OKfQRpNQ2J/oIN/3QYiK6FwW+eJbILhsdxh2WTLdl+30o8aGdTbm5JbffpFFAg/g+zi1E+jvJha5ng==", + "dev": true + }, + "add-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/add-stream/-/add-stream-1.0.0.tgz", + "integrity": "sha1-anmQQ3ynNtXhKI25K9MmbV9csqo=", + "dev": true + }, + "agent-base": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-5.1.1.tgz", + "integrity": "sha512-TMeqbNl2fMW0nMjTEPOwe3J/PRFP4vqeoNuQMG0HlMrtm5QxKqdvAkZ1pRBQ/ulIyDD5Yq0nJ7YbdD8ey0TO3g==" + }, + "ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "ansi-colors": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", + "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==", + "dev": true + }, + "ansi-escapes": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.1.tgz", + "integrity": "sha512-JWF7ocqNrp8u9oqpgV+wH5ftbt+cfvv+PTjOvKLT3AdYly/LmORARfEVT1iyjwN+4MqE5UmVKoAdIBqeoCHgLA==", + "dev": true, + "requires": { + "type-fest": "^0.11.0" + }, + "dependencies": { + "type-fest": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.11.0.tgz", + "integrity": "sha512-OdjXJxnCN1AvyLSzeKIgXTXxV+99ZuXl3Hpo9XpJAv9MBcHrrJOQ5kV7ypXOuQie+AmWG25hLbiKdwYTifzcfQ==", + "dev": true + } + } + }, + "ansi-regex": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", + "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", + "dev": true + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha1-q8av7tzqUugJzcA3au0845Y10X8=", + "dev": true + }, + "anymatch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.1.tgz", + "integrity": "sha512-mM8522psRCqzV+6LhomX5wgp25YVibjh8Wj23I5RPkPppSVSjyKD2A2mBJmWGa+KN7f2D6LNh9jkBCeyLktzjg==", + "dev": true, + "requires": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + } + }, + "arch": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz", + "integrity": "sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==", + "dev": true + }, + "archive-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/archive-type/-/archive-type-4.0.0.tgz", + "integrity": "sha1-+S5yIzBW38aWlHJ0nCZ72wRrHXA=", + "dev": true, + "requires": { + "file-type": "^4.2.0" + }, + "dependencies": { + "file-type": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-4.4.0.tgz", + "integrity": "sha1-G2AOX8ofvcboDApwxxyNul95BsU=", + "dev": true + } + } + }, + "arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true + }, + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "requires": { + "sprintf-js": "~1.0.2" + } + }, + "array-back": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/array-back/-/array-back-3.1.0.tgz", + "integrity": "sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==", + "dev": true + }, + "array-find-index": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.2.tgz", + "integrity": "sha1-3wEKoSh+Fku9pvlyOwqWoexBh6E=", + "dev": true + }, + "array-ify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz", + "integrity": "sha1-nlKHYrSpBmrRY6aWKjZEGOlibs4=", + "dev": true + }, + "array-includes": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.1.tgz", + "integrity": "sha512-c2VXaCHl7zPsvpkFsw4nxvFie4fh1ur9bpcgsVkIjqn0H/Xwdg+7fv3n2r/isyS8EBj5b06M9kHyZuIr4El6WQ==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.0", + "is-string": "^1.0.5" + } + }, + "array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true + }, + "array.prototype.flat": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.2.4.tgz", + "integrity": "sha512-4470Xi3GAPAjZqFcljX2xzckv1qeKPizoNkiS0+O4IoPR2ZNpcjE0pkhdihlDouK+x6QOast26B4Q/O9DJnwSg==", + "dev": true, + "requires": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3", + "es-abstract": "^1.18.0-next.1" + }, + "dependencies": { + "es-abstract": { + "version": "1.18.0-next.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.18.0-next.1.tgz", + "integrity": "sha512-I4UGspA0wpZXWENrdA0uHbnhte683t3qT/1VFH9aX2dA5PPSf6QW5HHXf5HImaqPmjXaVeVk4RGWnaylmV7uAA==", + "dev": true, + "requires": { + "es-to-primitive": "^1.2.1", + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1", + "is-callable": "^1.2.2", + "is-negative-zero": "^2.0.0", + "is-regex": "^1.1.1", + "object-inspect": "^1.8.0", + "object-keys": "^1.1.1", + "object.assign": "^4.1.1", + "string.prototype.trimend": "^1.0.1", + "string.prototype.trimstart": "^1.0.1" + } + } + } + }, + "arrify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", + "integrity": "sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0=", + "dev": true + }, + "astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true + }, + "async": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.3.tgz", + "integrity": "sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==", + "dev": true, + "requires": { + "lodash": "^4.17.14" + } + }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", + "dev": true + }, + "at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true + }, + "babel-eslint": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/babel-eslint/-/babel-eslint-10.1.0.tgz", + "integrity": "sha512-ifWaTHQ0ce+448CYop8AdrQiBsGrnC+bMgfyKFdi6EsPLTAWG+QfyDeM6OH+FmWnKvEq5NnBMLvlBUPKQZoDSg==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "@babel/parser": "^7.7.0", + "@babel/traverse": "^7.7.0", + "@babel/types": "^7.7.0", + "eslint-visitor-keys": "^1.0.0", + "resolve": "^1.12.0" + }, + "dependencies": { + "eslint-visitor-keys": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", + "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", + "dev": true + } + } + }, + "balanced-match": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" + }, + "base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" + }, + "bin-check": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bin-check/-/bin-check-4.1.0.tgz", + "integrity": "sha512-b6weQyEUKsDGFlACWSIOfveEnImkJyK/FGW6FAG42loyoquvjdtOIqO6yBFzHyqyVVhNgNkQxxx09SFLK28YnA==", + "dev": true, + "requires": { + "execa": "^0.7.0", + "executable": "^4.1.0" + } + }, + "bin-version": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/bin-version/-/bin-version-3.1.0.tgz", + "integrity": "sha512-Mkfm4iE1VFt4xd4vH+gx+0/71esbfus2LsnCGe8Pi4mndSPyT+NGES/Eg99jx8/lUGWfu3z2yuB/bt5UB+iVbQ==", + "dev": true, + "requires": { + "execa": "^1.0.0", + "find-versions": "^3.0.0" + }, + "dependencies": { + "cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "requires": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + } + }, + "execa": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz", + "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==", + "dev": true, + "requires": { + "cross-spawn": "^6.0.0", + "get-stream": "^4.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + } + }, + "get-stream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", + "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "dev": true, + "requires": { + "pump": "^3.0.0" + } + }, + "is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=", + "dev": true + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + } + } + }, + "bin-version-check": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/bin-version-check/-/bin-version-check-4.0.0.tgz", + "integrity": "sha512-sR631OrhC+1f8Cvs8WyVWOA33Y8tgwjETNPyyD/myRBXLkfS/vl74FmH/lFcRl9KY3zwGh7jFhvyk9vV3/3ilQ==", + "dev": true, + "requires": { + "bin-version": "^3.0.0", + "semver": "^5.6.0", + "semver-truncate": "^1.1.2" + }, + "dependencies": { + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + } + } + }, + "bin-wrapper": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bin-wrapper/-/bin-wrapper-4.1.0.tgz", + "integrity": "sha512-hfRmo7hWIXPkbpi0ZltboCMVrU+0ClXR/JgbCKKjlDjQf6igXa7OwdqNcFWQZPZTgiY7ZpzE3+LjjkLiTN2T7Q==", + "dev": true, + "requires": { + "bin-check": "^4.1.0", + "bin-version-check": "^4.0.0", + "download": "^7.1.0", + "import-lazy": "^3.1.0", + "os-filter-obj": "^2.0.0", + "pify": "^4.0.1" + }, + "dependencies": { + "import-lazy": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-3.1.0.tgz", + "integrity": "sha512-8/gvXvX2JMn0F+CDlSC4l6kOmVaLOO3XLkksI7CI3Ud95KDYJuYur2b9P/PUt/i/pDAMd/DulQsNbbbmRRsDIQ==", + "dev": true + } + } + }, + "binary-extensions": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.1.0.tgz", + "integrity": "sha512-1Yj8h9Q+QDF5FzhMs/c9+6UntbD5MkRfRwac8DoEm9ZfUBZ7tZ55YcGVAzEe4bXsdQHEk+s9S5wsOKVdZrw0tQ==", + "dev": true + }, + "bl": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.0.3.tgz", + "integrity": "sha512-fs4G6/Hu4/EE+F75J8DuN/0IpQqNjAdC7aEQv7Qt8MHGUH7Ckv2MwTEEeN9QehD0pfIDkMI1bkHYkKy7xHyKIg==", + "requires": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "requires": { + "fill-range": "^7.0.1" + } + }, + "browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", + "dev": true + }, + "buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "requires": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "buffer-alloc": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/buffer-alloc/-/buffer-alloc-1.2.0.tgz", + "integrity": "sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==", + "dev": true, + "requires": { + "buffer-alloc-unsafe": "^1.1.0", + "buffer-fill": "^1.0.0" + } + }, + "buffer-alloc-unsafe": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz", + "integrity": "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==", + "dev": true + }, + "buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=" + }, + "buffer-fill": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz", + "integrity": "sha1-+PeLdniYiO858gXNY39o5wISKyw=", + "dev": true + }, + "buffer-from": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", + "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==", + "dev": true + }, + "builtin-modules": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.1.0.tgz", + "integrity": "sha512-k0KL0aWZuBt2lrxrcASWDfwOLMnodeQjodT/1SxEQAXsHANgo6ZC/VEaSEHCXt7aSTZ4/4H5LKa+tBXmW7Vtvw==", + "dev": true + }, + "bytes": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz", + "integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==", + "dev": true + }, + "cache-content-type": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cache-content-type/-/cache-content-type-1.0.1.tgz", + "integrity": "sha512-IKufZ1o4Ut42YUrZSo8+qnMTrFuKkvyoLXUywKz9GJ5BrhOFGhLdkx9sG4KAnVvbY6kEcSFjLQul+DVmBm2bgA==", + "dev": true, + "requires": { + "mime-types": "^2.1.18", + "ylru": "^1.2.0" + } + }, + "cacheable-lookup": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.3.tgz", + "integrity": "sha512-W+JBqF9SWe18A72XFzN/V/CULFzPm7sBXzzR6ekkE+3tLG72wFZrBiBZhrZuDoYexop4PHJVdFAKb/Nj9+tm9w==", + "dev": true + }, + "cacheable-request": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-2.1.4.tgz", + "integrity": "sha1-DYCIAbY0KtM8kd+dC0TcCbkeXD0=", + "dev": true, + "requires": { + "clone-response": "1.0.2", + "get-stream": "3.0.0", + "http-cache-semantics": "3.8.1", + "keyv": "3.0.0", + "lowercase-keys": "1.0.0", + "normalize-url": "2.0.1", + "responselike": "1.0.2" + }, + "dependencies": { + "get-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", + "integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=", + "dev": true + }, + "lowercase-keys": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.0.tgz", + "integrity": "sha1-TjNms55/VFfjXxMkvfb4jQv8cwY=", + "dev": true + } + } + }, + "call-bind": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.0.tgz", + "integrity": "sha512-AEXsYIyyDY3MCzbwdhzG3Jx1R0J2wetQyUynn6dYHAO+bg8l1k7jwZtRv4ryryFs7EP+NDlikJlVe59jr0cM2w==", + "dev": true, + "requires": { + "function-bind": "^1.1.1", + "get-intrinsic": "^1.0.0" + } + }, + "callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true + }, + "camel-case": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.1.tgz", + "integrity": "sha512-7fa2WcG4fYFkclIvEmxBbTvmibwF2/agfEBc6q3lOpVu0A13ltLsA+Hr/8Hp6kp5f+G7hKi6t8lys6XxP+1K6Q==", + "dev": true, + "requires": { + "pascal-case": "^3.1.1", + "tslib": "^1.10.0" + } + }, + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true + }, + "camelcase-keys": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-6.2.2.tgz", + "integrity": "sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg==", + "dev": true, + "requires": { + "camelcase": "^5.3.1", + "map-obj": "^4.0.0", + "quick-lru": "^4.0.1" + } + }, + "capital-case": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/capital-case/-/capital-case-1.0.3.tgz", + "integrity": "sha512-OlUSJpUr7SY0uZFOxcwnDOU7/MpHlKTZx2mqnDYQFrDudXLFm0JJ9wr/l4csB+rh2Ug0OPuoSO53PqiZBqno9A==", + "dev": true, + "requires": { + "no-case": "^3.0.3", + "tslib": "^1.10.0", + "upper-case-first": "^2.0.1" + } + }, + "caw": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/caw/-/caw-2.0.1.tgz", + "integrity": "sha512-Cg8/ZSBEa8ZVY9HspcGUYaK63d/bN7rqS3CYCzEGUxuYv6UlmcjzDUz2fCFFHyTvUW5Pk0I+3hkA3iXlIj6guA==", + "dev": true, + "requires": { + "get-proxy": "^2.0.0", + "isurl": "^1.0.0-alpha5", + "tunnel-agent": "^0.6.0", + "url-to-options": "^1.0.1" + } + }, + "chalk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "change-case": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-4.1.1.tgz", + "integrity": "sha512-qRlUWn/hXnX1R1LBDF/RelJLiqNjKjUqlmuBVSEIyye8kq49CXqkZWKmi8XeUAdDXWFOcGLUMZ+aHn3Q5lzUXw==", + "dev": true, + "requires": { + "camel-case": "^4.1.1", + "capital-case": "^1.0.3", + "constant-case": "^3.0.3", + "dot-case": "^3.0.3", + "header-case": "^2.0.3", + "no-case": "^3.0.3", + "param-case": "^3.0.3", + "pascal-case": "^3.1.1", + "path-case": "^3.0.3", + "sentence-case": "^3.0.3", + "snake-case": "^3.0.3", + "tslib": "^1.10.0" + } + }, + "chokidar": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.4.3.tgz", + "integrity": "sha512-DtM3g7juCXQxFVSNPNByEC2+NImtBuxQQvWlHunpJIS5Ocr0lG306cC7FCi7cEA0fzmybPUIl4txBIobk1gGOQ==", + "dev": true, + "requires": { + "anymatch": "~3.1.1", + "braces": "~3.0.2", + "fsevents": "~2.1.2", + "glob-parent": "~5.1.0", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.5.0" + } + }, + "chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" + }, + "chrome-launcher": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-0.13.4.tgz", + "integrity": "sha512-nnzXiDbGKjDSK6t2I+35OAPBy5Pw/39bgkb/ZAFwMhwJbdYBp6aH+vW28ZgtjdU890Q7D+3wN/tB8N66q5Gi2A==", + "dev": true, + "requires": { + "@types/node": "*", + "escape-string-regexp": "^1.0.5", + "is-wsl": "^2.2.0", + "lighthouse-logger": "^1.0.0", + "mkdirp": "^0.5.3", + "rimraf": "^3.0.2" + } + }, + "ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", + "dev": true + }, + "clean-regexp": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/clean-regexp/-/clean-regexp-1.0.0.tgz", + "integrity": "sha1-jffHquUf02h06PjQW5GAvBGj/tc=", + "dev": true, + "requires": { + "escape-string-regexp": "^1.0.5" + } + }, + "cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "requires": { + "restore-cursor": "^3.1.0" + } + }, + "cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "dev": true, + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18=", + "dev": true + }, + "clone-response": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.2.tgz", + "integrity": "sha1-0dyXOSAxTfZ/vrlCI7TuNQI56Ws=", + "dev": true, + "requires": { + "mimic-response": "^1.0.0" + } + }, + "co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=", + "dev": true + }, + "co-body": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/co-body/-/co-body-6.1.0.tgz", + "integrity": "sha512-m7pOT6CdLN7FuXUcpuz/8lfQ/L77x8SchHCF4G0RBTJO20Wzmhn5Sp4/5WsKy8OSpifBSUrmg83qEqaDHdyFuQ==", + "dev": true, + "requires": { + "inflation": "^2.0.0", + "qs": "^6.5.2", + "raw-body": "^2.3.3", + "type-is": "^1.6.16" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "colors": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.2.5.tgz", + "integrity": "sha512-erNRLao/Y3Fv54qUa0LBB+//Uf3YwMUmdJinN20yMXm9zdKKqH9wt7R9IIVZ+K7ShzfpLV/Zg8+VyrBJYB4lpg==", + "dev": true + }, + "combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "requires": { + "delayed-stream": "~1.0.0" + } + }, + "command-line-args": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/command-line-args/-/command-line-args-5.1.1.tgz", + "integrity": "sha512-hL/eG8lrll1Qy1ezvkant+trihbGnaKaeEjj6Scyr3DN+RC7iQ5Rz84IeLERfAWDGo0HBSNAakczwgCilDXnWg==", + "dev": true, + "requires": { + "array-back": "^3.0.1", + "find-replace": "^3.0.0", + "lodash.camelcase": "^4.3.0", + "typical": "^4.0.0" + } + }, + "command-line-usage": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/command-line-usage/-/command-line-usage-6.1.1.tgz", + "integrity": "sha512-F59pEuAR9o1SF/bD0dQBDluhpT4jJQNWUHEuVBqpDmCUo6gPjCi+m9fCWnWZVR/oG6cMTUms4h+3NPl74wGXvA==", + "dev": true, + "requires": { + "array-back": "^4.0.1", + "chalk": "^2.4.2", + "table-layout": "^1.0.1", + "typical": "^5.2.0" + }, + "dependencies": { + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "array-back": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/array-back/-/array-back-4.0.1.tgz", + "integrity": "sha512-Z/JnaVEXv+A9xabHzN43FiiiWEE7gPCRXMrVmRm00tWbjZRul1iHm7ECzlyNq1p4a4ATXz+G9FJ3GqGOkOV3fg==", + "dev": true + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", + "dev": true + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + }, + "typical": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/typical/-/typical-5.2.0.tgz", + "integrity": "sha512-dvdQgNDNJo+8B2uBQoqdb11eUCE1JQXhvjC/CZtgvZseVd5TYMXnq0+vuUemXbd/Se29cTaUuPX3YIc2xgbvIg==", + "dev": true + } + } + }, + "commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true + }, + "commonmark": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/commonmark/-/commonmark-0.28.1.tgz", + "integrity": "sha1-Buq41SM4uDn6Gi11rwCF7tGxvq4=", + "dev": true, + "requires": { + "entities": "~ 1.1.1", + "mdurl": "~ 1.0.1", + "minimist": "~ 1.2.0", + "string.prototype.repeat": "^0.2.0" + } + }, + "compare-func": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz", + "integrity": "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==", + "dev": true, + "requires": { + "array-ify": "^1.0.0", + "dot-prop": "^5.1.0" + } + }, + "compare-versions": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-3.6.0.tgz", + "integrity": "sha512-W6Af2Iw1z4CB7q4uU4hv646dW9GQuBM+YpC0UvUCWSD8w90SJjp+ujJuXaEMtAXBtSqGfMPuFOVn4/+FlaqfBA==", + "dev": true + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" + }, + "concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "dev": true, + "requires": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "config-chain": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.12.tgz", + "integrity": "sha512-a1eOIcu8+7lUInge4Rpf/n4Krkf3Dd9lqhljRzII1/Zno/kRtUWnznPO3jOKBmTEktkt3fkxisUcivoj0ebzoA==", + "dev": true, + "requires": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "constant-case": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/constant-case/-/constant-case-3.0.3.tgz", + "integrity": "sha512-FXtsSnnrFYpzDmvwDGQW+l8XK3GV1coLyBN0eBz16ZUzGaZcT2ANVCJmLeuw2GQgxKHQIe9e0w2dzkSfaRlUmA==", + "dev": true, + "requires": { + "no-case": "^3.0.3", + "tslib": "^1.10.0", + "upper-case": "^2.0.1" + } + }, + "contains-path": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/contains-path/-/contains-path-0.1.0.tgz", + "integrity": "sha1-/ozxhP9mcLa67wGp1IYaXL7EEgo=", + "dev": true + }, + "content-disposition": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.3.tgz", + "integrity": "sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==", + "dev": true, + "requires": { + "safe-buffer": "5.1.2" + }, + "dependencies": { + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + } + } + }, + "content-type": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", + "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==", + "dev": true + }, + "conventional-changelog": { + "version": "3.1.23", + "resolved": "https://registry.npmjs.org/conventional-changelog/-/conventional-changelog-3.1.23.tgz", + "integrity": "sha512-sScUu2NHusjRC1dPc5p8/b3kT78OYr95/Bx7Vl8CPB8tF2mG1xei5iylDTRjONV5hTlzt+Cn/tBWrKdd299b7A==", + "dev": true, + "requires": { + "conventional-changelog-angular": "^5.0.11", + "conventional-changelog-atom": "^2.0.7", + "conventional-changelog-codemirror": "^2.0.7", + "conventional-changelog-conventionalcommits": "^4.4.0", + "conventional-changelog-core": "^4.2.0", + "conventional-changelog-ember": "^2.0.8", + "conventional-changelog-eslint": "^3.0.8", + "conventional-changelog-express": "^2.0.5", + "conventional-changelog-jquery": "^3.0.10", + "conventional-changelog-jshint": "^2.0.8", + "conventional-changelog-preset-loader": "^2.3.4" + } + }, + "conventional-changelog-angular": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-5.0.12.tgz", + "integrity": "sha512-5GLsbnkR/7A89RyHLvvoExbiGbd9xKdKqDTrArnPbOqBqG/2wIosu0fHwpeIRI8Tl94MhVNBXcLJZl92ZQ5USw==", + "dev": true, + "requires": { + "compare-func": "^2.0.0", + "q": "^1.5.1" + } + }, + "conventional-changelog-atom": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/conventional-changelog-atom/-/conventional-changelog-atom-2.0.8.tgz", + "integrity": "sha512-xo6v46icsFTK3bb7dY/8m2qvc8sZemRgdqLb/bjpBsH2UyOS8rKNTgcb5025Hri6IpANPApbXMg15QLb1LJpBw==", + "dev": true, + "requires": { + "q": "^1.5.1" + } + }, + "conventional-changelog-codemirror": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/conventional-changelog-codemirror/-/conventional-changelog-codemirror-2.0.8.tgz", + "integrity": "sha512-z5DAsn3uj1Vfp7po3gpt2Boc+Bdwmw2++ZHa5Ak9k0UKsYAO5mH1UBTN0qSCuJZREIhX6WU4E1p3IW2oRCNzQw==", + "dev": true, + "requires": { + "q": "^1.5.1" + } + }, + "conventional-changelog-config-spec": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-config-spec/-/conventional-changelog-config-spec-2.1.0.tgz", + "integrity": "sha512-IpVePh16EbbB02V+UA+HQnnPIohgXvJRxHcS5+Uwk4AT5LjzCZJm5sp/yqs5C6KZJ1jMsV4paEV13BN1pvDuxQ==", + "dev": true + }, + "conventional-changelog-conventionalcommits": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-4.5.0.tgz", + "integrity": "sha512-buge9xDvjjOxJlyxUnar/+6i/aVEVGA7EEh4OafBCXPlLUQPGbRUBhBUveWRxzvR8TEjhKEP4BdepnpG2FSZXw==", + "dev": true, + "requires": { + "compare-func": "^2.0.0", + "lodash": "^4.17.15", + "q": "^1.5.1" + } + }, + "conventional-changelog-core": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/conventional-changelog-core/-/conventional-changelog-core-4.2.1.tgz", + "integrity": "sha512-8cH8/DEoD3e5Q6aeogdR5oaaKs0+mG6+f+Om0ZYt3PNv7Zo0sQhu4bMDRsqAF+UTekTAtP1W/C41jH/fkm8Jtw==", + "dev": true, + "requires": { + "add-stream": "^1.0.0", + "conventional-changelog-writer": "^4.0.18", + "conventional-commits-parser": "^3.2.0", + "dateformat": "^3.0.0", + "get-pkg-repo": "^1.0.0", + "git-raw-commits": "2.0.0", + "git-remote-origin-url": "^2.0.0", + "git-semver-tags": "^4.1.1", + "lodash": "^4.17.15", + "normalize-package-data": "^3.0.0", + "q": "^1.5.1", + "read-pkg": "^3.0.0", + "read-pkg-up": "^3.0.0", + "shelljs": "^0.8.3", + "through2": "^4.0.0" + }, + "dependencies": { + "camelcase": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-4.1.0.tgz", + "integrity": "sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0=", + "dev": true + }, + "camelcase-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-4.2.0.tgz", + "integrity": "sha1-oqpfsa9oh1glnDLBQUJteJI7m3c=", + "dev": true, + "requires": { + "camelcase": "^4.1.0", + "map-obj": "^2.0.0", + "quick-lru": "^1.0.0" + } + }, + "dargs": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/dargs/-/dargs-4.1.0.tgz", + "integrity": "sha1-A6nbtLXC8Tm/FK5T8LiipqhvThc=", + "dev": true, + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "find-up": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz", + "integrity": "sha1-RdG35QbHF93UgndaK3eSCjwMV6c=", + "dev": true, + "requires": { + "locate-path": "^2.0.0" + } + }, + "git-raw-commits": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-2.0.0.tgz", + "integrity": "sha512-w4jFEJFgKXMQJ0H0ikBk2S+4KP2VEjhCvLCNqbNRQC8BgGWgLKNCO7a9K9LI+TVT7Gfoloje502sEnctibffgg==", + "dev": true, + "requires": { + "dargs": "^4.0.1", + "lodash.template": "^4.0.2", + "meow": "^4.0.0", + "split2": "^2.0.0", + "through2": "^2.0.0" + }, + "dependencies": { + "through2": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", + "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", + "dev": true, + "requires": { + "readable-stream": "~2.3.6", + "xtend": "~4.0.1" + } + } + } + }, + "hosted-git-info": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.8.tgz", + "integrity": "sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg==", + "dev": true + }, + "indent-string": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-3.2.0.tgz", + "integrity": "sha1-Sl/W0nzDMvN+VBmlBNu4NxBckok=", + "dev": true + }, + "load-json-file": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", + "integrity": "sha1-L19Fq5HjMhYjT9U62rZo607AmTs=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "parse-json": "^4.0.0", + "pify": "^3.0.0", + "strip-bom": "^3.0.0" + } + }, + "locate-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz", + "integrity": "sha1-K1aLJl7slExtnA3pw9u7ygNUzY4=", + "dev": true, + "requires": { + "p-locate": "^2.0.0", + "path-exists": "^3.0.0" + } + }, + "map-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-2.0.0.tgz", + "integrity": "sha1-plzSkIepJZi4eRJXpSPgISIqwfk=", + "dev": true + }, + "meow": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/meow/-/meow-4.0.1.tgz", + "integrity": "sha512-xcSBHD5Z86zaOc+781KrupuHAzeGXSLtiAOmBsiLDiPSaYSB6hdew2ng9EBAnZ62jagG9MHAOdxpDi/lWBFJ/A==", + "dev": true, + "requires": { + "camelcase-keys": "^4.0.0", + "decamelize-keys": "^1.0.0", + "loud-rejection": "^1.0.0", + "minimist": "^1.1.3", + "minimist-options": "^3.0.1", + "normalize-package-data": "^2.3.4", + "read-pkg-up": "^3.0.0", + "redent": "^2.0.0", + "trim-newlines": "^2.0.0" + }, + "dependencies": { + "normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "requires": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + } + } + }, + "minimist-options": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/minimist-options/-/minimist-options-3.0.2.tgz", + "integrity": "sha512-FyBrT/d0d4+uiZRbqznPXqw3IpZZG3gl3wKWiX784FycUKVwBt0uLBFkQrtE4tZOrgo78nZp2jnKz3L65T5LdQ==", + "dev": true, + "requires": { + "arrify": "^1.0.1", + "is-plain-obj": "^1.1.0" + } + }, + "p-limit": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz", + "integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==", + "dev": true, + "requires": { + "p-try": "^1.0.0" + } + }, + "p-locate": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz", + "integrity": "sha1-IKAQOyIqcMj9OcwuWAaA893l7EM=", + "dev": true, + "requires": { + "p-limit": "^1.1.0" + } + }, + "p-try": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", + "integrity": "sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M=", + "dev": true + }, + "parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=", + "dev": true, + "requires": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + } + }, + "path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=", + "dev": true + }, + "path-type": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", + "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==", + "dev": true, + "requires": { + "pify": "^3.0.0" + } + }, + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", + "dev": true + }, + "quick-lru": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-1.1.0.tgz", + "integrity": "sha1-Q2CxfGETatOAeDl/8RQW4Ybc+7g=", + "dev": true + }, + "read-pkg": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", + "integrity": "sha1-nLxoaXj+5l0WwA4rGcI3/Pbjg4k=", + "dev": true, + "requires": { + "load-json-file": "^4.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^3.0.0" + }, + "dependencies": { + "normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "requires": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + } + } + }, + "read-pkg-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-3.0.0.tgz", + "integrity": "sha1-PtSWaF26D4/hGNBpHcUfSh/5bwc=", + "dev": true, + "requires": { + "find-up": "^2.0.0", + "read-pkg": "^3.0.0" + } + }, + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "redent": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-2.0.0.tgz", + "integrity": "sha1-wbIAe0LVfrE4kHmzyDM2OdXhzKo=", + "dev": true, + "requires": { + "indent-string": "^3.0.0", + "strip-indent": "^2.0.0" + } + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.0" + } + }, + "strip-indent": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-2.0.0.tgz", + "integrity": "sha1-XvjbKV0B5u1sv3qrlpmNeCJSe2g=", + "dev": true + }, + "trim-newlines": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-2.0.0.tgz", + "integrity": "sha1-tAPQuRvlDDMd/EuC7s6yLD3hbSA=", + "dev": true + } + } + }, + "conventional-changelog-ember": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/conventional-changelog-ember/-/conventional-changelog-ember-2.0.9.tgz", + "integrity": "sha512-ulzIReoZEvZCBDhcNYfDIsLTHzYHc7awh+eI44ZtV5cx6LVxLlVtEmcO+2/kGIHGtw+qVabJYjdI5cJOQgXh1A==", + "dev": true, + "requires": { + "q": "^1.5.1" + } + }, + "conventional-changelog-eslint": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/conventional-changelog-eslint/-/conventional-changelog-eslint-3.0.9.tgz", + "integrity": "sha512-6NpUCMgU8qmWmyAMSZO5NrRd7rTgErjrm4VASam2u5jrZS0n38V7Y9CzTtLT2qwz5xEChDR4BduoWIr8TfwvXA==", + "dev": true, + "requires": { + "q": "^1.5.1" + } + }, + "conventional-changelog-express": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/conventional-changelog-express/-/conventional-changelog-express-2.0.6.tgz", + "integrity": "sha512-SDez2f3iVJw6V563O3pRtNwXtQaSmEfTCaTBPCqn0oG0mfkq0rX4hHBq5P7De2MncoRixrALj3u3oQsNK+Q0pQ==", + "dev": true, + "requires": { + "q": "^1.5.1" + } + }, + "conventional-changelog-jquery": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/conventional-changelog-jquery/-/conventional-changelog-jquery-3.0.11.tgz", + "integrity": "sha512-x8AWz5/Td55F7+o/9LQ6cQIPwrCjfJQ5Zmfqi8thwUEKHstEn4kTIofXub7plf1xvFA2TqhZlq7fy5OmV6BOMw==", + "dev": true, + "requires": { + "q": "^1.5.1" + } + }, + "conventional-changelog-jshint": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/conventional-changelog-jshint/-/conventional-changelog-jshint-2.0.9.tgz", + "integrity": "sha512-wMLdaIzq6TNnMHMy31hql02OEQ8nCQfExw1SE0hYL5KvU+JCTuPaDO+7JiogGT2gJAxiUGATdtYYfh+nT+6riA==", + "dev": true, + "requires": { + "compare-func": "^2.0.0", + "q": "^1.5.1" + } + }, + "conventional-changelog-preset-loader": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/conventional-changelog-preset-loader/-/conventional-changelog-preset-loader-2.3.4.tgz", + "integrity": "sha512-GEKRWkrSAZeTq5+YjUZOYxdHq+ci4dNwHvpaBC3+ENalzFWuCWa9EZXSuZBpkr72sMdKB+1fyDV4takK1Lf58g==", + "dev": true + }, + "conventional-changelog-writer": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/conventional-changelog-writer/-/conventional-changelog-writer-4.0.18.tgz", + "integrity": "sha512-mAQDCKyB9HsE8Ko5cCM1Jn1AWxXPYV0v8dFPabZRkvsiWUul2YyAqbIaoMKF88Zf2ffnOPSvKhboLf3fnjo5/A==", + "dev": true, + "requires": { + "compare-func": "^2.0.0", + "conventional-commits-filter": "^2.0.7", + "dateformat": "^3.0.0", + "handlebars": "^4.7.6", + "json-stringify-safe": "^5.0.1", + "lodash": "^4.17.15", + "meow": "^8.0.0", + "semver": "^6.0.0", + "split": "^1.0.0", + "through2": "^4.0.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "conventional-commits-filter": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/conventional-commits-filter/-/conventional-commits-filter-2.0.7.tgz", + "integrity": "sha512-ASS9SamOP4TbCClsRHxIHXRfcGCnIoQqkvAzCSbZzTFLfcTqJVugB0agRgsEELsqaeWgsXv513eS116wnlSSPA==", + "dev": true, + "requires": { + "lodash.ismatch": "^4.4.0", + "modify-values": "^1.0.0" + } + }, + "conventional-commits-parser": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-3.2.0.tgz", + "integrity": "sha512-XmJiXPxsF0JhAKyfA2Nn+rZwYKJ60nanlbSWwwkGwLQFbugsc0gv1rzc7VbbUWAzJfR1qR87/pNgv9NgmxtBMQ==", + "dev": true, + "requires": { + "JSONStream": "^1.0.4", + "is-text-path": "^1.0.1", + "lodash": "^4.17.15", + "meow": "^8.0.0", + "split2": "^2.0.0", + "through2": "^4.0.0", + "trim-off-newlines": "^1.0.0" + } + }, + "conventional-recommended-bump": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/conventional-recommended-bump/-/conventional-recommended-bump-6.0.10.tgz", + "integrity": "sha512-2ibrqAFMN3ZA369JgVoSbajdD/BHN6zjY7DZFKTHzyzuQejDUCjQ85S5KHxCRxNwsbDJhTPD5hOKcis/jQhRgg==", + "dev": true, + "requires": { + "concat-stream": "^2.0.0", + "conventional-changelog-preset-loader": "^2.3.4", + "conventional-commits-filter": "^2.0.6", + "conventional-commits-parser": "^3.1.0", + "git-raw-commits": "2.0.0", + "git-semver-tags": "^4.1.0", + "meow": "^7.0.0", + "q": "^1.5.1" + }, + "dependencies": { + "camelcase": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-4.1.0.tgz", + "integrity": "sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0=", + "dev": true + }, + "camelcase-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-4.2.0.tgz", + "integrity": "sha1-oqpfsa9oh1glnDLBQUJteJI7m3c=", + "dev": true, + "requires": { + "camelcase": "^4.1.0", + "map-obj": "^2.0.0", + "quick-lru": "^1.0.0" + } + }, + "dargs": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/dargs/-/dargs-4.1.0.tgz", + "integrity": "sha1-A6nbtLXC8Tm/FK5T8LiipqhvThc=", + "dev": true, + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "find-up": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz", + "integrity": "sha1-RdG35QbHF93UgndaK3eSCjwMV6c=", + "dev": true, + "requires": { + "locate-path": "^2.0.0" + } + }, + "git-raw-commits": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-2.0.0.tgz", + "integrity": "sha512-w4jFEJFgKXMQJ0H0ikBk2S+4KP2VEjhCvLCNqbNRQC8BgGWgLKNCO7a9K9LI+TVT7Gfoloje502sEnctibffgg==", + "dev": true, + "requires": { + "dargs": "^4.0.1", + "lodash.template": "^4.0.2", + "meow": "^4.0.0", + "split2": "^2.0.0", + "through2": "^2.0.0" + }, + "dependencies": { + "meow": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/meow/-/meow-4.0.1.tgz", + "integrity": "sha512-xcSBHD5Z86zaOc+781KrupuHAzeGXSLtiAOmBsiLDiPSaYSB6hdew2ng9EBAnZ62jagG9MHAOdxpDi/lWBFJ/A==", + "dev": true, + "requires": { + "camelcase-keys": "^4.0.0", + "decamelize-keys": "^1.0.0", + "loud-rejection": "^1.0.0", + "minimist": "^1.1.3", + "minimist-options": "^3.0.1", + "normalize-package-data": "^2.3.4", + "read-pkg-up": "^3.0.0", + "redent": "^2.0.0", + "trim-newlines": "^2.0.0" + } + } + } + }, + "hosted-git-info": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.8.tgz", + "integrity": "sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg==", + "dev": true + }, + "indent-string": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-3.2.0.tgz", + "integrity": "sha1-Sl/W0nzDMvN+VBmlBNu4NxBckok=", + "dev": true + }, + "load-json-file": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", + "integrity": "sha1-L19Fq5HjMhYjT9U62rZo607AmTs=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "parse-json": "^4.0.0", + "pify": "^3.0.0", + "strip-bom": "^3.0.0" + } + }, + "locate-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz", + "integrity": "sha1-K1aLJl7slExtnA3pw9u7ygNUzY4=", + "dev": true, + "requires": { + "p-locate": "^2.0.0", + "path-exists": "^3.0.0" + } + }, + "map-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-2.0.0.tgz", + "integrity": "sha1-plzSkIepJZi4eRJXpSPgISIqwfk=", + "dev": true + }, + "meow": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/meow/-/meow-7.1.1.tgz", + "integrity": "sha512-GWHvA5QOcS412WCo8vwKDlTelGLsCGBVevQB5Kva961rmNfun0PCbv5+xta2kUMFJyR8/oWnn7ddeKdosbAPbA==", + "dev": true, + "requires": { + "@types/minimist": "^1.2.0", + "camelcase-keys": "^6.2.2", + "decamelize-keys": "^1.1.0", + "hard-rejection": "^2.1.0", + "minimist-options": "4.1.0", + "normalize-package-data": "^2.5.0", + "read-pkg-up": "^7.0.1", + "redent": "^3.0.0", + "trim-newlines": "^3.0.0", + "type-fest": "^0.13.1", + "yargs-parser": "^18.1.3" + }, + "dependencies": { + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true + }, + "camelcase-keys": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-6.2.2.tgz", + "integrity": "sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg==", + "dev": true, + "requires": { + "camelcase": "^5.3.1", + "map-obj": "^4.0.0", + "quick-lru": "^4.0.1" + } + }, + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } + }, + "map-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.1.0.tgz", + "integrity": "sha512-glc9y00wgtwcDmp7GaE/0b0OnxpNJsVf3ael/An6Fe2Q51LLwN1er6sdomLRzz5h0+yMpiYLhWYF5R7HeqVd4g==", + "dev": true + }, + "minimist-options": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz", + "integrity": "sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==", + "dev": true, + "requires": { + "arrify": "^1.0.1", + "is-plain-obj": "^1.1.0", + "kind-of": "^6.0.3" + } + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + } + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true + }, + "parse-json": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.1.0.tgz", + "integrity": "sha512-+mi/lmVVNKFNVyLXV31ERiy2CY5E1/F6QtJFEzoChPRwwngMNXRDQ9GJ5WdE2Z2P4AujsOi0/+2qHID68KwfIQ==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + } + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true + }, + "quick-lru": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz", + "integrity": "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==", + "dev": true + }, + "read-pkg": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", + "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==", + "dev": true, + "requires": { + "@types/normalize-package-data": "^2.4.0", + "normalize-package-data": "^2.5.0", + "parse-json": "^5.0.0", + "type-fest": "^0.6.0" + }, + "dependencies": { + "type-fest": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz", + "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==", + "dev": true + } + } + }, + "read-pkg-up": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz", + "integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==", + "dev": true, + "requires": { + "find-up": "^4.1.0", + "read-pkg": "^5.2.0", + "type-fest": "^0.8.1" + }, + "dependencies": { + "type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "dev": true + } + } + }, + "redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "requires": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + } + }, + "strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "requires": { + "min-indent": "^1.0.0" + } + }, + "trim-newlines": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-3.0.0.tgz", + "integrity": "sha512-C4+gOpvmxaSMKuEf9Qc134F1ZuOHVXKRbtEflf4NTtuuJDEIJ9p5PXsalL8SkeRw+qit1Mo+yuvMPAKwWg/1hA==", + "dev": true + } + } + }, + "minimist-options": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/minimist-options/-/minimist-options-3.0.2.tgz", + "integrity": "sha512-FyBrT/d0d4+uiZRbqznPXqw3IpZZG3gl3wKWiX784FycUKVwBt0uLBFkQrtE4tZOrgo78nZp2jnKz3L65T5LdQ==", + "dev": true, + "requires": { + "arrify": "^1.0.1", + "is-plain-obj": "^1.1.0" + } + }, + "normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "requires": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "p-limit": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz", + "integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==", + "dev": true, + "requires": { + "p-try": "^1.0.0" + } + }, + "p-locate": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz", + "integrity": "sha1-IKAQOyIqcMj9OcwuWAaA893l7EM=", + "dev": true, + "requires": { + "p-limit": "^1.1.0" + } + }, + "p-try": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", + "integrity": "sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M=", + "dev": true + }, + "parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=", + "dev": true, + "requires": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + } + }, + "path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=", + "dev": true + }, + "path-type": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", + "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==", + "dev": true, + "requires": { + "pify": "^3.0.0" + } + }, + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", + "dev": true + }, + "quick-lru": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-1.1.0.tgz", + "integrity": "sha1-Q2CxfGETatOAeDl/8RQW4Ybc+7g=", + "dev": true + }, + "read-pkg": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", + "integrity": "sha1-nLxoaXj+5l0WwA4rGcI3/Pbjg4k=", + "dev": true, + "requires": { + "load-json-file": "^4.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^3.0.0" + } + }, + "read-pkg-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-3.0.0.tgz", + "integrity": "sha1-PtSWaF26D4/hGNBpHcUfSh/5bwc=", + "dev": true, + "requires": { + "find-up": "^2.0.0", + "read-pkg": "^3.0.0" + } + }, + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "redent": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-2.0.0.tgz", + "integrity": "sha1-wbIAe0LVfrE4kHmzyDM2OdXhzKo=", + "dev": true, + "requires": { + "indent-string": "^3.0.0", + "strip-indent": "^2.0.0" + } + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.0" + } + }, + "strip-indent": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-2.0.0.tgz", + "integrity": "sha1-XvjbKV0B5u1sv3qrlpmNeCJSe2g=", + "dev": true + }, + "through2": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", + "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", + "dev": true, + "requires": { + "readable-stream": "~2.3.6", + "xtend": "~4.0.1" + } + }, + "trim-newlines": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-2.0.0.tgz", + "integrity": "sha1-tAPQuRvlDDMd/EuC7s6yLD3hbSA=", + "dev": true + }, + "type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true + }, + "yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "dev": true, + "requires": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "dependencies": { + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true + } + } + } + } + }, + "convert-source-map": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.7.0.tgz", + "integrity": "sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.1" + }, + "dependencies": { + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + } + } + }, + "cookies": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.8.0.tgz", + "integrity": "sha512-8aPsApQfebXnuI+537McwYsDtjVxGm8gTIzQI3FDW6t5t/DAhERxtnbEPN/8RX+uZthoz4eCOgloXaE5cYyNow==", + "dev": true, + "requires": { + "depd": "~2.0.0", + "keygrip": "~1.1.0" + }, + "dependencies": { + "depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true + } + } + }, + "core-js": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.7.0.tgz", + "integrity": "sha512-NwS7fI5M5B85EwpWuIwJN4i/fbisQUwLwiSNUWeXlkAZ0sbBjLEvLvFLf1uzAUV66PcEPt4xCGCmOZSxVf3xzA==", + "dev": true + }, + "core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", + "dev": true + }, + "cosmiconfig": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.0.tgz", + "integrity": "sha512-pondGvTuVYDk++upghXJabWzL6Kxu6f26ljFw64Swq9v6sQPUL3EUlVDV56diOjpCayKihL6hVe8exIACU4XcA==", + "dev": true, + "requires": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + } + }, + "cross-env": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.2.tgz", + "integrity": "sha512-KZP/bMEOJEDCkDQAyRhu3RL2ZO/SUVrxQVI0G3YEQ+OLbRA3c6zgixe8Mq8a/z7+HKlNEjo8oiLUs8iRijY2Rw==", + "dev": true, + "requires": { + "cross-spawn": "^7.0.1" + }, + "dependencies": { + "cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "requires": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + } + }, + "path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true + }, + "shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "requires": { + "shebang-regex": "^3.0.0" + } + }, + "shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true + }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + } + } + }, + "cross-spawn": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz", + "integrity": "sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk=", + "dev": true, + "requires": { + "lru-cache": "^4.0.1", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "dependencies": { + "lru-cache": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz", + "integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==", + "dev": true, + "requires": { + "pseudomap": "^1.0.2", + "yallist": "^2.1.2" + } + }, + "yallist": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", + "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=", + "dev": true + } + } + }, + "currently-unhandled": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/currently-unhandled/-/currently-unhandled-0.4.1.tgz", + "integrity": "sha1-mI3zP+qxke95mmE2nddsF635V+o=", + "dev": true, + "requires": { + "array-find-index": "^1.0.1" + } + }, + "dargs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/dargs/-/dargs-7.0.0.tgz", + "integrity": "sha512-2iy1EkLdlBzQGvbweYRFxmFath8+K7+AKB0TlhHWkNuH+TmovaMH/Wp7V7R4u7f4SnX3OgLsU9t1NI9ioDnUpg==", + "dev": true + }, + "dateformat": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-3.0.3.tgz", + "integrity": "sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q==", + "dev": true + }, + "debounce": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.0.tgz", + "integrity": "sha512-mYtLl1xfZLi1m4RtQYlZgJUNQjl4ZxVnHzIR8nLLgi4q1YT8o/WM+MK/f8yfcc9s5Ir5zRaPZyZU6xs1Syoocg==", + "dev": true + }, + "debug": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", + "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", + "requires": { + "ms": "2.1.2" + } + }, + "decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=", + "dev": true + }, + "decamelize-keys": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/decamelize-keys/-/decamelize-keys-1.1.0.tgz", + "integrity": "sha1-0XGoeTMlKAfrPLYdwcFEXQeN8tk=", + "dev": true, + "requires": { + "decamelize": "^1.1.0", + "map-obj": "^1.0.0" + }, + "dependencies": { + "map-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz", + "integrity": "sha1-2TPOuSBdgr3PSIb2dCvcK03qFG0=", + "dev": true + } + } + }, + "decode-uri-component": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz", + "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=", + "dev": true + }, + "decompress": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/decompress/-/decompress-4.2.1.tgz", + "integrity": "sha512-e48kc2IjU+2Zw8cTb6VZcJQ3lgVbS4uuB1TfCHbiZIP/haNXm+SVyhu+87jts5/3ROpd82GSVCoNs/z8l4ZOaQ==", + "dev": true, + "requires": { + "decompress-tar": "^4.0.0", + "decompress-tarbz2": "^4.0.0", + "decompress-targz": "^4.0.0", + "decompress-unzip": "^4.0.1", + "graceful-fs": "^4.1.10", + "make-dir": "^1.0.0", + "pify": "^2.3.0", + "strip-dirs": "^2.0.0" + }, + "dependencies": { + "make-dir": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz", + "integrity": "sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==", + "dev": true, + "requires": { + "pify": "^3.0.0" + }, + "dependencies": { + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", + "dev": true + } + } + }, + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", + "dev": true + } + } + }, + "decompress-response": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-3.3.0.tgz", + "integrity": "sha1-gKTdMjdIOEv6JICDYirt7Jgq3/M=", + "dev": true, + "requires": { + "mimic-response": "^1.0.0" + } + }, + "decompress-tar": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/decompress-tar/-/decompress-tar-4.1.1.tgz", + "integrity": "sha512-JdJMaCrGpB5fESVyxwpCx4Jdj2AagLmv3y58Qy4GE6HMVjWz1FeVQk1Ct4Kye7PftcdOo/7U7UKzYBJgqnGeUQ==", + "dev": true, + "requires": { + "file-type": "^5.2.0", + "is-stream": "^1.1.0", + "tar-stream": "^1.5.2" + }, + "dependencies": { + "bl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/bl/-/bl-1.2.3.tgz", + "integrity": "sha512-pvcNpa0UU69UT341rO6AYy4FVAIkUHuZXRIWbq+zHnsVcRzDDjIAhGuuYoi0d//cwIwtt4pkpKycWEfjdV+vww==", + "dev": true, + "requires": { + "readable-stream": "^2.3.5", + "safe-buffer": "^5.1.1" + } + }, + "file-type": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-5.2.0.tgz", + "integrity": "sha1-LdvqfHP/42No365J3DOMBYwritY=", + "dev": true + }, + "is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=", + "dev": true + }, + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + }, + "dependencies": { + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + } + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.0" + }, + "dependencies": { + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + } + } + }, + "tar-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-1.6.2.tgz", + "integrity": "sha512-rzS0heiNf8Xn7/mpdSVVSMAWAoy9bfb1WOTYC78Z0UQKeKa/CWS8FOq0lKGNa8DWKAn9gxjCvMLYc5PGXYlK2A==", + "dev": true, + "requires": { + "bl": "^1.0.0", + "buffer-alloc": "^1.2.0", + "end-of-stream": "^1.0.0", + "fs-constants": "^1.0.0", + "readable-stream": "^2.3.0", + "to-buffer": "^1.1.1", + "xtend": "^4.0.0" + } + } + } + }, + "decompress-tarbz2": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/decompress-tarbz2/-/decompress-tarbz2-4.1.1.tgz", + "integrity": "sha512-s88xLzf1r81ICXLAVQVzaN6ZmX4A6U4z2nMbOwobxkLoIIfjVMBg7TeguTUXkKeXni795B6y5rnvDw7rxhAq9A==", + "dev": true, + "requires": { + "decompress-tar": "^4.1.0", + "file-type": "^6.1.0", + "is-stream": "^1.1.0", + "seek-bzip": "^1.0.5", + "unbzip2-stream": "^1.0.9" + }, + "dependencies": { + "file-type": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-6.2.0.tgz", + "integrity": "sha512-YPcTBDV+2Tm0VqjybVd32MHdlEGAtuxS3VAYsumFokDSMG+ROT5wawGlnHDoz7bfMcMDt9hxuXvXwoKUx2fkOg==", + "dev": true + }, + "is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=", + "dev": true + } + } + }, + "decompress-targz": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/decompress-targz/-/decompress-targz-4.1.1.tgz", + "integrity": "sha512-4z81Znfr6chWnRDNfFNqLwPvm4db3WuZkqV+UgXQzSngG3CEKdBkw5jrv3axjjL96glyiiKjsxJG3X6WBZwX3w==", + "dev": true, + "requires": { + "decompress-tar": "^4.1.1", + "file-type": "^5.2.0", + "is-stream": "^1.1.0" + }, + "dependencies": { + "file-type": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-5.2.0.tgz", + "integrity": "sha1-LdvqfHP/42No365J3DOMBYwritY=", + "dev": true + }, + "is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=", + "dev": true + } + } + }, + "decompress-unzip": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/decompress-unzip/-/decompress-unzip-4.0.1.tgz", + "integrity": "sha1-3qrM39FK6vhVePczroIQ+bSEj2k=", + "dev": true, + "requires": { + "file-type": "^3.8.0", + "get-stream": "^2.2.0", + "pify": "^2.3.0", + "yauzl": "^2.4.2" + }, + "dependencies": { + "file-type": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-3.9.0.tgz", + "integrity": "sha1-JXoHg4TR24CHvESdEH1SpSZyuek=", + "dev": true + }, + "get-stream": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-2.3.1.tgz", + "integrity": "sha1-Xzj5PzRgCWZu4BUKBUFn+Rvdld4=", + "dev": true, + "requires": { + "object-assign": "^4.0.1", + "pinkie-promise": "^2.0.0" + } + }, + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", + "dev": true + } + } + }, + "deep-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz", + "integrity": "sha1-9dJgKStmDghO/0zbyfCK0yR0SLU=", + "dev": true + }, + "deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true + }, + "deep-freeze": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/deep-freeze/-/deep-freeze-0.0.1.tgz", + "integrity": "sha1-OgsABd4YZygZ39OM0x+RF5yJPoQ=", + "dev": true + }, + "deep-is": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz", + "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=", + "dev": true + }, + "deepmerge": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", + "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==", + "dev": true + }, + "defer-to-connect": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.0.tgz", + "integrity": "sha512-bYL2d05vOSf1JEZNx5vSAtPuBMkX8K9EUutg7zlKvTqKXHt7RhWJFbmd7qakVuf13i+IkGmp6FwSsONOf6VYIg==", + "dev": true + }, + "define-properties": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz", + "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==", + "dev": true, + "requires": { + "object-keys": "^1.0.12" + } + }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", + "dev": true + }, + "delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=", + "dev": true + }, + "depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=", + "dev": true + }, + "dependency-graph": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/dependency-graph/-/dependency-graph-0.9.0.tgz", + "integrity": "sha512-9YLIBURXj4DJMFALxXw9K3Y3rwb5Fk0X5/8ipCzaN84+gKxoHK43tVKRNakCQbiEx07E8Uwhuq21BpUagFhZ8w==", + "dev": true + }, + "destroy": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", + "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=", + "dev": true + }, + "detect-indent": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.0.0.tgz", + "integrity": "sha512-oSyFlqaTHCItVRGK5RmrmjB+CmaMOW7IaNA/kdxqhoa6d17j/5ce9O9eWXmV/KEdRwqpQA+Vqe8a8Bsybu4YnA==", + "dev": true + }, + "detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true + }, + "devtools-protocol": { + "version": "0.0.818844", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.818844.tgz", + "integrity": "sha512-AD1hi7iVJ8OD0aMLQU5VK0XH9LDlA1+BcPIgrAxPfaibx2DbWucuyOhc4oyQCbnvDDO68nN6/LcKfqTP343Jjg==" + }, + "diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true + }, + "diff-sequences": { + "version": "25.2.6", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-25.2.6.tgz", + "integrity": "sha512-Hq8o7+6GaZeoFjtpgvRBUknSXNeJiCx7V9Fr94ZMljNiCr9n9L8H8aJqgWOQiDDGdyn29fRNcDdRVJ5fdyihfg==", + "dev": true + }, + "dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "requires": { + "path-type": "^4.0.0" + } + }, + "doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "requires": { + "esutils": "^2.0.2" + } + }, + "dot-case": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.3.tgz", + "integrity": "sha512-7hwEmg6RiSQfm/GwPL4AAWXKy3YNNZA3oFv2Pdiey0mwkRCPZ9x6SZbkLcn8Ma5PYeVokzoD4Twv2n7LKp5WeA==", + "dev": true, + "requires": { + "no-case": "^3.0.3", + "tslib": "^1.10.0" + } + }, + "dot-prop": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", + "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", + "dev": true, + "requires": { + "is-obj": "^2.0.0" + } + }, + "dotgitignore": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/dotgitignore/-/dotgitignore-2.1.0.tgz", + "integrity": "sha512-sCm11ak2oY6DglEPpCB8TixLjWAxd3kJTs6UIcSasNYxXdFPV+YKlye92c8H4kKFqV5qYMIh7d+cYecEg0dIkA==", + "dev": true, + "requires": { + "find-up": "^3.0.0", + "minimatch": "^3.0.4" + }, + "dependencies": { + "find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "requires": { + "locate-path": "^3.0.0" + } + }, + "locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "requires": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + } + }, + "p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "requires": { + "p-limit": "^2.0.0" + } + }, + "path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=", + "dev": true + } + } + }, + "download": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/download/-/download-7.1.0.tgz", + "integrity": "sha512-xqnBTVd/E+GxJVrX5/eUJiLYjCGPwMpdL+jGhGU57BvtcA7wwhtHVbXBeUk51kOpW3S7Jn3BQbN9Q1R1Km2qDQ==", + "dev": true, + "requires": { + "archive-type": "^4.0.0", + "caw": "^2.0.1", + "content-disposition": "^0.5.2", + "decompress": "^4.2.0", + "ext-name": "^5.0.0", + "file-type": "^8.1.0", + "filenamify": "^2.0.0", + "get-stream": "^3.0.0", + "got": "^8.3.1", + "make-dir": "^1.2.0", + "p-event": "^2.1.0", + "pify": "^3.0.0" + }, + "dependencies": { + "get-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", + "integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=", + "dev": true + }, + "got": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/got/-/got-8.3.2.tgz", + "integrity": "sha512-qjUJ5U/hawxosMryILofZCkm3C84PLJS/0grRIpjAwu+Lkxxj5cxeCU25BG0/3mDSpXKTyZr8oh8wIgLaH0QCw==", + "dev": true, + "requires": { + "@sindresorhus/is": "^0.7.0", + "cacheable-request": "^2.1.1", + "decompress-response": "^3.3.0", + "duplexer3": "^0.1.4", + "get-stream": "^3.0.0", + "into-stream": "^3.1.0", + "is-retry-allowed": "^1.1.0", + "isurl": "^1.0.0-alpha5", + "lowercase-keys": "^1.0.0", + "mimic-response": "^1.0.0", + "p-cancelable": "^0.4.0", + "p-timeout": "^2.0.1", + "pify": "^3.0.0", + "safe-buffer": "^5.1.1", + "timed-out": "^4.0.1", + "url-parse-lax": "^3.0.0", + "url-to-options": "^1.0.1" + } + }, + "make-dir": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz", + "integrity": "sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==", + "dev": true, + "requires": { + "pify": "^3.0.0" + } + }, + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", + "dev": true + } + } + }, + "duplexer3": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.4.tgz", + "integrity": "sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=", + "dev": true + }, + "ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=", + "dev": true + }, + "emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=", + "dev": true + }, + "end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "requires": { + "once": "^1.4.0" + } + }, + "enquirer": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz", + "integrity": "sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==", + "dev": true, + "requires": { + "ansi-colors": "^4.1.1" + } + }, + "entities": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/entities/-/entities-1.1.2.tgz", + "integrity": "sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==", + "dev": true + }, + "error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "requires": { + "is-arrayish": "^0.2.1" + } + }, + "es-abstract": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.7.tgz", + "integrity": "sha512-VBl/gnfcJ7OercKA9MVaegWsBHFjV492syMudcnQZvt/Dw8ezpcOHYZXa/J96O8vx+g4x65YKhxOwDUh63aS5g==", + "dev": true, + "requires": { + "es-to-primitive": "^1.2.1", + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1", + "is-callable": "^1.2.2", + "is-regex": "^1.1.1", + "object-inspect": "^1.8.0", + "object-keys": "^1.1.1", + "object.assign": "^4.1.1", + "string.prototype.trimend": "^1.0.1", + "string.prototype.trimstart": "^1.0.1" + } + }, + "es-module-lexer": { + "version": "0.3.26", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.3.26.tgz", + "integrity": "sha512-Va0Q/xqtrss45hWzP8CZJwzGSZJjDM5/MJRE3IXXnUCcVLElR9BRaE9F62BopysASyc4nM3uwhSW7FFB9nlWAA==", + "dev": true + }, + "es-to-primitive": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "dev": true, + "requires": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + } + }, + "esbuild": { + "version": "0.7.22", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.7.22.tgz", + "integrity": "sha512-B43SYg8LGWYTCv9Gs0RnuLNwjzpuWOoCaZHTWEDEf5AfrnuDMerPVMdCEu7xOdhFvQ+UqfP2MGU9lxEy0JzccA==", + "dev": true + }, + "escalade": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", + "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "dev": true + }, + "escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=", + "dev": true + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true + }, + "eslint": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.13.0.tgz", + "integrity": "sha512-uCORMuOO8tUzJmsdRtrvcGq5qposf7Rw0LwkTJkoDbOycVQtQjmnhZSuLQnozLE4TmAzlMVV45eCHmQ1OpDKUQ==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "@eslint/eslintrc": "^0.2.1", + "ajv": "^6.10.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.0.1", + "doctrine": "^3.0.0", + "enquirer": "^2.3.5", + "eslint-scope": "^5.1.1", + "eslint-utils": "^2.1.0", + "eslint-visitor-keys": "^2.0.0", + "espree": "^7.3.0", + "esquery": "^1.2.0", + "esutils": "^2.0.2", + "file-entry-cache": "^5.0.1", + "functional-red-black-tree": "^1.0.1", + "glob-parent": "^5.0.0", + "globals": "^12.1.0", + "ignore": "^4.0.6", + "import-fresh": "^3.0.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "js-yaml": "^3.13.1", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash": "^4.17.19", + "minimatch": "^3.0.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.1", + "progress": "^2.0.0", + "regexpp": "^3.1.0", + "semver": "^7.2.1", + "strip-ansi": "^6.0.0", + "strip-json-comments": "^3.1.0", + "table": "^5.2.3", + "text-table": "^0.2.0", + "v8-compile-cache": "^2.0.3" + }, + "dependencies": { + "cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "requires": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + } + }, + "ignore": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", + "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", + "dev": true + }, + "path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true + }, + "shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "requires": { + "shebang-regex": "^3.0.0" + } + }, + "shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true + }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + } + } + }, + "eslint-ast-utils": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/eslint-ast-utils/-/eslint-ast-utils-1.1.0.tgz", + "integrity": "sha512-otzzTim2/1+lVrlH19EfQQJEhVJSu0zOb9ygb3iapN6UlyaDtyRq4b5U1FuW0v1lRa9Fp/GJyHkSwm6NqABgCA==", + "dev": true, + "requires": { + "lodash.get": "^4.4.2", + "lodash.zip": "^4.2.0" + } + }, + "eslint-config-prettier": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-6.15.0.tgz", + "integrity": "sha512-a1+kOYLR8wMGustcgAjdydMsQ2A/2ipRPwRKUmfYaSxc9ZPcrku080Ctl6zrZzZNs/U82MjSv+qKREkoq3bJaw==", + "dev": true, + "requires": { + "get-stdin": "^6.0.0" + }, + "dependencies": { + "get-stdin": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-6.0.0.tgz", + "integrity": "sha512-jp4tHawyV7+fkkSKyvjuLZswblUtz+SQKzSWnBbii16BuZksJlU1wuBYXY75r+duh/llF1ur6oNwi+2ZzjKZ7g==", + "dev": true + } + } + }, + "eslint-import-resolver-node": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.4.tgz", + "integrity": "sha512-ogtf+5AB/O+nM6DIeBUNr2fuT7ot9Qg/1harBfBtaP13ekEWFQEEMP94BCB7zaNW3gyY+8SHYF00rnqYwXKWOA==", + "dev": true, + "requires": { + "debug": "^2.6.9", + "resolve": "^1.13.1" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + } + } + }, + "eslint-module-utils": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.6.0.tgz", + "integrity": "sha512-6j9xxegbqe8/kZY8cYpcp0xhbK0EgJlg3g9mib3/miLaExuuwc3n5UEfSnU6hWMbT0FAYVvDbL9RrRgpUeQIvA==", + "dev": true, + "requires": { + "debug": "^2.6.9", + "pkg-dir": "^2.0.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "find-up": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz", + "integrity": "sha1-RdG35QbHF93UgndaK3eSCjwMV6c=", + "dev": true, + "requires": { + "locate-path": "^2.0.0" + } + }, + "locate-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz", + "integrity": "sha1-K1aLJl7slExtnA3pw9u7ygNUzY4=", + "dev": true, + "requires": { + "p-locate": "^2.0.0", + "path-exists": "^3.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + }, + "p-limit": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz", + "integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==", + "dev": true, + "requires": { + "p-try": "^1.0.0" + } + }, + "p-locate": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz", + "integrity": "sha1-IKAQOyIqcMj9OcwuWAaA893l7EM=", + "dev": true, + "requires": { + "p-limit": "^1.1.0" + } + }, + "p-try": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", + "integrity": "sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M=", + "dev": true + }, + "path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=", + "dev": true + }, + "pkg-dir": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-2.0.0.tgz", + "integrity": "sha1-9tXREJ4Z1j7fQo4L1X4Sd3YVM0s=", + "dev": true, + "requires": { + "find-up": "^2.1.0" + } + } + } + }, + "eslint-plugin-import": { + "version": "2.22.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.22.1.tgz", + "integrity": "sha512-8K7JjINHOpH64ozkAhpT3sd+FswIZTfMZTjdx052pnWrgRCVfp8op9tbjpAk3DdUeI/Ba4C8OjdC0r90erHEOw==", + "dev": true, + "requires": { + "array-includes": "^3.1.1", + "array.prototype.flat": "^1.2.3", + "contains-path": "^0.1.0", + "debug": "^2.6.9", + "doctrine": "1.5.0", + "eslint-import-resolver-node": "^0.3.4", + "eslint-module-utils": "^2.6.0", + "has": "^1.0.3", + "minimatch": "^3.0.4", + "object.values": "^1.1.1", + "read-pkg-up": "^2.0.0", + "resolve": "^1.17.0", + "tsconfig-paths": "^3.9.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "doctrine": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-1.5.0.tgz", + "integrity": "sha1-N53Ocw9hZvds76TmcHoVmwLFpvo=", + "dev": true, + "requires": { + "esutils": "^2.0.2", + "isarray": "^1.0.0" + } + }, + "find-up": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz", + "integrity": "sha1-RdG35QbHF93UgndaK3eSCjwMV6c=", + "dev": true, + "requires": { + "locate-path": "^2.0.0" + } + }, + "hosted-git-info": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.8.tgz", + "integrity": "sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg==", + "dev": true + }, + "locate-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz", + "integrity": "sha1-K1aLJl7slExtnA3pw9u7ygNUzY4=", + "dev": true, + "requires": { + "p-locate": "^2.0.0", + "path-exists": "^3.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + }, + "normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "requires": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "p-limit": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz", + "integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==", + "dev": true, + "requires": { + "p-try": "^1.0.0" + } + }, + "p-locate": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz", + "integrity": "sha1-IKAQOyIqcMj9OcwuWAaA893l7EM=", + "dev": true, + "requires": { + "p-limit": "^1.1.0" + } + }, + "p-try": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", + "integrity": "sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M=", + "dev": true + }, + "path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=", + "dev": true + }, + "path-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-2.0.0.tgz", + "integrity": "sha1-8BLMuEFbcJb8LaoQVMPXI4lZTHM=", + "dev": true, + "requires": { + "pify": "^2.0.0" + } + }, + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", + "dev": true + }, + "read-pkg": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-2.0.0.tgz", + "integrity": "sha1-jvHAYjxqbbDcZxPEv6xGMysjaPg=", + "dev": true, + "requires": { + "load-json-file": "^2.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^2.0.0" + } + }, + "read-pkg-up": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-2.0.0.tgz", + "integrity": "sha1-a3KoBImE4MQeeVEP1en6mbO1Sb4=", + "dev": true, + "requires": { + "find-up": "^2.0.0", + "read-pkg": "^2.0.0" + } + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + } + } + }, + "eslint-plugin-mocha": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-mocha/-/eslint-plugin-mocha-8.0.0.tgz", + "integrity": "sha512-n67etbWDz6NQM+HnTwZHyBwz/bLlYPOxUbw7bPuCyFujv7ZpaT/Vn6KTAbT02gf7nRljtYIjWcTxK/n8a57rQQ==", + "dev": true, + "requires": { + "eslint-utils": "^2.1.0", + "ramda": "^0.27.1" + } + }, + "eslint-plugin-prettier": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-3.1.4.tgz", + "integrity": "sha512-jZDa8z76klRqo+TdGDTFJSavwbnWK2ZpqGKNZ+VvweMW516pDUMmQ2koXvxEE4JhzNvTv+radye/bWGBmA6jmg==", + "dev": true, + "requires": { + "prettier-linter-helpers": "^1.0.0" + } + }, + "eslint-plugin-unicorn": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-22.0.0.tgz", + "integrity": "sha512-jXPOauNiVFYLr+AeU3l21Ao+iDl/G08vUWui21RCI2L1TJIIoJvAMjMR6I+QPKr8FgIumzuR6gzDKCtEx2IkzA==", + "dev": true, + "requires": { + "ci-info": "^2.0.0", + "clean-regexp": "^1.0.0", + "eslint-ast-utils": "^1.1.0", + "eslint-template-visitor": "^2.2.1", + "eslint-utils": "^2.1.0", + "import-modules": "^2.0.0", + "lodash": "^4.17.20", + "pluralize": "^8.0.0", + "read-pkg-up": "^7.0.1", + "regexp-tree": "^0.1.21", + "reserved-words": "^0.1.2", + "safe-regex": "^2.1.1", + "semver": "^7.3.2" + } + }, + "eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "requires": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + } + }, + "eslint-template-visitor": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/eslint-template-visitor/-/eslint-template-visitor-2.2.1.tgz", + "integrity": "sha512-q3SxoBXz0XjPGkUpwGVAwIwIPIxzCAJX1uwfVc8tW3v7u/zS7WXNH3I2Mu2MDz2NgSITAyKLRaQFPHu/iyKxDQ==", + "dev": true, + "requires": { + "babel-eslint": "^10.1.0", + "eslint-visitor-keys": "^1.3.0", + "esquery": "^1.3.1", + "multimap": "^1.1.0" + }, + "dependencies": { + "eslint-visitor-keys": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", + "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", + "dev": true + } + } + }, + "eslint-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz", + "integrity": "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==", + "dev": true, + "requires": { + "eslint-visitor-keys": "^1.1.0" + }, + "dependencies": { + "eslint-visitor-keys": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", + "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", + "dev": true + } + } + }, + "eslint-visitor-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.0.0.tgz", + "integrity": "sha512-QudtT6av5WXels9WjIM7qz1XD1cWGvX4gGXvp/zBn9nXG02D0utdU3Em2m/QjTnrsk6bBjmCygl3rmj118msQQ==", + "dev": true + }, + "espree": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-7.3.0.tgz", + "integrity": "sha512-dksIWsvKCixn1yrEXO8UosNSxaDoSYpq9reEjZSbHLpT5hpaCAKTLBwq0RHtLrIr+c0ByiYzWT8KTMRzoRCNlw==", + "dev": true, + "requires": { + "acorn": "^7.4.0", + "acorn-jsx": "^5.2.0", + "eslint-visitor-keys": "^1.3.0" + }, + "dependencies": { + "eslint-visitor-keys": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", + "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", + "dev": true + } + } + }, + "esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true + }, + "esquery": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.3.1.tgz", + "integrity": "sha512-olpvt9QG0vniUBZspVRN6lwB7hOZoTRtT+jzR+tS4ffYx2mzbw+z0XCOk44aaLYKApNX5nMm+E+P6o25ip/DHQ==", + "dev": true, + "requires": { + "estraverse": "^5.1.0" + }, + "dependencies": { + "estraverse": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.2.0.tgz", + "integrity": "sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ==", + "dev": true + } + } + }, + "esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "requires": { + "estraverse": "^5.2.0" + }, + "dependencies": { + "estraverse": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.2.0.tgz", + "integrity": "sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ==", + "dev": true + } + } + }, + "estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true + }, + "estree-walker": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", + "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==", + "dev": true + }, + "esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true + }, + "etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=", + "dev": true + }, + "execa": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-0.7.0.tgz", + "integrity": "sha1-lEvs00zEHuMqY6n68nrVpl/Fl3c=", + "dev": true, + "requires": { + "cross-spawn": "^5.0.1", + "get-stream": "^3.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + }, + "dependencies": { + "get-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", + "integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=", + "dev": true + }, + "is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=", + "dev": true + } + } + }, + "executable": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/executable/-/executable-4.1.1.tgz", + "integrity": "sha512-8iA79xD3uAch729dUG8xaaBBFGaEa0wdD2VkYLFHwlqosEj/jT66AzcreRDSgV7ehnNLBW2WR5jIXwGKjVdTLg==", + "dev": true, + "requires": { + "pify": "^2.2.0" + }, + "dependencies": { + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", + "dev": true + } + } + }, + "expect": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-25.5.0.tgz", + "integrity": "sha512-w7KAXo0+6qqZZhovCaBVPSIqQp7/UTcx4M9uKt2m6pd2VB1voyC8JizLRqeEqud3AAVP02g+hbErDu5gu64tlA==", + "dev": true, + "requires": { + "@jest/types": "^25.5.0", + "ansi-styles": "^4.0.0", + "jest-get-type": "^25.2.6", + "jest-matcher-utils": "^25.5.0", + "jest-message-util": "^25.5.0", + "jest-regex-util": "^25.2.6" + } + }, + "ext-list": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/ext-list/-/ext-list-2.2.2.tgz", + "integrity": "sha512-u+SQgsubraE6zItfVA0tBuCBhfU9ogSRnsvygI7wht9TS510oLkBRXBsqopeUG/GBOIQyKZO9wjTqIu/sf5zFA==", + "dev": true, + "requires": { + "mime-db": "^1.28.0" + } + }, + "ext-name": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ext-name/-/ext-name-5.0.0.tgz", + "integrity": "sha512-yblEwXAbGv1VQDmow7s38W77hzAgJAO50ztBLMcUyUBfxv1HC+LGwtiEN+Co6LtlqT/5uwVOxsD4TNIilWhwdQ==", + "dev": true, + "requires": { + "ext-list": "^2.0.0", + "sort-keys-length": "^1.0.0" + } + }, + "extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true + }, + "extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "requires": { + "@types/yauzl": "^2.9.1", + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + } + }, + "fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "fast-diff": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.2.0.tgz", + "integrity": "sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==", + "dev": true + }, + "fast-glob": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.4.tgz", + "integrity": "sha512-kr/Oo6PX51265qeuCYsyGypiO5uJFgBS0jksyG7FUeCyQzNwYnzrNIMR1NXfkZXsMYXYLRAHgISHBz8gQcxKHQ==", + "dev": true, + "requires": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.0", + "merge2": "^1.3.0", + "micromatch": "^4.0.2", + "picomatch": "^2.2.1" + } + }, + "fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", + "dev": true + }, + "fastq": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.9.0.tgz", + "integrity": "sha512-i7FVWL8HhVY+CTkwFxkN2mk3h+787ixS5S63eb78diVRc1MCssarHq3W5cj0av7YDSwmaV928RNag+U1etRQ7w==", + "dev": true, + "requires": { + "reusify": "^1.0.4" + } + }, + "fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha1-JcfInLH5B3+IkbvmHY85Dq4lbx4=", + "requires": { + "pend": "~1.2.0" + } + }, + "figures": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", + "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", + "dev": true, + "requires": { + "escape-string-regexp": "^1.0.5" + } + }, + "file-entry-cache": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-5.0.1.tgz", + "integrity": "sha512-bCg29ictuBaKUwwArK4ouCaqDgLZcysCFLmM/Yn/FDoqndh/9vNuQfXRDvTuXKLxfD/JtZQGKFT8MGcJBK644g==", + "dev": true, + "requires": { + "flat-cache": "^2.0.1" + } + }, + "file-type": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-8.1.0.tgz", + "integrity": "sha512-qyQ0pzAy78gVoJsmYeNgl8uH8yKhr1lVhW7JbzJmnlRi0I4R2eEDEJZVKG8agpDnLpacwNbDhLNG/LMdxHD2YQ==", + "dev": true + }, + "filename-reserved-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/filename-reserved-regex/-/filename-reserved-regex-2.0.0.tgz", + "integrity": "sha1-q/c9+rc10EVECr/qLZHzieu/oik=", + "dev": true + }, + "filenamify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/filenamify/-/filenamify-2.1.0.tgz", + "integrity": "sha512-ICw7NTT6RsDp2rnYKVd8Fu4cr6ITzGy3+u4vUujPkabyaz+03F24NWEX7fs5fp+kBonlaqPH8fAO2NM+SXt/JA==", + "dev": true, + "requires": { + "filename-reserved-regex": "^2.0.0", + "strip-outer": "^1.0.0", + "trim-repeated": "^1.0.0" + } + }, + "fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "requires": { + "to-regex-range": "^5.0.1" + } + }, + "find-replace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-replace/-/find-replace-3.0.0.tgz", + "integrity": "sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==", + "dev": true, + "requires": { + "array-back": "^3.0.1" + } + }, + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "find-versions": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/find-versions/-/find-versions-3.2.0.tgz", + "integrity": "sha512-P8WRou2S+oe222TOCHitLy8zj+SIsVJh52VP4lvXkaFVnOFFdoWv1H1Jjvel1aI6NCFOAaeAVm8qrI0odiLcww==", + "dev": true, + "requires": { + "semver-regex": "^2.0.0" + } + }, + "flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true + }, + "flat-cache": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-2.0.1.tgz", + "integrity": "sha512-LoQe6yDuUMDzQAEH8sgmh4Md6oZnc/7PjtwjNFSzveXqSHt6ka9fPBuso7IGf9Rz4uqnSnWiFH2B/zj24a5ReA==", + "dev": true, + "requires": { + "flatted": "^2.0.0", + "rimraf": "2.6.3", + "write": "1.0.3" + }, + "dependencies": { + "rimraf": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + } + } + }, + "flatted": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-2.0.2.tgz", + "integrity": "sha512-r5wGx7YeOwNWNlCA0wQ86zKyDLMQr+/RB8xy74M4hTphfmjlijTSSXGuH8rnvKZnfT9i+75zmd8jcKdMR4O6jA==", + "dev": true + }, + "form-data": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.0.tgz", + "integrity": "sha512-CKMFDglpbMi6PyN+brwB9Q/GOw0eAnsrEZDgcsH5Krhz5Od/haKHAX0NmQfha2zPPz0JpWzA7GJHGSnvCRLWsg==", + "dev": true, + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + } + }, + "fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=", + "dev": true + }, + "from2": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", + "integrity": "sha1-i/tVAr3kpNNs/e6gB/zKIdfjgq8=", + "dev": true, + "requires": { + "inherits": "^2.0.1", + "readable-stream": "^2.0.0" + }, + "dependencies": { + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.0" + } + } + } + }, + "fs-access": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fs-access/-/fs-access-1.0.1.tgz", + "integrity": "sha1-1qh/JiJxzv6+wwxVNAf7mV2od3o=", + "dev": true, + "requires": { + "null-check": "^1.0.0" + } + }, + "fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" + }, + "fs-extra": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.0.1.tgz", + "integrity": "sha512-h2iAoN838FqAFJY2/qVpzFXy+EBxfVE220PalAqQLDVsFOHLJrZvut5puAbCdNv6WJk+B8ihI+k0c7JK5erwqQ==", + "dev": true, + "requires": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^1.0.0" + } + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" + }, + "fsevents": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.1.3.tgz", + "integrity": "sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ==", + "dev": true, + "optional": true + }, + "function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", + "dev": true + }, + "functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=", + "dev": true + }, + "get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true + }, + "get-intrinsic": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.0.1.tgz", + "integrity": "sha512-ZnWP+AmS1VUaLgTRy47+zKtjTxz+0xMpx3I52i+aalBK1QP19ggLF3Db89KJX7kjfOfP2eoa01qc++GwPgufPg==", + "dev": true, + "requires": { + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1" + } + }, + "get-pkg-repo": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/get-pkg-repo/-/get-pkg-repo-1.4.0.tgz", + "integrity": "sha1-xztInAbYDMVTbCyFP54FIyBWly0=", + "dev": true, + "requires": { + "hosted-git-info": "^2.1.4", + "meow": "^3.3.0", + "normalize-package-data": "^2.3.0", + "parse-github-repo-url": "^1.3.0", + "through2": "^2.0.0" + }, + "dependencies": { + "camelcase": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-2.1.1.tgz", + "integrity": "sha1-fB0W1nmhu+WcoCys7PsBHiAfWh8=", + "dev": true + }, + "camelcase-keys": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-2.1.0.tgz", + "integrity": "sha1-MIvur/3ygRkFHvodkyITyRuPkuc=", + "dev": true, + "requires": { + "camelcase": "^2.0.0", + "map-obj": "^1.0.0" + } + }, + "find-up": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-1.1.2.tgz", + "integrity": "sha1-ay6YIrGizgpgq2TWEOzK1TyyTQ8=", + "dev": true, + "requires": { + "path-exists": "^2.0.0", + "pinkie-promise": "^2.0.0" + } + }, + "get-stdin": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-4.0.1.tgz", + "integrity": "sha1-uWjGsKBDhDJJAui/Gl3zJXmkUP4=", + "dev": true + }, + "hosted-git-info": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.8.tgz", + "integrity": "sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg==", + "dev": true + }, + "indent-string": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-2.1.0.tgz", + "integrity": "sha1-ji1INIdCEhtKghi3oTfppSBJ3IA=", + "dev": true, + "requires": { + "repeating": "^2.0.0" + } + }, + "load-json-file": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-1.1.0.tgz", + "integrity": "sha1-lWkFcI1YtLq0wiYbBPWfMcmTdMA=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "parse-json": "^2.2.0", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0", + "strip-bom": "^2.0.0" + } + }, + "map-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz", + "integrity": "sha1-2TPOuSBdgr3PSIb2dCvcK03qFG0=", + "dev": true + }, + "meow": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-3.7.0.tgz", + "integrity": "sha1-cstmi0JSKCkKu/qFaJJYcwioAfs=", + "dev": true, + "requires": { + "camelcase-keys": "^2.0.0", + "decamelize": "^1.1.2", + "loud-rejection": "^1.0.0", + "map-obj": "^1.0.1", + "minimist": "^1.1.3", + "normalize-package-data": "^2.3.4", + "object-assign": "^4.0.1", + "read-pkg-up": "^1.0.1", + "redent": "^1.0.0", + "trim-newlines": "^1.0.0" + } + }, + "normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "requires": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "parse-json": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-2.2.0.tgz", + "integrity": "sha1-9ID0BDTvgHQfhGkJn43qGPVaTck=", + "dev": true, + "requires": { + "error-ex": "^1.2.0" + } + }, + "path-exists": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-2.1.0.tgz", + "integrity": "sha1-D+tsZPD8UY2adU3V77YscCJ2H0s=", + "dev": true, + "requires": { + "pinkie-promise": "^2.0.0" + } + }, + "path-type": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-1.1.0.tgz", + "integrity": "sha1-WcRPfuSR2nBNpBXaWkBwuk+P5EE=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0" + } + }, + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", + "dev": true + }, + "read-pkg": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-1.1.0.tgz", + "integrity": "sha1-9f+qXs0pyzHAR0vKfXVra7KePyg=", + "dev": true, + "requires": { + "load-json-file": "^1.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^1.0.0" + } + }, + "read-pkg-up": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-1.0.1.tgz", + "integrity": "sha1-nWPBMnbAZZGNV/ACpX9AobZD+wI=", + "dev": true, + "requires": { + "find-up": "^1.0.0", + "read-pkg": "^1.0.0" + } + }, + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "redent": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-1.0.0.tgz", + "integrity": "sha1-z5Fqsf1fHxbfsggi3W7H9zDCr94=", + "dev": true, + "requires": { + "indent-string": "^2.1.0", + "strip-indent": "^1.0.1" + } + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.0" + } + }, + "strip-bom": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-2.0.0.tgz", + "integrity": "sha1-YhmoVhZSBJHzV4i9vxRHqZx+aw4=", + "dev": true, + "requires": { + "is-utf8": "^0.2.0" + } + }, + "strip-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-1.0.1.tgz", + "integrity": "sha1-DHlipq3vp7vUrDZkYKY4VSrhoKI=", + "dev": true, + "requires": { + "get-stdin": "^4.0.1" + } + }, + "through2": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", + "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", + "dev": true, + "requires": { + "readable-stream": "~2.3.6", + "xtend": "~4.0.1" + } + }, + "trim-newlines": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-1.0.0.tgz", + "integrity": "sha1-WIeWa7WCpFA6QetST301ARgVphM=", + "dev": true + } + } + }, + "get-proxy": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/get-proxy/-/get-proxy-2.1.0.tgz", + "integrity": "sha512-zmZIaQTWnNQb4R4fJUEp/FC51eZsc6EkErspy3xtIYStaq8EB/hDIWipxsal+E8rz0qD7f2sL/NA9Xee4RInJw==", + "dev": true, + "requires": { + "npm-conf": "^1.1.0" + } + }, + "get-stdin": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-8.0.0.tgz", + "integrity": "sha512-sY22aA6xchAzprjyqmSEQv4UbAAzRN0L2dQB0NlN5acTTK9Don6nhoc3eAbUnpZiCANAMfd/+40kVdKfFygohg==", + "dev": true + }, + "get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "requires": { + "pump": "^3.0.0" + } + }, + "git-raw-commits": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-2.0.8.tgz", + "integrity": "sha512-6Gk7tQHGMLEL1bSnrMJTCVt2AQl4EmCcJDtzs/JJacCb2+TNEyHM67Gp7Ri9faF7OcGpjGGRjHLvs/AG7QKZ2Q==", + "dev": true, + "requires": { + "dargs": "^7.0.0", + "lodash.template": "^4.0.2", + "meow": "^8.0.0", + "split2": "^2.0.0", + "through2": "^4.0.0" + } + }, + "git-remote-origin-url": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/git-remote-origin-url/-/git-remote-origin-url-2.0.0.tgz", + "integrity": "sha1-UoJlna4hBxRaERJhEq0yFuxfpl8=", + "dev": true, + "requires": { + "gitconfiglocal": "^1.0.0", + "pify": "^2.3.0" + }, + "dependencies": { + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", + "dev": true + } + } + }, + "git-semver-tags": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/git-semver-tags/-/git-semver-tags-4.1.1.tgz", + "integrity": "sha512-OWyMt5zBe7xFs8vglMmhM9lRQzCWL3WjHtxNNfJTMngGym7pC1kh8sP6jevfydJ6LP3ZvGxfb6ABYgPUM0mtsA==", + "dev": true, + "requires": { + "meow": "^8.0.0", + "semver": "^6.0.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "gitconfiglocal": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/gitconfiglocal/-/gitconfiglocal-1.0.0.tgz", + "integrity": "sha1-QdBF84UaXqiPA/JMocYXgRRGS5s=", + "dev": true, + "requires": { + "ini": "^1.3.2" + } + }, + "glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "glob-parent": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.1.tgz", + "integrity": "sha512-FnI+VGOpnlGHWZxthPGR+QhR78fuiK0sNLkHQv+bL9fQi57lNNdquIbna/WrfROrolq8GK5Ek6BiMwqL/voRYQ==", + "dev": true, + "requires": { + "is-glob": "^4.0.1" + } + }, + "global-dirs": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-0.1.1.tgz", + "integrity": "sha1-sxnA3UYH81PzvpzKTHL8FIxJ9EU=", + "dev": true, + "requires": { + "ini": "^1.3.4" + } + }, + "globals": { + "version": "12.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-12.4.0.tgz", + "integrity": "sha512-BWICuzzDvDoH54NHKCseDanAhE3CeDorgDL5MT6LMXXj2WCnd9UC2szdk4AWLfjdgNBCXLUanXYcpBBKOSWGwg==", + "dev": true, + "requires": { + "type-fest": "^0.8.1" + }, + "dependencies": { + "type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "dev": true + } + } + }, + "globby": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.0.1.tgz", + "integrity": "sha512-iH9RmgwCmUJHi2z5o2l3eTtGBtXek1OYlHrbcxOYugyHLmAsZrPj43OtHThd62Buh/Vv6VyCBD2bdyWcGNQqoQ==", + "dev": true, + "requires": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.1.1", + "ignore": "^5.1.4", + "merge2": "^1.3.0", + "slash": "^3.0.0" + } + }, + "got": { + "version": "11.8.0", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.0.tgz", + "integrity": "sha512-k9noyoIIY9EejuhaBNLyZ31D5328LeqnyPNXJQb2XlJZcKakLqN5m6O/ikhq/0lw56kUYS54fVm+D1x57YC9oQ==", + "dev": true, + "requires": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.1", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "dependencies": { + "@sindresorhus/is": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.0.0.tgz", + "integrity": "sha512-FyD2meJpDPjyNQejSjvnhpgI/azsQkA4lGbuu5BQZfjvJ9cbRZXzeWL2HceCekW4lixO9JPesIIQkSoLjeJHNQ==", + "dev": true + }, + "cacheable-request": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.1.tgz", + "integrity": "sha512-lt0mJ6YAnsrBErpTMWeu5kl/tg9xMAWjavYTN6VQXM1A/teBITuNcccXsCxF0tDQQJf9DfAaX5O4e0zp0KlfZw==", + "dev": true, + "requires": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^4.1.0", + "responselike": "^2.0.0" + } + }, + "decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "requires": { + "mimic-response": "^3.1.0" + } + }, + "http-cache-semantics": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz", + "integrity": "sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==", + "dev": true + }, + "json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "keyv": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.0.3.tgz", + "integrity": "sha512-zdGa2TOpSZPq5mU6iowDARnMBZgtCqJ11dJROFi6tg6kTn4nuUdU09lFyLFSaHrWqpIJ+EBq4E8/Dc0Vx5vLdA==", + "dev": true, + "requires": { + "json-buffer": "3.0.1" + } + }, + "lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true + }, + "mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true + }, + "normalize-url": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-4.5.0.tgz", + "integrity": "sha512-2s47yzUxdexf1OhyRi4Em83iQk0aPvwTddtFz4hnSSw9dCEsLEGf6SwIO8ss/19S9iBb5sJaOuTvTGDeZI00BQ==", + "dev": true + }, + "p-cancelable": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.0.0.tgz", + "integrity": "sha512-wvPXDmbMmu2ksjkB4Z3nZWTSkJEb9lqVdMaCKpZUGJG9TMiNp9XcbG3fn9fPKjem04fJMJnXoyFPk2FmgiaiNg==", + "dev": true + }, + "responselike": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.0.tgz", + "integrity": "sha512-xH48u3FTB9VsZw7R+vvgaKeLKzT6jOogbQhEe/jewwnZgzPcnyWui2Av6JpoYZF/91uueC+lqhWqeURw5/qhCw==", + "dev": true, + "requires": { + "lowercase-keys": "^2.0.0" + } + } + } + }, + "graceful-fs": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz", + "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==", + "dev": true + }, + "growl": { + "version": "1.10.5", + "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.5.tgz", + "integrity": "sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==", + "dev": true + }, + "handlebars": { + "version": "4.7.6", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.6.tgz", + "integrity": "sha512-1f2BACcBfiwAfStCKZNrUCgqNZkGsAT7UM3kkYtXuLo0KnaVfjKOyf7PRzB6++aK9STyT1Pd2ZCPe3EGOXleXA==", + "dev": true, + "requires": { + "minimist": "^1.2.5", + "neo-async": "^2.6.0", + "source-map": "^0.6.1", + "uglify-js": "^3.1.4", + "wordwrap": "^1.0.0" + } + }, + "hard-rejection": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz", + "integrity": "sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==", + "dev": true + }, + "has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dev": true, + "requires": { + "function-bind": "^1.1.1" + } + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "has-symbol-support-x": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/has-symbol-support-x/-/has-symbol-support-x-1.4.2.tgz", + "integrity": "sha512-3ToOva++HaW+eCpgqZrCfN51IPB+7bJNVT6CUATzueB5Heb8o6Nam0V3HG5dlDvZU1Gn5QLcbahiKw/XVk5JJw==", + "dev": true + }, + "has-symbols": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz", + "integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==", + "dev": true + }, + "has-to-string-tag-x": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/has-to-string-tag-x/-/has-to-string-tag-x-1.4.1.tgz", + "integrity": "sha512-vdbKfmw+3LoOYVr+mtxHaX5a96+0f3DljYd8JOqvOLsf5mw2Otda2qCDT9qRqLAhrjyQ0h7ual5nOiASpsGNFw==", + "dev": true, + "requires": { + "has-symbol-support-x": "^1.4.1" + } + }, + "hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "dev": true, + "requires": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, + "he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true + }, + "header-case": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/header-case/-/header-case-2.0.3.tgz", + "integrity": "sha512-LChe/V32mnUQnTwTxd3aAlNMk8ia9tjCDb/LjYtoMrdAPApxLB+azejUk5ERZIZdIqvinwv6BAUuFXH/tQPdZA==", + "dev": true, + "requires": { + "capital-case": "^1.0.3", + "tslib": "^1.10.0" + } + }, + "hosted-git-info": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-3.0.7.tgz", + "integrity": "sha512-fWqc0IcuXs+BmE9orLDyVykAG9GJtGLGuZAAqgcckPgv5xad4AcXGIv8galtQvlwutxSlaMcdw7BUtq2EIvqCQ==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + } + }, + "html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true + }, + "http-assert": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/http-assert/-/http-assert-1.4.1.tgz", + "integrity": "sha512-rdw7q6GTlibqVVbXr0CKelfV5iY8G2HqEUkhSk297BMbSpSL8crXC+9rjKoMcZZEsksX30le6f/4ul4E28gegw==", + "dev": true, + "requires": { + "deep-equal": "~1.0.1", + "http-errors": "~1.7.2" + }, + "dependencies": { + "http-errors": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.3.tgz", + "integrity": "sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==", + "dev": true, + "requires": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.1.1", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.0" + } + } + } + }, + "http-cache-semantics": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-3.8.1.tgz", + "integrity": "sha512-5ai2iksyV8ZXmnZhHH4rWPoxxistEexSi5936zIQ1bnNTW5VnA85B6P/VpXiRM017IgRvb2kKo1a//y+0wSp3w==", + "dev": true + }, + "http-errors": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.0.tgz", + "integrity": "sha512-4I8r0C5JDhT5VkvI47QktDW75rNlGVsUf/8hzjCC/wkWI/jdTRmBb9aI7erSG82r1bjKY3F6k28WnsVxB1C73A==", + "dev": true, + "requires": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.0" + }, + "dependencies": { + "setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true + } + } + }, + "http2-wrapper": { + "version": "1.0.0-beta.5.2", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.0-beta.5.2.tgz", + "integrity": "sha512-xYz9goEyBnC8XwXDTuC/MZ6t+MrKVQZOk4s7+PaDkwIsQd8IwqvM+0M6bA/2lvG8GHXcPdf+MejTUeO2LCPCeQ==", + "dev": true, + "requires": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "dependencies": { + "quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true + } + } + }, + "https-proxy-agent": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-4.0.0.tgz", + "integrity": "sha512-zoDhWrkR3of1l9QAL8/scJZyLu8j/gBkcwcaQOZh7Gyh/+uJQzGVETdgT30akuwkpL8HTRfssqI3BZuV18teDg==", + "requires": { + "agent-base": "5", + "debug": "4" + } + }, + "husky": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/husky/-/husky-4.3.0.tgz", + "integrity": "sha512-tTMeLCLqSBqnflBZnlVDhpaIMucSGaYyX6855jM4AguGeWCeSzNdb1mfyWduTZ3pe3SJVvVWGL0jO1iKZVPfTA==", + "dev": true, + "requires": { + "chalk": "^4.0.0", + "ci-info": "^2.0.0", + "compare-versions": "^3.6.0", + "cosmiconfig": "^7.0.0", + "find-versions": "^3.2.0", + "opencollective-postinstall": "^2.0.2", + "pkg-dir": "^4.2.0", + "please-upgrade-node": "^3.2.0", + "slash": "^3.0.0", + "which-pm-runs": "^1.0.0" + } + }, + "iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, + "ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==" + }, + "ignore": { + "version": "5.1.8", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.8.tgz", + "integrity": "sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw==", + "dev": true + }, + "immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha1-nbHb0Pr43m++D13V5Wu2BigN5ps=", + "dev": true + }, + "import-fresh": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.2.2.tgz", + "integrity": "sha512-cTPNrlvJT6twpYy+YmKUKrTSjWFs3bjYjAhCwm+z4EOCubZxAuO+hHpRN64TqjEaYSHs7tJAE0w1CKMGmsG/lw==", + "dev": true, + "requires": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "dependencies": { + "resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true + } + } + }, + "import-lazy": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-4.0.0.tgz", + "integrity": "sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==", + "dev": true + }, + "import-modules": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/import-modules/-/import-modules-2.0.0.tgz", + "integrity": "sha512-iczM/v9drffdNnABOKwj0f9G3cFDon99VcG1mxeBsdqnbd+vnQ5c2uAiCHNQITqFTOPaEvwg3VjoWCur0uHLEw==", + "dev": true + }, + "imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", + "dev": true + }, + "indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true + }, + "inflation": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/inflation/-/inflation-2.0.0.tgz", + "integrity": "sha1-i0F+R8KPklpFEz2RTKH9OJEH8w8=", + "dev": true + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "ini": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz", + "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==", + "dev": true + }, + "interpret": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz", + "integrity": "sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==", + "dev": true + }, + "into-stream": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/into-stream/-/into-stream-3.1.0.tgz", + "integrity": "sha1-lvsKk2wSur1v8XUqF9BWFqvQlMY=", + "dev": true, + "requires": { + "from2": "^2.1.1", + "p-is-promise": "^1.1.0" + } + }, + "ip": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/ip/-/ip-1.1.5.tgz", + "integrity": "sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo=", + "dev": true + }, + "is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=", + "dev": true + }, + "is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "requires": { + "binary-extensions": "^2.0.0" + } + }, + "is-callable": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.2.tgz", + "integrity": "sha512-dnMqspv5nU3LoewK2N/y7KLtxtakvTuaCsU9FU50/QDmdbHNy/4/JuRtMHqRU22o3q+W89YQndQEeCVwK+3qrA==", + "dev": true + }, + "is-core-module": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.1.0.tgz", + "integrity": "sha512-YcV7BgVMRFRua2FqQzKtTDMz8iCuLEyGKjr70q8Zm1yy2qKcurbFEd79PAdHV77oL3NrAaOVQIbMmiHQCHB7ZA==", + "dev": true, + "requires": { + "has": "^1.0.3" + } + }, + "is-date-object": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.2.tgz", + "integrity": "sha512-USlDT524woQ08aoZFzh3/Z6ch9Y/EWXEHQ/AaRN0SkKq4t2Jw2R2339tSXmwuVoY7LLlBCbOIlx2myP/L5zk0g==", + "dev": true + }, + "is-docker": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.1.1.tgz", + "integrity": "sha512-ZOoqiXfEwtGknTiuDEy8pN2CfE3TxMHprvNer1mXiqwkOT77Rw3YVrUQ52EqAOU3QAWDQ+bQdx7HJzrv7LS2Hw==", + "dev": true + }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", + "dev": true + }, + "is-finite": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.1.0.tgz", + "integrity": "sha512-cdyMtqX/BOqqNBBiKlIVkytNHm49MtMlYyn1zxzvJKWmFMlGzm+ry5BBfYyeY9YmNKbRSo/o7OX9w9ale0wg3w==", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true + }, + "is-generator-function": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.7.tgz", + "integrity": "sha512-YZc5EwyO4f2kWCax7oegfuSr9mFz1ZvieNYBEjmukLxgXfBUbxAWGVF7GZf0zidYtoBl3WvC07YK0wT76a+Rtw==", + "dev": true + }, + "is-glob": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz", + "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==", + "dev": true, + "requires": { + "is-extglob": "^2.1.1" + } + }, + "is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha1-Mlj7afeMFNW4FdZkM2tM/7ZEFZE=", + "dev": true + }, + "is-natural-number": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-natural-number/-/is-natural-number-4.0.1.tgz", + "integrity": "sha1-q5124dtM7VHjXeDHLr7PCfc0zeg=", + "dev": true + }, + "is-negative-zero": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.0.tgz", + "integrity": "sha1-lVOxIbD6wohp2p7UWeIMdUN4hGE=", + "dev": true + }, + "is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true + }, + "is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "dev": true + }, + "is-object": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-object/-/is-object-1.0.1.tgz", + "integrity": "sha1-iVJojF7C/9awPsyF52ngKQMINHA=", + "dev": true + }, + "is-plain-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", + "integrity": "sha1-caUMhCnfync8kqOQpKA7OfzVHT4=", + "dev": true + }, + "is-regex": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.1.tgz", + "integrity": "sha512-1+QkEcxiLlB7VEyFtyBg94e08OAsvq7FUBgApTq/w2ymCLyKJgDPsybBENVtA7XCQEgEXxKPonG+mvYRxh/LIg==", + "dev": true, + "requires": { + "has-symbols": "^1.0.1" + } + }, + "is-retry-allowed": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-1.2.0.tgz", + "integrity": "sha512-RUbUeKwvm3XG2VYamhJL1xFktgjvPzL0Hq8C+6yrWIswDy3BIXGqCxhxkc30N9jqK311gVU137K8Ei55/zVJRg==", + "dev": true + }, + "is-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.0.tgz", + "integrity": "sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw==", + "dev": true + }, + "is-string": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.5.tgz", + "integrity": "sha512-buY6VNRjhQMiF1qWDouloZlQbRhDPCebwxSjxMjxgemYT46YMd2NR0/H+fBhEfWX4A/w9TBJ+ol+okqJKFE6vQ==", + "dev": true + }, + "is-symbol": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz", + "integrity": "sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ==", + "dev": true, + "requires": { + "has-symbols": "^1.0.1" + } + }, + "is-text-path": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-text-path/-/is-text-path-1.0.1.tgz", + "integrity": "sha1-Thqg+1G/vLPpJogAE5cgLBd1tm4=", + "dev": true, + "requires": { + "text-extensions": "^1.0.0" + } + }, + "is-utf8": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz", + "integrity": "sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI=", + "dev": true + }, + "is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "requires": { + "is-docker": "^2.0.0" + } + }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "isbinaryfile": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.6.tgz", + "integrity": "sha512-ORrEy+SNVqUhrCaal4hA4fBzhggQQ+BaLntyPOdoEiwlKZW9BZiJXjg3RMiruE4tPEI3pyVPpySHQF/dKWperg==", + "dev": true + }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", + "dev": true + }, + "istanbul-lib-coverage": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.0.0.tgz", + "integrity": "sha512-UiUIqxMgRDET6eR+o5HbfRYP1l0hqkWOs7vNxC/mggutCMUIhWMm8gAHb8tHlyfD3/l6rlgNA5cKdDzEAf6hEg==", + "dev": true + }, + "istanbul-lib-report": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", + "integrity": "sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw==", + "dev": true, + "requires": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^3.0.0", + "supports-color": "^7.1.0" + } + }, + "istanbul-reports": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.0.2.tgz", + "integrity": "sha512-9tZvz7AiR3PEDNGiV9vIouQ/EAcqMXFmkcA1CDFTwOB98OZVDL0PH9glHotf5Ugp6GCOTypfzGWI/OqjWNCRUw==", + "dev": true, + "requires": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + } + }, + "isurl": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isurl/-/isurl-1.0.0.tgz", + "integrity": "sha512-1P/yWsxPlDtn7QeRD+ULKQPaIaN6yF368GZ2vDfv0AL0NwpStafjWCDDdn0k8wgFMWpVAqG7oJhxHnlud42i9w==", + "dev": true, + "requires": { + "has-to-string-tag-x": "^1.2.0", + "is-object": "^1.0.1" + } + }, + "jest-diff": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-25.5.0.tgz", + "integrity": "sha512-z1kygetuPiREYdNIumRpAHY6RXiGmp70YHptjdaxTWGmA085W3iCnXNx0DhflK3vwrKmrRWyY1wUpkPMVxMK7A==", + "dev": true, + "requires": { + "chalk": "^3.0.0", + "diff-sequences": "^25.2.6", + "jest-get-type": "^25.2.6", + "pretty-format": "^25.5.0" + }, + "dependencies": { + "chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, + "jest-get-type": { + "version": "25.2.6", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-25.2.6.tgz", + "integrity": "sha512-DxjtyzOHjObRM+sM1knti6or+eOgcGU4xVSb2HNP1TqO4ahsT+rqZg+nyqHWJSvWgKC5cG3QjGFBqxLghiF/Ig==", + "dev": true + }, + "jest-matcher-utils": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-25.5.0.tgz", + "integrity": "sha512-VWI269+9JS5cpndnpCwm7dy7JtGQT30UHfrnM3mXl22gHGt/b7NkjBqXfbhZ8V4B7ANUsjK18PlSBmG0YH7gjw==", + "dev": true, + "requires": { + "chalk": "^3.0.0", + "jest-diff": "^25.5.0", + "jest-get-type": "^25.2.6", + "pretty-format": "^25.5.0" + }, + "dependencies": { + "chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, + "jest-message-util": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-25.5.0.tgz", + "integrity": "sha512-ezddz3YCT/LT0SKAmylVyWWIGYoKHOFOFXx3/nA4m794lfVUskMcwhip6vTgdVrOtYdjeQeis2ypzes9mZb4EA==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "@jest/types": "^25.5.0", + "@types/stack-utils": "^1.0.1", + "chalk": "^3.0.0", + "graceful-fs": "^4.2.4", + "micromatch": "^4.0.2", + "slash": "^3.0.0", + "stack-utils": "^1.0.1" + }, + "dependencies": { + "chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, + "jest-regex-util": { + "version": "25.2.6", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-25.2.6.tgz", + "integrity": "sha512-KQqf7a0NrtCkYmZZzodPftn7fL1cq3GQAFVMn5Hg8uKx/fIenLEobNanUxb7abQ1sjADHBseG/2FGpsv/wr+Qw==", + "dev": true + }, + "jju": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz", + "integrity": "sha1-o6vicYryQaKykE+EpiWXDzia4yo=", + "dev": true + }, + "jpeg-js": { + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.3.7.tgz", + "integrity": "sha512-9IXdWudL61npZjvLuVe/ktHiA41iE8qFyLB+4VDTblEsWBzeg8WQTlktdUK4CdncUqtUgUg0bbOmTE2bKBKaBQ==", + "dev": true + }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "js-yaml": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz", + "integrity": "sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==", + "dev": true, + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } + }, + "jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true + }, + "json-buffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.0.tgz", + "integrity": "sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg=", + "dev": true + }, + "json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true + }, + "json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=", + "dev": true + }, + "json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=", + "dev": true + }, + "json5": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz", + "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==", + "dev": true, + "requires": { + "minimist": "^1.2.0" + } + }, + "jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.6", + "universalify": "^2.0.0" + }, + "dependencies": { + "universalify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", + "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", + "dev": true + } + } + }, + "jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA=", + "dev": true + }, + "jszip": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.5.0.tgz", + "integrity": "sha512-WRtu7TPCmYePR1nazfrtuF216cIVon/3GWOvHS9QR5bIwSbnxtdpma6un3jyGGNhHsKCSzn5Ypk+EkDRvTGiFA==", + "dev": true, + "requires": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "set-immediate-shim": "~1.0.1" + }, + "dependencies": { + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.0" + } + } + } + }, + "just-extend": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.1.1.tgz", + "integrity": "sha512-aWgeGFW67BP3e5181Ep1Fv2v8z//iBJfrvyTnq8wG86vEESwmonn1zPBJ0VfmT9CJq2FIT0VsETtrNFm2a+SHA==", + "dev": true + }, + "keygrip": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", + "integrity": "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==", + "dev": true, + "requires": { + "tsscmp": "1.0.6" + } + }, + "keyv": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-3.0.0.tgz", + "integrity": "sha512-eguHnq22OE3uVoSYG0LVWNP+4ppamWr9+zWBe1bsNcovIMy6huUJFPgy4mGwCd/rnl3vOLGW1MTlu4c57CT1xA==", + "dev": true, + "requires": { + "json-buffer": "3.0.0" + } + }, + "kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true + }, + "koa": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/koa/-/koa-2.13.0.tgz", + "integrity": "sha512-i/XJVOfPw7npbMv67+bOeXr3gPqOAw6uh5wFyNs3QvJ47tUx3M3V9rIE0//WytY42MKz4l/MXKyGkQ2LQTfLUQ==", + "dev": true, + "requires": { + "accepts": "^1.3.5", + "cache-content-type": "^1.0.0", + "content-disposition": "~0.5.2", + "content-type": "^1.0.4", + "cookies": "~0.8.0", + "debug": "~3.1.0", + "delegates": "^1.0.0", + "depd": "^1.1.2", + "destroy": "^1.0.4", + "encodeurl": "^1.0.2", + "escape-html": "^1.0.3", + "fresh": "~0.5.2", + "http-assert": "^1.3.0", + "http-errors": "^1.6.3", + "is-generator-function": "^1.0.7", + "koa-compose": "^4.1.0", + "koa-convert": "^1.2.0", + "on-finished": "^2.3.0", + "only": "~0.0.2", + "parseurl": "^1.3.2", + "statuses": "^1.5.0", + "type-is": "^1.6.16", + "vary": "^1.1.2" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + } + } + }, + "koa-compose": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/koa-compose/-/koa-compose-4.1.0.tgz", + "integrity": "sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==", + "dev": true + }, + "koa-convert": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/koa-convert/-/koa-convert-1.2.0.tgz", + "integrity": "sha1-2kCHXfSd4FOQmNFwC1CCDOvNIdA=", + "dev": true, + "requires": { + "co": "^4.6.0", + "koa-compose": "^3.0.0" + }, + "dependencies": { + "koa-compose": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/koa-compose/-/koa-compose-3.2.1.tgz", + "integrity": "sha1-qFzLQLfZhtjlo0Wzoazo6rz1Tec=", + "dev": true, + "requires": { + "any-promise": "^1.1.0" + } + } + } + }, + "koa-etag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/koa-etag/-/koa-etag-3.0.0.tgz", + "integrity": "sha1-nvc4Ld1agqsN6xU0FckVg293HT8=", + "dev": true, + "requires": { + "etag": "^1.3.0", + "mz": "^2.1.0" + } + }, + "koa-send": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/koa-send/-/koa-send-5.0.1.tgz", + "integrity": "sha512-tmcyQ/wXXuxpDxyNXv5yNNkdAMdFRqwtegBXUaowiQzUKqJehttS0x2j0eOZDQAyloAth5w6wwBImnFzkUz3pQ==", + "dev": true, + "requires": { + "debug": "^4.1.1", + "http-errors": "^1.7.3", + "resolve-path": "^1.4.0" + } + }, + "koa-static": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/koa-static/-/koa-static-5.0.0.tgz", + "integrity": "sha512-UqyYyH5YEXaJrf9S8E23GoJFQZXkBVJ9zYYMPGz919MSX1KuvAcycIuS0ci150HCoPf4XQVhQ84Qf8xRPWxFaQ==", + "dev": true, + "requires": { + "debug": "^3.1.0", + "koa-send": "^5.0.0" + }, + "dependencies": { + "debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + } + } + }, + "levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "requires": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + } + }, + "lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dev": true, + "requires": { + "immediate": "~3.0.5" + } + }, + "lighthouse-logger": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-1.2.0.tgz", + "integrity": "sha512-wzUvdIeJZhRsG6gpZfmSCfysaxNEr43i+QT+Hie94wvHDKFLi4n7C2GqZ4sTC+PH5b5iktmXJvU87rWvhP3lHw==", + "dev": true, + "requires": { + "debug": "^2.6.8", + "marky": "^1.2.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + } + } + }, + "lines-and-columns": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.1.6.tgz", + "integrity": "sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=", + "dev": true + }, + "load-json-file": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-2.0.0.tgz", + "integrity": "sha1-eUfkIUmvgNaWy/eXvKq8/h/inKg=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "parse-json": "^2.2.0", + "pify": "^2.0.0", + "strip-bom": "^3.0.0" + }, + "dependencies": { + "parse-json": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-2.2.0.tgz", + "integrity": "sha1-9ID0BDTvgHQfhGkJn43qGPVaTck=", + "dev": true, + "requires": { + "error-ex": "^1.2.0" + } + }, + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", + "dev": true + } + } + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "requires": { + "p-locate": "^4.1.0" + } + }, + "lodash": { + "version": "4.17.20", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", + "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==", + "dev": true + }, + "lodash._reinterpolate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz", + "integrity": "sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0=", + "dev": true + }, + "lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha1-soqmKIorn8ZRA1x3EfZathkDMaY=", + "dev": true + }, + "lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=", + "dev": true + }, + "lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA=", + "dev": true + }, + "lodash.ismatch": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.ismatch/-/lodash.ismatch-4.4.0.tgz", + "integrity": "sha1-dWy1FQyjum8RCFp4hJZF8Yj4Xzc=", + "dev": true + }, + "lodash.sortby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", + "integrity": "sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=", + "dev": true + }, + "lodash.template": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.template/-/lodash.template-4.5.0.tgz", + "integrity": "sha512-84vYFxIkmidUiFxidA/KjjH9pAycqW+h980j7Fuz5qxRtO9pgB7MDFTdys1N7A5mcucRiDyEq4fusljItR1T/A==", + "dev": true, + "requires": { + "lodash._reinterpolate": "^3.0.0", + "lodash.templatesettings": "^4.0.0" + } + }, + "lodash.templatesettings": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.templatesettings/-/lodash.templatesettings-4.2.0.tgz", + "integrity": "sha512-stgLz+i3Aa9mZgnjr/O+v9ruKZsPsndy7qPZOchbqk2cnTU1ZaldKK+v7m54WoKIyxiuMZTKT2H81F8BeAc3ZQ==", + "dev": true, + "requires": { + "lodash._reinterpolate": "^3.0.0" + } + }, + "lodash.zip": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.zip/-/lodash.zip-4.2.0.tgz", + "integrity": "sha1-7GZi5IlkCO1KtsVCo5kLcswIACA=", + "dev": true + }, + "log-symbols": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.0.0.tgz", + "integrity": "sha512-FN8JBzLx6CzeMrB0tg6pqlGU1wCrXW+ZXGH481kfsBqer0hToTIiHdjH4Mq8xJUbvATujKCvaREGWpGUionraA==", + "dev": true, + "requires": { + "chalk": "^4.0.0" + } + }, + "log-update": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-4.0.0.tgz", + "integrity": "sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==", + "dev": true, + "requires": { + "ansi-escapes": "^4.3.0", + "cli-cursor": "^3.1.0", + "slice-ansi": "^4.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "loud-rejection": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/loud-rejection/-/loud-rejection-1.6.0.tgz", + "integrity": "sha1-W0b4AUft7leIcPCG0Eghz5mOVR8=", + "dev": true, + "requires": { + "currently-unhandled": "^0.4.1", + "signal-exit": "^3.0.0" + } + }, + "lower-case": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.1.tgz", + "integrity": "sha512-LiWgfDLLb1dwbFQZsSglpRj+1ctGnayXz3Uv0/WO8n558JycT5fg6zkNcnW0G68Nn0aEldTFeEfmjCfmqry/rQ==", + "dev": true, + "requires": { + "tslib": "^1.10.0" + } + }, + "lowercase-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz", + "integrity": "sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==", + "dev": true + }, + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + }, + "make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "requires": { + "semver": "^6.0.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true + }, + "map-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.1.0.tgz", + "integrity": "sha512-glc9y00wgtwcDmp7GaE/0b0OnxpNJsVf3ael/An6Fe2Q51LLwN1er6sdomLRzz5h0+yMpiYLhWYF5R7HeqVd4g==", + "dev": true + }, + "marky": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/marky/-/marky-1.2.1.tgz", + "integrity": "sha512-md9k+Gxa3qLH6sUKpeC2CNkJK/Ld+bEz5X96nYwloqphQE0CKCVEKco/6jxEZixinqNdz5RFi/KaCyfbMDMAXQ==", + "dev": true + }, + "mdurl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", + "integrity": "sha1-/oWy7HWlkDfyrf7BAP1sYBdhFS4=", + "dev": true + }, + "media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=", + "dev": true + }, + "meow": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-8.0.0.tgz", + "integrity": "sha512-nbsTRz2fwniJBFgUkcdISq8y/q9n9VbiHYbfwklFh5V4V2uAcxtKQkDc0yCLPM/kP0d+inZBewn3zJqewHE7kg==", + "dev": true, + "requires": { + "@types/minimist": "^1.2.0", + "camelcase-keys": "^6.2.2", + "decamelize-keys": "^1.1.0", + "hard-rejection": "^2.1.0", + "minimist-options": "4.1.0", + "normalize-package-data": "^3.0.0", + "read-pkg-up": "^7.0.1", + "redent": "^3.0.0", + "trim-newlines": "^3.0.0", + "type-fest": "^0.18.0", + "yargs-parser": "^20.2.3" + } + }, + "merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true + }, + "micromatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.2.tgz", + "integrity": "sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q==", + "dev": true, + "requires": { + "braces": "^3.0.1", + "picomatch": "^2.0.5" + } + }, + "mime": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.4.6.tgz", + "integrity": "sha512-RZKhC3EmpBchfTGBVb8fb+RL2cWyw/32lshnsETttkBAyAUXSGHxbEJWWRXc751DrIxG1q04b8QwMbAwkRPpUA==", + "dev": true + }, + "mime-db": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.44.0.tgz", + "integrity": "sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg==", + "dev": true + }, + "mime-types": { + "version": "2.1.27", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.27.tgz", + "integrity": "sha512-JIhqnCasI9yD+SsmkquHBxTSEuZdQX5BuQnS2Vc7puQQQ+8yiP5AY5uWhpdv4YL4VM5c6iliiYWPgJ/nJQLp7w==", + "dev": true, + "requires": { + "mime-db": "1.44.0" + } + }, + "mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true + }, + "mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true + }, + "min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true + }, + "minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "dev": true + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", + "dev": true + }, + "minimist-options": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz", + "integrity": "sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==", + "dev": true, + "requires": { + "arrify": "^1.0.1", + "is-plain-obj": "^1.1.0", + "kind-of": "^6.0.3" + } + }, + "mkdirp": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", + "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", + "dev": true, + "requires": { + "minimist": "^1.2.5" + } + }, + "mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" + }, + "mocha": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-8.2.1.tgz", + "integrity": "sha512-cuLBVfyFfFqbNR0uUKbDGXKGk+UDFe6aR4os78XIrMQpZl/nv7JYHcvP5MFIAb374b2zFXsdgEGwmzMtP0Xg8w==", + "dev": true, + "requires": { + "@ungap/promise-all-settled": "1.1.2", + "ansi-colors": "4.1.1", + "browser-stdout": "1.3.1", + "chokidar": "3.4.3", + "debug": "4.2.0", + "diff": "4.0.2", + "escape-string-regexp": "4.0.0", + "find-up": "5.0.0", + "glob": "7.1.6", + "growl": "1.10.5", + "he": "1.2.0", + "js-yaml": "3.14.0", + "log-symbols": "4.0.0", + "minimatch": "3.0.4", + "ms": "2.1.2", + "nanoid": "3.1.12", + "serialize-javascript": "5.0.1", + "strip-json-comments": "3.1.1", + "supports-color": "7.2.0", + "which": "2.0.2", + "wide-align": "1.1.3", + "workerpool": "6.0.2", + "yargs": "13.3.2", + "yargs-parser": "13.1.2", + "yargs-unparser": "2.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "dev": true + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "cliui": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz", + "integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==", + "dev": true, + "requires": { + "string-width": "^3.1.0", + "strip-ansi": "^5.2.0", + "wrap-ansi": "^5.1.0" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", + "dev": true + }, + "debug": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.2.0.tgz", + "integrity": "sha512-IX2ncY78vDTjZMFUdmsvIRFY2Cf4FnD0wRs+nQwJU8Lu99/tPFdb0VybiiMTPe3I6rQmwsqQqRBvxU+bZ/I8sg==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "emoji-regex": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", + "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", + "dev": true + }, + "escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true + }, + "find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "requires": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + } + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", + "dev": true + }, + "js-yaml": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.0.tgz", + "integrity": "sha512-/4IbIeHcD9VMHFqDR/gQ7EdZdLimOvW2DdcxFjdyyZ9NsbS+ccrXqVWDtab/lRl5AlUqmpBx8EhPaWR+OtY17A==", + "dev": true, + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } + }, + "locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "requires": { + "p-locate": "^5.0.0" + } + }, + "p-limit": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.0.2.tgz", + "integrity": "sha512-iwqZSOoWIW+Ew4kAGUlN16J4M7OB3ysMLSZtnhmqx7njIHFPlxWBX8xo3lVTyFVq6mI/lL9qt2IsN1sHwaxJkg==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "requires": { + "p-limit": "^3.0.2" + } + }, + "string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "dev": true, + "requires": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + } + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "requires": { + "ansi-regex": "^4.1.0" + } + }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + }, + "wrap-ansi": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", + "integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.0", + "string-width": "^3.0.0", + "strip-ansi": "^5.0.0" + } + }, + "yargs": { + "version": "13.3.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz", + "integrity": "sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==", + "dev": true, + "requires": { + "cliui": "^5.0.0", + "find-up": "^3.0.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^3.0.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^13.1.2" + }, + "dependencies": { + "find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "requires": { + "locate-path": "^3.0.0" + } + }, + "locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "requires": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + } + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "requires": { + "p-limit": "^2.0.0" + } + }, + "path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=", + "dev": true + } + } + }, + "yargs-parser": { + "version": "13.1.2", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.2.tgz", + "integrity": "sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==", + "dev": true, + "requires": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } + } + } + }, + "modify-values": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/modify-values/-/modify-values-1.0.1.tgz", + "integrity": "sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw==", + "dev": true + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "multimap": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/multimap/-/multimap-1.1.0.tgz", + "integrity": "sha512-0ZIR9PasPxGXmRsEF8jsDzndzHDj7tIav+JUmvIFB/WHswliFnquxECT/De7GR4yg99ky/NlRKJT82G1y271bw==", + "dev": true + }, + "mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "requires": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "nanoid": { + "version": "3.1.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.12.tgz", + "integrity": "sha512-1qstj9z5+x491jfiC4Nelk+f8XBad7LN20PmyWINJEMRSf3wcAjAWysw1qaA8z6NSKe2sjq1hRSDpBH5paCb6A==", + "dev": true + }, + "natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", + "dev": true + }, + "ncp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz", + "integrity": "sha1-GVoh1sRuNh0vsSgbo4uR6d9727M=", + "dev": true + }, + "negotiator": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz", + "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==", + "dev": true + }, + "neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true + }, + "nice-try": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", + "dev": true + }, + "nise": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/nise/-/nise-4.0.4.tgz", + "integrity": "sha512-bTTRUNlemx6deJa+ZyoCUTRvH3liK5+N6VQZ4NIw90AgDXY6iPnsqplNFf6STcj+ePk0H/xqxnP75Lr0J0Fq3A==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.7.0", + "@sinonjs/fake-timers": "^6.0.0", + "@sinonjs/text-encoding": "^0.7.1", + "just-extend": "^4.0.2", + "path-to-regexp": "^1.7.0" + } + }, + "no-case": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.3.tgz", + "integrity": "sha512-ehY/mVQCf9BL0gKfsJBvFJen+1V//U+0HQMPrWct40ixE4jnv0bfvxDbWtAHL9EcaPEOJHVVYKoQn1TlZUB8Tw==", + "dev": true, + "requires": { + "lower-case": "^2.0.1", + "tslib": "^1.10.0" + } + }, + "node-fetch": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz", + "integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==" + }, + "normalize-package-data": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.0.tgz", + "integrity": "sha512-6lUjEI0d3v6kFrtgA/lOx4zHCWULXsFNIjHolnZCKCTLA6m/G625cdn3O7eNmT0iD3jfo6HZ9cdImGZwf21prw==", + "dev": true, + "requires": { + "hosted-git-info": "^3.0.6", + "resolve": "^1.17.0", + "semver": "^7.3.2", + "validate-npm-package-license": "^3.0.1" + } + }, + "normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true + }, + "normalize-url": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-2.0.1.tgz", + "integrity": "sha512-D6MUW4K/VzoJ4rJ01JFKxDrtY1v9wrgzCX5f2qj/lzH1m/lW6MhUZFKerVsnyjOhOsYzI9Kqqak+10l4LvLpMw==", + "dev": true, + "requires": { + "prepend-http": "^2.0.0", + "query-string": "^5.0.1", + "sort-keys": "^2.0.0" + }, + "dependencies": { + "sort-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-2.0.0.tgz", + "integrity": "sha1-ZYU1WEhh7JfXMNbPQYIuH1ZoQSg=", + "dev": true, + "requires": { + "is-plain-obj": "^1.0.0" + } + } + } + }, + "npm-conf": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/npm-conf/-/npm-conf-1.1.3.tgz", + "integrity": "sha512-Yic4bZHJOt9RCFbRP3GgpqhScOY4HH3V2P8yBj6CeYq118Qr+BLXqT2JvpJ00mryLESpgOxf5XlFv4ZjXxLScw==", + "dev": true, + "requires": { + "config-chain": "^1.1.11", + "pify": "^3.0.0" + }, + "dependencies": { + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", + "dev": true + } + } + }, + "npm-run-path": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", + "integrity": "sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8=", + "dev": true, + "requires": { + "path-key": "^2.0.0" + } + }, + "null-check": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/null-check/-/null-check-1.0.0.tgz", + "integrity": "sha1-l33/1xdgErnsMNKjnbXPcqBDnt0=", + "dev": true + }, + "number-is-nan": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", + "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=", + "dev": true + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", + "dev": true + }, + "object-inspect": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.8.0.tgz", + "integrity": "sha512-jLdtEOB112fORuypAyl/50VRVIBIdVQOSUUGQHzJ4xBSbit81zRarz7GThkEFZy1RceYrWYcPcBFPQwHyAc1gA==", + "dev": true + }, + "object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true + }, + "object.assign": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.2.tgz", + "integrity": "sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3", + "has-symbols": "^1.0.1", + "object-keys": "^1.1.1" + } + }, + "object.values": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.1.tgz", + "integrity": "sha512-WTa54g2K8iu0kmS/us18jEmdv1a4Wi//BZ/DTVYEcH0XhLM5NYdpDHja3gt57VrZLcNAO2WGA+KpWsDBaHt6eA==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.0-next.1", + "function-bind": "^1.1.1", + "has": "^1.0.3" + } + }, + "on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=", + "dev": true, + "requires": { + "ee-first": "1.1.1" + } + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "requires": { + "wrappy": "1" + } + }, + "onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "requires": { + "mimic-fn": "^2.1.0" + } + }, + "only": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/only/-/only-0.0.2.tgz", + "integrity": "sha1-Kv3oTQPlC5qO3EROMGEKcCle37Q=", + "dev": true + }, + "open": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/open/-/open-7.3.0.tgz", + "integrity": "sha512-mgLwQIx2F/ye9SmbrUkurZCnkoXyXyu9EbHtJZrICjVAJfyMArdHp3KkixGdZx1ZHFPNIwl0DDM1dFFqXbTLZw==", + "dev": true, + "requires": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + } + }, + "opencollective-postinstall": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz", + "integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==", + "dev": true + }, + "optionator": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", + "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==", + "dev": true, + "requires": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.3" + } + }, + "os-filter-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/os-filter-obj/-/os-filter-obj-2.0.0.tgz", + "integrity": "sha512-uksVLsqG3pVdzzPvmAHpBK0wKxYItuzZr7SziusRPoz67tGV8rL1szZ6IdeUrbqLjGDwApBtN29eEE3IqGHOjg==", + "dev": true, + "requires": { + "arch": "^2.1.0" + } + }, + "os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", + "dev": true + }, + "p-cancelable": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-0.4.1.tgz", + "integrity": "sha512-HNa1A8LvB1kie7cERyy21VNeHb2CWJJYqyyC2o3klWFfMGlFmWv2Z7sFgZH8ZiaYL95ydToKTFVXgMV/Os0bBQ==", + "dev": true + }, + "p-event": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/p-event/-/p-event-2.3.1.tgz", + "integrity": "sha512-NQCqOFhbpVTMX4qMe8PF8lbGtzZ+LCiN7pcNrb/413Na7+TRoe1xkKUzuWa/YEJdGQ0FvKtj35EEbDoVPO2kbA==", + "dev": true, + "requires": { + "p-timeout": "^2.0.1" + } + }, + "p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=", + "dev": true + }, + "p-is-promise": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/p-is-promise/-/p-is-promise-1.1.0.tgz", + "integrity": "sha1-nJRWmJ6fZYgBewQ01WCXZ1w9oF4=", + "dev": true + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "requires": { + "p-limit": "^2.2.0" + } + }, + "p-timeout": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-2.0.1.tgz", + "integrity": "sha512-88em58dDVB/KzPEx1X0N3LwFfYZPyDc4B6eF38M1rk9VTZMbxXXgjugz8mmwpS9Ox4BDZ+t6t3QP5+/gazweIA==", + "dev": true, + "requires": { + "p-finally": "^1.0.0" + } + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==" + }, + "pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "dev": true + }, + "param-case": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.3.tgz", + "integrity": "sha512-VWBVyimc1+QrzappRs7waeN2YmoZFCGXWASRYX1/rGHtXqEcrGEIDm+jqIwFa2fRXNgQEwrxaYuIrX0WcAguTA==", + "dev": true, + "requires": { + "dot-case": "^3.0.3", + "tslib": "^1.10.0" + } + }, + "parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "requires": { + "callsites": "^3.0.0" + } + }, + "parse-github-repo-url": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/parse-github-repo-url/-/parse-github-repo-url-1.4.1.tgz", + "integrity": "sha1-nn2LslKmy2ukJZUGC3v23z28H1A=", + "dev": true + }, + "parse-json": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.1.0.tgz", + "integrity": "sha512-+mi/lmVVNKFNVyLXV31ERiy2CY5E1/F6QtJFEzoChPRwwngMNXRDQ9GJ5WdE2Z2P4AujsOi0/+2qHID68KwfIQ==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + } + }, + "parse5": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", + "dev": true + }, + "parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true + }, + "pascal-case": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.1.tgz", + "integrity": "sha512-XIeHKqIrsquVTQL2crjq3NfJUxmdLasn3TYOU0VBM+UX2a6ztAWBlJQBePLGY7VHW8+2dRadeIPK5+KImwTxQA==", + "dev": true, + "requires": { + "no-case": "^3.0.3", + "tslib": "^1.10.0" + } + }, + "path-case": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/path-case/-/path-case-3.0.3.tgz", + "integrity": "sha512-UMFU6UETFpCNWbIWNczshPrnK/7JAXBP2NYw80ojElbQ2+JYxdqWDBkvvqM93u4u6oLmuJ/tPOf2tM8KtXv4eg==", + "dev": true, + "requires": { + "dot-case": "^3.0.3", + "tslib": "^1.10.0" + } + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==" + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" + }, + "path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=", + "dev": true + }, + "path-parse": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", + "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==", + "dev": true + }, + "path-to-regexp": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", + "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", + "dev": true, + "requires": { + "isarray": "0.0.1" + }, + "dependencies": { + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", + "dev": true + } + } + }, + "path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true + }, + "pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha1-elfrVQpng/kRUzH89GY9XI4AelA=" + }, + "picomatch": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz", + "integrity": "sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==", + "dev": true + }, + "pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true + }, + "pinkie": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", + "integrity": "sha1-clVrgM+g1IqXToDnckjoDtT3+HA=", + "dev": true + }, + "pinkie-promise": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", + "integrity": "sha1-ITXW36ejWMBprJsXh3YogihFD/o=", + "dev": true, + "requires": { + "pinkie": "^2.0.0" + } + }, + "pixelmatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-4.0.2.tgz", + "integrity": "sha1-j0fc7FARtHe2fbA8JDvB8wheiFQ=", + "dev": true, + "requires": { + "pngjs": "^3.0.0" + }, + "dependencies": { + "pngjs": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-3.4.0.tgz", + "integrity": "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==", + "dev": true + } + } + }, + "pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "requires": { + "find-up": "^4.0.0" + } + }, + "please-upgrade-node": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/please-upgrade-node/-/please-upgrade-node-3.2.0.tgz", + "integrity": "sha512-gQR3WpIgNIKwBMVLkpMUeR3e1/E1y42bqDQZfql+kDeXd8COYfM8PQA4X6y7a8u9Ua9FHmsrrmirW2vHs45hWg==", + "dev": true, + "requires": { + "semver-compare": "^1.0.0" + } + }, + "pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true + }, + "pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "dev": true + }, + "portfinder": { + "version": "1.0.28", + "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.28.tgz", + "integrity": "sha512-Se+2isanIcEqf2XMHjyUKskczxbPH7dQnlMjXX6+dybayyHvAf/TCgyMRlzf/B6QDhAEFOGes0pzRo3by4AbMA==", + "dev": true, + "requires": { + "async": "^2.6.2", + "debug": "^3.1.1", + "mkdirp": "^0.5.5" + }, + "dependencies": { + "debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + } + } + }, + "prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true + }, + "prepend-http": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz", + "integrity": "sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc=", + "dev": true + }, + "prettier": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.2.0.tgz", + "integrity": "sha512-yYerpkvseM4iKD/BXLYUkQV5aKt4tQPqaGW6EsZjzyu0r7sVZZNPJW4Y8MyKmicp6t42XUPcBVA+H6sB3gqndw==", + "dev": true + }, + "prettier-linter-helpers": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", + "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", + "dev": true, + "requires": { + "fast-diff": "^1.1.2" + } + }, + "pretty-format": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-25.5.0.tgz", + "integrity": "sha512-kbo/kq2LQ/A/is0PQwsEHM7Ca6//bGPPvU6UnsdDRSKTWxT/ru/xb88v4BJf6a69H+uTytOEsTusT9ksd/1iWQ==", + "dev": true, + "requires": { + "@jest/types": "^25.5.0", + "ansi-regex": "^5.0.0", + "ansi-styles": "^4.0.0", + "react-is": "^16.12.0" + } + }, + "process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true + }, + "progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==" + }, + "proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha1-IS1b/hMYMGpCD2QCuOJv85ZHqEk=", + "dev": true + }, + "proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, + "pseudomap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", + "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=", + "dev": true + }, + "pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "punycode": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", + "dev": true + }, + "puppeteer-core": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-5.5.0.tgz", + "integrity": "sha512-tlA+1n+ziW/Db03hVV+bAecDKse8ihFRXYiEypBe9IlLRvOCzYFG6qrCMBYK34HO/Q/Ecjc+tvkHRAfLVH+NgQ==", + "dev": true, + "requires": { + "debug": "^4.1.0", + "devtools-protocol": "0.0.818844", + "extract-zip": "^2.0.0", + "https-proxy-agent": "^4.0.0", + "node-fetch": "^2.6.1", + "pkg-dir": "^4.2.0", + "progress": "^2.0.1", + "proxy-from-env": "^1.0.0", + "rimraf": "^3.0.2", + "tar-fs": "^2.0.0", + "unbzip2-stream": "^1.3.3", + "ws": "^7.2.3" + } + }, + "q": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", + "integrity": "sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc=", + "dev": true + }, + "qs": { + "version": "6.9.4", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.9.4.tgz", + "integrity": "sha512-A1kFqHekCTM7cz0udomYUoYNWjBebHm/5wzU/XqrBRBNWectVH0QIiN+NEcZ0Dte5hvzHwbr8+XQmguPhJ6WdQ==", + "dev": true + }, + "query-string": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-5.1.1.tgz", + "integrity": "sha512-gjWOsm2SoGlgLEdAGt7a6slVOk9mGiXmPFMqrEhLQ68rhQuBnpfs3+EmlvqKyxnCo9/PPlF+9MtY02S1aFg+Jw==", + "dev": true, + "requires": { + "decode-uri-component": "^0.2.0", + "object-assign": "^4.1.0", + "strict-uri-encode": "^1.0.0" + } + }, + "quick-lru": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz", + "integrity": "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==", + "dev": true + }, + "ramda": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.27.1.tgz", + "integrity": "sha512-PgIdVpn5y5Yns8vqb8FzBUEYn98V3xcPgawAkkgj0YJ0qDsnHCiNmZYfOGMgOvoB0eWFLpYbhxUR3mxfDIMvpw==", + "dev": true + }, + "randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "requires": { + "safe-buffer": "^5.1.0" + } + }, + "raw-body": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.1.tgz", + "integrity": "sha512-9WmIKF6mkvA0SLmA2Knm9+qj89e+j1zqgyn8aXGd7+nAduPoqgI9lO57SAZNn/Byzo5P7JhXTyg9PzaJbH73bA==", + "dev": true, + "requires": { + "bytes": "3.1.0", + "http-errors": "1.7.3", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "dependencies": { + "http-errors": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.3.tgz", + "integrity": "sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==", + "dev": true, + "requires": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.1.1", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.0" + } + } + } + }, + "react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true + }, + "read-pkg": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", + "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==", + "dev": true, + "requires": { + "@types/normalize-package-data": "^2.4.0", + "normalize-package-data": "^2.5.0", + "parse-json": "^5.0.0", + "type-fest": "^0.6.0" + }, + "dependencies": { + "hosted-git-info": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.8.tgz", + "integrity": "sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg==", + "dev": true + }, + "normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "requires": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + }, + "type-fest": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz", + "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==", + "dev": true + } + } + }, + "read-pkg-up": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz", + "integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==", + "dev": true, + "requires": { + "find-up": "^4.1.0", + "read-pkg": "^5.2.0", + "type-fest": "^0.8.1" + }, + "dependencies": { + "type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "dev": true + } + } + }, + "readable-stream": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", + "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + }, + "readdirp": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.5.0.tgz", + "integrity": "sha512-cMhu7c/8rdhkHXWsY+osBhfSy0JikwpHK/5+imo+LpeasTF8ouErHrlYkwT0++njiyuDvc7OFY5T3ukvZ8qmFQ==", + "dev": true, + "requires": { + "picomatch": "^2.2.1" + } + }, + "rechoir": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", + "integrity": "sha1-hSBLVNuoLVdC4oyWdW70OvUOM4Q=", + "dev": true, + "requires": { + "resolve": "^1.1.6" + } + }, + "redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "requires": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + } + }, + "reduce-flatten": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/reduce-flatten/-/reduce-flatten-2.0.0.tgz", + "integrity": "sha512-EJ4UNY/U1t2P/2k6oqotuX2Cc3T6nxJwsM0N0asT7dhrtH1ltUxDn4NalSYmPE2rCkVpcf/X6R0wDwcFpzhd4w==", + "dev": true + }, + "regenerator-runtime": { + "version": "0.13.7", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz", + "integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==", + "dev": true + }, + "regexp-tree": { + "version": "0.1.21", + "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.21.tgz", + "integrity": "sha512-kUUXjX4AnqnR8KRTCrayAo9PzYMRKmVoGgaz2tBuz0MF3g1ZbGebmtW0yFHfFK9CmBjQKeYIgoL22pFLBJY7sw==", + "dev": true + }, + "regexpp": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.1.0.tgz", + "integrity": "sha512-ZOIzd8yVsQQA7j8GCSlPGXwg5PfmA1mrq0JP4nGhh54LaKN3xdai/vHUDu74pKwV8OxseMS65u2NImosQcSD0Q==", + "dev": true + }, + "repeating": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/repeating/-/repeating-2.0.1.tgz", + "integrity": "sha1-UhTFOpJtNVJwdSf7q0FdvAjQbdo=", + "dev": true, + "requires": { + "is-finite": "^1.0.0" + } + }, + "require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=", + "dev": true + }, + "require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "dev": true + }, + "reserved-words": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/reserved-words/-/reserved-words-0.1.2.tgz", + "integrity": "sha1-AKCUD5jNUBrqqsMWQR2a3FKzGrE=", + "dev": true + }, + "resolve": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.19.0.tgz", + "integrity": "sha512-rArEXAgsBG4UgRGcynxWIWKFvh/XZCcS8UJdHhwy91zwAvCZIbcs+vAbflgBnNjYMs/i/i+/Ux6IZhML1yPvxg==", + "dev": true, + "requires": { + "is-core-module": "^2.1.0", + "path-parse": "^1.0.6" + } + }, + "resolve-alpn": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.0.0.tgz", + "integrity": "sha512-rTuiIEqFmGxne4IovivKSDzld2lWW9QCjqv80SYjPgf+gS35eaCAjaP54CCwGAwBtnCsvNLYtqxe1Nw+i6JEmA==", + "dev": true + }, + "resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true + }, + "resolve-global": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-global/-/resolve-global-1.0.0.tgz", + "integrity": "sha512-zFa12V4OLtT5XUX/Q4VLvTfBf+Ok0SPc1FNGM/z9ctUdiU618qwKpWnd0CHs3+RqROfyEg/DhuHbMWYqcgljEw==", + "dev": true, + "requires": { + "global-dirs": "^0.1.1" + } + }, + "resolve-path": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/resolve-path/-/resolve-path-1.4.0.tgz", + "integrity": "sha1-xL2p9e+y/OZSR4c6s2u02DT+Fvc=", + "dev": true, + "requires": { + "http-errors": "~1.6.2", + "path-is-absolute": "1.0.1" + }, + "dependencies": { + "http-errors": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", + "integrity": "sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0=", + "dev": true, + "requires": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.0", + "statuses": ">= 1.4.0 < 2" + } + }, + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", + "dev": true + }, + "setprototypeof": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", + "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==", + "dev": true + } + } + }, + "responselike": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-1.0.2.tgz", + "integrity": "sha1-kYcg7ztjHFZCvgaPFa3lpG9Loec=", + "dev": true, + "requires": { + "lowercase-keys": "^1.0.0" + } + }, + "restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "requires": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + } + }, + "reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true + }, + "rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "requires": { + "glob": "^7.1.3" + } + }, + "rollup": { + "version": "2.33.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.33.3.tgz", + "integrity": "sha512-RpayhPTe4Gu/uFGCmk7Gp5Z9Qic2VsqZ040G+KZZvsZYdcuWaJg678JeDJJvJeEQXminu24a2au+y92CUWVd+w==", + "dev": true, + "requires": { + "fsevents": "~2.1.2" + } + }, + "run-parallel": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.1.10.tgz", + "integrity": "sha512-zb/1OuZ6flOlH6tQyMPUrE3x3Ulxjlo9WIVXR4yVYi4H9UXQaeIsPbLn2R3O3vQCnDKkAl2qHiuocKKX4Tz/Sw==", + "dev": true + }, + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" + }, + "safe-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-2.1.1.tgz", + "integrity": "sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==", + "dev": true, + "requires": { + "regexp-tree": "~0.1.1" + } + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true + }, + "saucelabs": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/saucelabs/-/saucelabs-4.5.2.tgz", + "integrity": "sha512-D5T+KMFMi2PFS64Qhsjc/ibO9hSGRTC2VDi0D4MXvuNkbEc9vT8yx+l7PwrLlnDoN8jfJdpKiCrTe4Of3FpRvw==", + "dev": true, + "requires": { + "bin-wrapper": "^4.1.0", + "change-case": "^4.1.1", + "form-data": "^3.0.0", + "got": "^11.7.0", + "hash.js": "^1.1.7", + "tunnel": "0.0.6", + "yargs": "^16.0.3" + }, + "dependencies": { + "cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + } + }, + "y18n": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.5.tgz", + "integrity": "sha512-hsRUr4FFrvhhRH12wOdfs38Gy7k2FFzB9qgN9v3aLykRq0dRcdcpz5C9FxdS2NuhOrI/628b/KSTJ3rwHysYSg==", + "dev": true + }, + "yargs": { + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.1.1.tgz", + "integrity": "sha512-hAD1RcFP/wfgfxgMVswPE+z3tlPFtxG8/yWUrG2i17sTWGCGqWnxKcLTF4cUKDUK8fzokwsmO9H0TDkRbMHy8w==", + "dev": true, + "requires": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + } + } + } + }, + "seek-bzip": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/seek-bzip/-/seek-bzip-1.0.6.tgz", + "integrity": "sha512-e1QtP3YL5tWww8uKaOCQ18UxIT2laNBXHjV/S2WYCiK4udiv8lkG89KRIoCjUagnAmCBurjF4zEVX2ByBbnCjQ==", + "dev": true, + "requires": { + "commander": "^2.8.1" + } + }, + "selenium-webdriver": { + "version": "4.0.0-alpha.7", + "resolved": "https://registry.npmjs.org/selenium-webdriver/-/selenium-webdriver-4.0.0-alpha.7.tgz", + "integrity": "sha512-D4qnTsyTr91jT8f7MfN+OwY0IlU5+5FmlO5xlgRUV6hDEV8JyYx2NerdTEqDDkNq7RZDYc4VoPALk8l578RBHw==", + "dev": true, + "requires": { + "jszip": "^3.2.2", + "rimraf": "^2.7.1", + "tmp": "0.0.30" + }, + "dependencies": { + "rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + } + } + }, + "semver": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.2.tgz", + "integrity": "sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ==", + "dev": true + }, + "semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha1-De4hahyUGrN+nvsXiPavxf9VN/w=", + "dev": true + }, + "semver-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/semver-regex/-/semver-regex-2.0.0.tgz", + "integrity": "sha512-mUdIBBvdn0PLOeP3TEkMH7HHeUP3GjsXCwKarjv/kGmUFOYg1VqEemKhoQpWMu6X2I8kHeuVdGibLGkVK+/5Qw==", + "dev": true + }, + "semver-truncate": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/semver-truncate/-/semver-truncate-1.1.2.tgz", + "integrity": "sha1-V/Qd5pcHpicJp+AQS6IRcQnqR+g=", + "dev": true, + "requires": { + "semver": "^5.3.0" + }, + "dependencies": { + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + } + } + }, + "sentence-case": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sentence-case/-/sentence-case-3.0.3.tgz", + "integrity": "sha512-ZPr4dgTcNkEfcGOMFQyDdJrTU9uQO1nb1cjf+nuzb6FxgMDgKddZOM29qEsB7jvsZSMruLRcL2KfM4ypKpa0LA==", + "dev": true, + "requires": { + "no-case": "^3.0.3", + "tslib": "^1.10.0", + "upper-case-first": "^2.0.1" + } + }, + "serialize-javascript": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-5.0.1.tgz", + "integrity": "sha512-SaaNal9imEO737H2c05Og0/8LUXG7EnsZyMa8MzkmuHoELfT6txuj0cMqRj6zfPKnmQ1yasR4PCJc8x+M4JSPA==", + "dev": true, + "requires": { + "randombytes": "^2.1.0" + } + }, + "set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=", + "dev": true + }, + "set-immediate-shim": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz", + "integrity": "sha1-SysbJ+uAip+NzEgaWOXlb1mfP2E=", + "dev": true + }, + "setprototypeof": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz", + "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==", + "dev": true + }, + "shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=", + "dev": true, + "requires": { + "shebang-regex": "^1.0.0" + } + }, + "shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=", + "dev": true + }, + "shelljs": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.8.4.tgz", + "integrity": "sha512-7gk3UZ9kOfPLIAbslLzyWeGiEqx9e3rxwZM0KE6EL8GlGwjym9Mrlx5/p33bWTu9YG6vcS4MBxYZDHYr5lr8BQ==", + "dev": true, + "requires": { + "glob": "^7.0.0", + "interpret": "^1.0.0", + "rechoir": "^0.6.2" + } + }, + "signal-exit": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz", + "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==", + "dev": true + }, + "sinon": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-9.2.1.tgz", + "integrity": "sha512-naPfsamB5KEE1aiioaoqJ6MEhdUs/2vtI5w1hPAXX/UwvoPjXcwh1m5HiKx0HGgKR8lQSoFIgY5jM6KK8VrS9w==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.8.1", + "@sinonjs/fake-timers": "^6.0.1", + "@sinonjs/formatio": "^5.0.1", + "@sinonjs/samsam": "^5.2.0", + "diff": "^4.0.2", + "nise": "^4.0.4", + "supports-color": "^7.1.0" + } + }, + "slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true + }, + "slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "requires": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + } + }, + "snake-case": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.3.tgz", + "integrity": "sha512-WM1sIXEO+rsAHBKjGf/6R1HBBcgbncKS08d2Aqec/mrDSpU80SiOU41hO7ny6DToHSyrlwTYzQBIK1FPSx4Y3Q==", + "dev": true, + "requires": { + "dot-case": "^3.0.3", + "tslib": "^1.10.0" + } + }, + "sort-keys": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-1.1.2.tgz", + "integrity": "sha1-RBttTTRnmPG05J6JIK37oOVD+a0=", + "dev": true, + "requires": { + "is-plain-obj": "^1.0.0" + } + }, + "sort-keys-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/sort-keys-length/-/sort-keys-length-1.0.1.tgz", + "integrity": "sha1-nLb09OnkgVWmqgZx7dM2/xR5oYg=", + "dev": true, + "requires": { + "sort-keys": "^1.0.0" + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + }, + "source-map-support": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.19.tgz", + "integrity": "sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==", + "dev": true, + "requires": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "spdx-correct": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.1.tgz", + "integrity": "sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w==", + "dev": true, + "requires": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-exceptions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz", + "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==", + "dev": true + }, + "spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "requires": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-license-ids": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.6.tgz", + "integrity": "sha512-+orQK83kyMva3WyPf59k1+Y525csj5JejicWut55zeTWANuN17qSiSLUXWtzHeNWORSvT7GLDJ/E/XiIWoXBTw==", + "dev": true + }, + "split": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/split/-/split-1.0.1.tgz", + "integrity": "sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==", + "dev": true, + "requires": { + "through": "2" + } + }, + "split2": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-2.2.0.tgz", + "integrity": "sha512-RAb22TG39LhI31MbreBgIuKiIKhVsawfTgEGqKHTK87aG+ul/PB8Sqoi3I7kVdRWiCfrKxK3uo4/YUkpNvhPbw==", + "dev": true, + "requires": { + "through2": "^2.0.2" + }, + "dependencies": { + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.0" + } + }, + "through2": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", + "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", + "dev": true, + "requires": { + "readable-stream": "~2.3.6", + "xtend": "~4.0.1" + } + } + } + }, + "sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", + "dev": true + }, + "stack-utils": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-1.0.3.tgz", + "integrity": "sha512-WldO+YmqhEpjp23eHZRhOT1NQF51STsbxZ+/AdpFD+EhheFxAe5d0WoK4DQVJkSHacPrJJX3OqRAl9CgHf78pg==", + "dev": true, + "requires": { + "escape-string-regexp": "^2.0.0" + }, + "dependencies": { + "escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true + } + } + }, + "standard-version": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/standard-version/-/standard-version-9.0.0.tgz", + "integrity": "sha512-eRR04IscMP3xW9MJTykwz13HFNYs8jS33AGuDiBKgfo5YrO0qX0Nxb4rjupVwT5HDYL/aR+MBEVLjlmVFmFEDQ==", + "dev": true, + "requires": { + "chalk": "^2.4.2", + "conventional-changelog": "3.1.23", + "conventional-changelog-config-spec": "2.1.0", + "conventional-changelog-conventionalcommits": "4.4.0", + "conventional-recommended-bump": "6.0.10", + "detect-indent": "^6.0.0", + "detect-newline": "^3.1.0", + "dotgitignore": "^2.1.0", + "figures": "^3.1.0", + "find-up": "^4.1.0", + "fs-access": "^1.0.1", + "git-semver-tags": "^4.0.0", + "semver": "^7.1.1", + "stringify-package": "^1.0.1", + "yargs": "^15.3.1" + }, + "dependencies": { + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", + "dev": true + }, + "conventional-changelog-conventionalcommits": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-4.4.0.tgz", + "integrity": "sha512-ybvx76jTh08tpaYrYn/yd0uJNLt5yMrb1BphDe4WBredMlvPisvMghfpnJb6RmRNcqXeuhR6LfGZGewbkRm9yA==", + "dev": true, + "requires": { + "compare-func": "^2.0.0", + "lodash": "^4.17.15", + "q": "^1.5.1" + } + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=", + "dev": true + }, + "strict-uri-encode": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz", + "integrity": "sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM=", + "dev": true + }, + "string-argv": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.1.tgz", + "integrity": "sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg==", + "dev": true + }, + "string-width": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", + "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==", + "dev": true, + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.0" + } + }, + "string.prototype.repeat": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-0.2.0.tgz", + "integrity": "sha1-q6Nt4I3O5qWjN9SbLqHaGyj8Ds8=", + "dev": true + }, + "string.prototype.trimend": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.2.tgz", + "integrity": "sha512-8oAG/hi14Z4nOVP0z6mdiVZ/wqjDtWSLygMigTzAb+7aPEDTleeFf+WrF+alzecxIRkckkJVn+dTlwzJXORATw==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.18.0-next.1" + }, + "dependencies": { + "es-abstract": { + "version": "1.18.0-next.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.18.0-next.1.tgz", + "integrity": "sha512-I4UGspA0wpZXWENrdA0uHbnhte683t3qT/1VFH9aX2dA5PPSf6QW5HHXf5HImaqPmjXaVeVk4RGWnaylmV7uAA==", + "dev": true, + "requires": { + "es-to-primitive": "^1.2.1", + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1", + "is-callable": "^1.2.2", + "is-negative-zero": "^2.0.0", + "is-regex": "^1.1.1", + "object-inspect": "^1.8.0", + "object-keys": "^1.1.1", + "object.assign": "^4.1.1", + "string.prototype.trimend": "^1.0.1", + "string.prototype.trimstart": "^1.0.1" + } + } + } + }, + "string.prototype.trimstart": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.2.tgz", + "integrity": "sha512-7F6CdBTl5zyu30BJFdzSTlSlLPwODC23Od+iLoVH8X6+3fvDPPuBVVj9iaB1GOsSTSIgVfsfm27R2FGrAPznWg==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.18.0-next.1" + }, + "dependencies": { + "es-abstract": { + "version": "1.18.0-next.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.18.0-next.1.tgz", + "integrity": "sha512-I4UGspA0wpZXWENrdA0uHbnhte683t3qT/1VFH9aX2dA5PPSf6QW5HHXf5HImaqPmjXaVeVk4RGWnaylmV7uAA==", + "dev": true, + "requires": { + "es-to-primitive": "^1.2.1", + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1", + "is-callable": "^1.2.2", + "is-negative-zero": "^2.0.0", + "is-regex": "^1.1.1", + "object-inspect": "^1.8.0", + "object-keys": "^1.1.1", + "object.assign": "^4.1.1", + "string.prototype.trimend": "^1.0.1", + "string.prototype.trimstart": "^1.0.1" + } + } + } + }, + "string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "requires": { + "safe-buffer": "~5.2.0" + } + }, + "stringify-package": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/stringify-package/-/stringify-package-1.0.1.tgz", + "integrity": "sha512-sa4DUQsYciMP1xhKWGuFM04fB0LG/9DlluZoSVywUMRNvzid6XucHK0/90xGxRoHrAaROrcHK1aPKaijCtSrhg==", + "dev": true + }, + "strip-ansi": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", + "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.0" + } + }, + "strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=", + "dev": true + }, + "strip-dirs": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/strip-dirs/-/strip-dirs-2.1.0.tgz", + "integrity": "sha512-JOCxOeKLm2CAS73y/U4ZeZPTkE+gNVCzKt7Eox84Iej1LT/2pTWYpZKJuxwQpvX1LiZb1xokNR7RLfuBAa7T3g==", + "dev": true, + "requires": { + "is-natural-number": "^4.0.1" + } + }, + "strip-eof": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", + "integrity": "sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=", + "dev": true + }, + "strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "requires": { + "min-indent": "^1.0.0" + } + }, + "strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true + }, + "strip-outer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/strip-outer/-/strip-outer-1.0.1.tgz", + "integrity": "sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==", + "dev": true, + "requires": { + "escape-string-regexp": "^1.0.2" + } + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + }, + "table": { + "version": "5.4.6", + "resolved": "https://registry.npmjs.org/table/-/table-5.4.6.tgz", + "integrity": "sha512-wmEc8m4fjnob4gt5riFRtTu/6+4rSe12TpAELNSqHMfF3IqnA+CH37USM6/YR3qRZv7e56kAEAtd6nKZaxe0Ug==", + "dev": true, + "requires": { + "ajv": "^6.10.2", + "lodash": "^4.17.14", + "slice-ansi": "^2.1.0", + "string-width": "^3.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "dev": true + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "astral-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-1.0.0.tgz", + "integrity": "sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg==", + "dev": true + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", + "dev": true + }, + "emoji-regex": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", + "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", + "dev": true + }, + "slice-ansi": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-2.1.0.tgz", + "integrity": "sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.0", + "astral-regex": "^1.0.0", + "is-fullwidth-code-point": "^2.0.0" + } + }, + "string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "dev": true, + "requires": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + } + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "requires": { + "ansi-regex": "^4.1.0" + } + } + } + }, + "table-layout": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/table-layout/-/table-layout-1.0.1.tgz", + "integrity": "sha512-dEquqYNJiGwY7iPfZ3wbXDI944iqanTSchrACLL2nOB+1r+h1Nzu2eH+DuPPvWvm5Ry7iAPeFlgEtP5bIp5U7Q==", + "dev": true, + "requires": { + "array-back": "^4.0.1", + "deep-extend": "~0.6.0", + "typical": "^5.2.0", + "wordwrapjs": "^4.0.0" + }, + "dependencies": { + "array-back": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/array-back/-/array-back-4.0.1.tgz", + "integrity": "sha512-Z/JnaVEXv+A9xabHzN43FiiiWEE7gPCRXMrVmRm00tWbjZRul1iHm7ECzlyNq1p4a4ATXz+G9FJ3GqGOkOV3fg==", + "dev": true + }, + "typical": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/typical/-/typical-5.2.0.tgz", + "integrity": "sha512-dvdQgNDNJo+8B2uBQoqdb11eUCE1JQXhvjC/CZtgvZseVd5TYMXnq0+vuUemXbd/Se29cTaUuPX3YIc2xgbvIg==", + "dev": true + } + } + }, + "tar-fs": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", + "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==", + "requires": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "tar-stream": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.1.4.tgz", + "integrity": "sha512-o3pS2zlG4gxr67GmFYBLlq+dM8gyRGUOvsrHclSkvtVtQbjV0s/+ZE8OpICbaj8clrX3tjeHngYGP7rweaBnuw==", + "requires": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + } + }, + "text-diff": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/text-diff/-/text-diff-1.0.1.tgz", + "integrity": "sha1-bBBZBUNeM3hXN1ydL2ymPkU/9WU=", + "dev": true + }, + "text-extensions": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/text-extensions/-/text-extensions-1.9.0.tgz", + "integrity": "sha512-wiBrwC1EhBelW12Zy26JeOUkQ5mRu+5o8rpsJk5+2t+Y5vE7e842qtZDQ2g1NpX/29HdyFeJ4nSIhI47ENSxlQ==", + "dev": true + }, + "text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", + "dev": true + }, + "thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "requires": { + "any-promise": "^1.0.0" + } + }, + "thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha1-GhkY1ALY/D+Y+/I02wvMjMEOlyY=", + "dev": true, + "requires": { + "thenify": ">= 3.1.0 < 4" + } + }, + "through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=" + }, + "through2": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", + "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", + "dev": true, + "requires": { + "readable-stream": "3" + } + }, + "timed-out": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/timed-out/-/timed-out-4.0.1.tgz", + "integrity": "sha1-8y6srFoXW+ol1/q1Zas+2HQe9W8=", + "dev": true + }, + "timsort": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/timsort/-/timsort-0.3.0.tgz", + "integrity": "sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q=", + "dev": true + }, + "tmp": { + "version": "0.0.30", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.30.tgz", + "integrity": "sha1-ckGdSovn1s51FI/YsyTlk6cRwu0=", + "dev": true, + "requires": { + "os-tmpdir": "~1.0.1" + } + }, + "to-buffer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.1.1.tgz", + "integrity": "sha512-lx9B5iv7msuFYE3dytT+KE5tap+rNYw+K4jVkb9R/asAb+pbBSM17jtunHplhBe6RRJdZx3Pn2Jph24O32mOVg==", + "dev": true + }, + "to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=", + "dev": true + }, + "to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "requires": { + "is-number": "^7.0.0" + } + }, + "toidentifier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", + "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==", + "dev": true + }, + "tr46": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-2.0.2.tgz", + "integrity": "sha512-3n1qG+/5kg+jrbTzwAykB5yRYtQCTqOGKq5U5PE3b0a1/mzo6snDhjGS0zJVJunO0NrT3Dg1MLy5TjWP/UJppg==", + "dev": true, + "requires": { + "punycode": "^2.1.1" + } + }, + "trim-newlines": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-3.0.0.tgz", + "integrity": "sha512-C4+gOpvmxaSMKuEf9Qc134F1ZuOHVXKRbtEflf4NTtuuJDEIJ9p5PXsalL8SkeRw+qit1Mo+yuvMPAKwWg/1hA==", + "dev": true + }, + "trim-off-newlines": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/trim-off-newlines/-/trim-off-newlines-1.0.1.tgz", + "integrity": "sha1-n5up2e+odkw4dpi8v+sshI8RrbM=", + "dev": true + }, + "trim-repeated": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/trim-repeated/-/trim-repeated-1.0.0.tgz", + "integrity": "sha1-42RqLqTokTEr9+rObPsFOAvAHCE=", + "dev": true, + "requires": { + "escape-string-regexp": "^1.0.2" + } + }, + "ts-node": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-9.0.0.tgz", + "integrity": "sha512-/TqB4SnererCDR/vb4S/QvSZvzQMJN8daAslg7MeaiHvD8rDZsSfXmNeNumyZZzMned72Xoq/isQljYSt8Ynfg==", + "dev": true, + "requires": { + "arg": "^4.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "source-map-support": "^0.5.17", + "yn": "3.1.1" + } + }, + "tsconfig-paths": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.9.0.tgz", + "integrity": "sha512-dRcuzokWhajtZWkQsDVKbWyY+jgcLC5sqJhg2PSgf4ZkH2aHPvaOY8YWGhmjb68b5qqTfasSsDO9k7RUiEmZAw==", + "dev": true, + "requires": { + "@types/json5": "^0.0.29", + "json5": "^1.0.1", + "minimist": "^1.2.0", + "strip-bom": "^3.0.0" + } + }, + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + }, + "tsscmp": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", + "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", + "dev": true + }, + "tsutils": { + "version": "3.17.1", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.17.1.tgz", + "integrity": "sha512-kzeQ5B8H3w60nFY2g8cJIuH7JDpsALXySGtwGJ0p2LSjLgay3NdIpqq5SoOBe46bKDW2iq25irHCr8wjomUS2g==", + "dev": true, + "requires": { + "tslib": "^1.8.1" + } + }, + "tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "dev": true + }, + "tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", + "dev": true, + "requires": { + "safe-buffer": "^5.0.1" + } + }, + "type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "requires": { + "prelude-ls": "^1.2.1" + } + }, + "type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true + }, + "type-fest": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.18.1.tgz", + "integrity": "sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw==", + "dev": true + }, + "type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dev": true, + "requires": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + } + }, + "typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=", + "dev": true + }, + "typescript": { + "version": "3.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.5.tgz", + "integrity": "sha512-hSAifV3k+i6lEoCJ2k6R2Z/rp/H3+8sdmcn5NrS3/3kE7+RyZXm9aqvxWqjEXHAd8b0pShatpcdMTvEdvAJltQ==", + "dev": true + }, + "typical": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/typical/-/typical-4.0.0.tgz", + "integrity": "sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==", + "dev": true + }, + "ua-parser-js": { + "version": "0.7.22", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.22.tgz", + "integrity": "sha512-YUxzMjJ5T71w6a8WWVcMGM6YWOTX27rCoIQgLXiWaxqXSx9D7DNjiGWn1aJIRSQ5qr0xuhra77bSIh6voR/46Q==", + "dev": true + }, + "uglify-js": { + "version": "3.11.6", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.11.6.tgz", + "integrity": "sha512-oASI1FOJ7BBFkSCNDZ446EgkSuHkOZBuqRFrwXIKWCoXw8ZXQETooTQjkAcBS03Acab7ubCKsXnwuV2svy061g==", + "dev": true, + "optional": true + }, + "unbzip2-stream": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz", + "integrity": "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==", + "requires": { + "buffer": "^5.2.1", + "through": "^2.3.8" + } + }, + "universalify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-1.0.0.tgz", + "integrity": "sha512-rb6X1W158d7pRQBg5gkR8uPaSfiids68LTJQYOtEUhoJUWBdaQHsuT/EUduxXYxcrt4r5PJ4fuHW1MHT6p0qug==", + "dev": true + }, + "unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=", + "dev": true + }, + "upper-case": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/upper-case/-/upper-case-2.0.1.tgz", + "integrity": "sha512-laAsbea9SY5osxrv7S99vH9xAaJKrw5Qpdh4ENRLcaxipjKsiaBwiAsxfa8X5mObKNTQPsupSq0J/VIxsSJe3A==", + "dev": true, + "requires": { + "tslib": "^1.10.0" + } + }, + "upper-case-first": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/upper-case-first/-/upper-case-first-2.0.1.tgz", + "integrity": "sha512-105J8XqQ+9RxW3l9gHZtgve5oaiR9TIwvmZAMAIZWRHe00T21cdvewKORTlOJf/zXW6VukuTshM+HXZNWz7N5w==", + "dev": true, + "requires": { + "tslib": "^1.10.0" + } + }, + "uri-js": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.0.tgz", + "integrity": "sha512-B0yRTzYdUCCn9n+F4+Gh4yIDtMQcaJsmYBDsTSG8g/OejKBodLQ2IHfN3bM7jUsRXndopT7OIXWdYqc1fjmV6g==", + "dev": true, + "requires": { + "punycode": "^2.1.0" + } + }, + "url-parse-lax": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-3.0.0.tgz", + "integrity": "sha1-FrXK/Afb42dsGxmZF3gj1lA6yww=", + "dev": true, + "requires": { + "prepend-http": "^2.0.0" + } + }, + "url-to-options": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/url-to-options/-/url-to-options-1.0.1.tgz", + "integrity": "sha1-FQWgOiiaSMvXpDTvuu7FBV9WM6k=", + "dev": true + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" + }, + "uuid": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.1.tgz", + "integrity": "sha512-FOmRr+FmWEIG8uhZv6C2bTgEVXsHk08kE7mPlrBbEe+c3r9pjceVPgupIfNIhc4yx55H69OXANrUaSuu9eInKg==", + "dev": true + }, + "v8-compile-cache": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.2.0.tgz", + "integrity": "sha512-gTpR5XQNKFwOd4clxfnhaqvfqMpqEwr4tOtCyz4MtYZX2JYhfr1JvBFKdS+7K/9rfpZR3VLX+YWBbKoxCgS43Q==", + "dev": true + }, + "v8-to-istanbul": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-7.0.0.tgz", + "integrity": "sha512-fLL2rFuQpMtm9r8hrAV2apXX/WqHJ6+IC4/eQVdMDGBUgH/YMV4Gv3duk3kjmyg6uiQWBAA9nJwue4iJUOkHeA==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^1.6.0", + "source-map": "^0.7.3" + }, + "dependencies": { + "source-map": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", + "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==", + "dev": true + } + } + }, + "validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "requires": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "validator": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-8.2.0.tgz", + "integrity": "sha512-Yw5wW34fSv5spzTXNkokD6S6/Oq92d8q/t14TqsS3fAiA1RYnxSFSIZ+CY3n6PGGRCq5HhJTSepQvFUS2QUDxA==", + "dev": true + }, + "vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=", + "dev": true + }, + "webidl-conversions": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz", + "integrity": "sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w==", + "dev": true + }, + "whatwg-url": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-8.4.0.tgz", + "integrity": "sha512-vwTUFf6V4zhcPkWp/4CQPr1TW9Ml6SF4lVyaIMBdJw5i6qUUJ1QWM4Z6YYVkfka0OUIzVo/0aNtGVGk256IKWw==", + "dev": true, + "requires": { + "lodash.sortby": "^4.7.0", + "tr46": "^2.0.2", + "webidl-conversions": "^6.1.0" + } + }, + "which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + }, + "which-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", + "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=", + "dev": true + }, + "which-pm-runs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/which-pm-runs/-/which-pm-runs-1.0.0.tgz", + "integrity": "sha1-Zws6+8VS4LVd9rd4DKdGFfI60cs=", + "dev": true + }, + "wide-align": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz", + "integrity": "sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==", + "dev": true, + "requires": { + "string-width": "^1.0.2 || 2" + }, + "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", + "dev": true + }, + "string-width": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", + "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", + "dev": true, + "requires": { + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^4.0.0" + } + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "dev": true, + "requires": { + "ansi-regex": "^3.0.0" + } + } + } + }, + "word-wrap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", + "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", + "dev": true + }, + "wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus=", + "dev": true + }, + "wordwrapjs": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/wordwrapjs/-/wordwrapjs-4.0.0.tgz", + "integrity": "sha512-Svqw723a3R34KvsMgpjFBYCgNOSdcW3mQFK4wIfhGQhtaFVOJmdYoXgi63ne3dTlWgatVcUc7t4HtQ/+bUVIzQ==", + "dev": true, + "requires": { + "reduce-flatten": "^2.0.0", + "typical": "^5.0.0" + }, + "dependencies": { + "typical": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/typical/-/typical-5.2.0.tgz", + "integrity": "sha512-dvdQgNDNJo+8B2uBQoqdb11eUCE1JQXhvjC/CZtgvZseVd5TYMXnq0+vuUemXbd/Se29cTaUuPX3YIc2xgbvIg==", + "dev": true + } + } + }, + "workerpool": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.0.2.tgz", + "integrity": "sha512-DSNyvOpFKrNusaaUwk+ej6cBj1bmhLcBfj80elGk+ZIo5JSkq+unB1dLKEOcNfJDZgjGICfhQ0Q5TbP0PvF4+Q==", + "dev": true + }, + "wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + } + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" + }, + "write": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/write/-/write-1.0.3.tgz", + "integrity": "sha512-/lg70HAjtkUgWPVZhZcm+T4hkL8Zbtp1nFNOn3lRrxnlv50SRBv7cR7RqR+GMsd3hUXy9hWBo4CHTbFTcOYwig==", + "dev": true, + "requires": { + "mkdirp": "^0.5.1" + } + }, + "ws": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.4.0.tgz", + "integrity": "sha512-kyFwXuV/5ymf+IXhS6f0+eAFvydbaBW3zjpT6hUdAh/hbVjTIB5EHBGi0bPoCLSK2wcuz3BrEkB9LrYv1Nm4NQ==" + }, + "xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true + }, + "y18n": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz", + "integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==", + "dev": true + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, + "yaml": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.0.tgz", + "integrity": "sha512-yr2icI4glYaNG+KWONODapy2/jDdMSDnrONSjblABjD9B4Z5LgiircSt8m8sRZFNi08kG9Sm0uSHtEmP3zaEGg==", + "dev": true + }, + "yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "dev": true, + "requires": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "dependencies": { + "yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "dev": true, + "requires": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } + } + } + }, + "yargs-parser": { + "version": "20.2.4", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.4.tgz", + "integrity": "sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA==", + "dev": true + }, + "yargs-unparser": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", + "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", + "dev": true, + "requires": { + "camelcase": "^6.0.0", + "decamelize": "^4.0.0", + "flat": "^5.0.2", + "is-plain-obj": "^2.1.0" + }, + "dependencies": { + "camelcase": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.2.0.tgz", + "integrity": "sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg==", + "dev": true + }, + "decamelize": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", + "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", + "dev": true + }, + "is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "dev": true + } + } + }, + "yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha1-x+sXyT4RLLEIb6bY5R+wZnt5pfk=", + "requires": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "ylru": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ylru/-/ylru-1.2.1.tgz", + "integrity": "sha512-faQrqNMzcPCHGVC2aaOINk13K+aaBDUPjGWl0teOXywElLjyVAB6Oe2jj62jHYtwsU49jXhScYbvPENK+6zAvQ==", + "dev": true + }, + "yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true + }, + "z-schema": { + "version": "3.18.4", + "resolved": "https://registry.npmjs.org/z-schema/-/z-schema-3.18.4.tgz", + "integrity": "sha512-DUOKC/IhbkdLKKiV89gw9DUauTV8U/8yJl1sjf6MtDmzevLKOF2duNJ495S3MFVjqZarr+qNGCPbkg4mu4PpLw==", + "dev": true, + "requires": { + "commander": "^2.7.1", + "lodash.get": "^4.0.0", + "lodash.isequal": "^4.0.0", + "validator": "^8.0.0" + } + } + } +} diff --git a/remote/test/puppeteer/package.json b/remote/test/puppeteer/package.json new file mode 100644 index 0000000000..f124546778 --- /dev/null +++ b/remote/test/puppeteer/package.json @@ -0,0 +1,115 @@ +{ + "name": "puppeteer", + "version": "5.5.0", + "description": "A high-level API to control headless Chrome over the DevTools Protocol", + "main": "./cjs-entry.js", + "repository": "github:puppeteer/puppeteer", + "engines": { + "node": ">=10.18.1" + }, + "scripts": { + "test-browser": "wtr", + "test-browser-watch": "wtr --watch", + "unit": "npm run tsc-cjs && mocha --config mocha-config/puppeteer-unit-tests.js", + "unit-debug": "npm run tsc-cjs && mocha --inspect-brk --config mocha-config/puppeteer-unit-tests.js", + "unit-with-coverage": "cross-env COVERAGE=1 npm run unit", + "assert-unit-coverage": "cross-env COVERAGE=1 mocha --config mocha-config/coverage-tests.js", + "funit": "PUPPETEER_PRODUCT=firefox npm run unit", + "test": "npm run tsc && npm run lint --silent && npm run unit-with-coverage && npm run test-browser", + "prepare": "node typescript-if-required.js", + "prepublishOnly": "npm run tsc", + "dev-install": "npm run tsc && node install.js", + "install": "node install.js", + "eslint": "([ \"$CI\" = true ] && eslint --ext js --ext ts --quiet -f codeframe . || eslint --ext js --ext ts .)", + "eslint-fix": "eslint --ext js --ext ts --fix .", + "commitlint": "commitlint --from=HEAD~1", + "lint": "npm run eslint && npm run tsc && npm run doc && npm run commitlint", + "doc": "node utils/doclint/cli.js", + "clean-lib": "rm -rf lib", + "build": "npm run tsc", + "tsc": "npm run clean-lib && tsc --version && npm run tsc-cjs && npm run tsc-esm", + "tsc-cjs": "tsc -b src/tsconfig.cjs.json", + "tsc-esm": "tsc -b src/tsconfig.esm.json", + "apply-next-version": "node utils/apply_next_version.js", + "test-install": "scripts/test-install.sh", + "generate-docs": "npm run tsc && api-extractor run --local --verbose && api-documenter markdown -i temp -o new-docs", + "ensure-correct-devtools-protocol-revision": "ts-node -s scripts/ensure-correct-devtools-protocol-package", + "release": "standard-version" + }, + "files": [ + "lib/", + "install.js", + "typescript-if-required.js", + "cjs-entry.js", + "cjs-entry-core.js" + ], + "author": "The Chromium Authors", + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0", + "devtools-protocol": "0.0.818844", + "extract-zip": "^2.0.0", + "https-proxy-agent": "^4.0.0", + "node-fetch": "^2.6.1", + "pkg-dir": "^4.2.0", + "progress": "^2.0.1", + "proxy-from-env": "^1.0.0", + "rimraf": "^3.0.2", + "tar-fs": "^2.0.0", + "unbzip2-stream": "^1.3.3", + "ws": "^7.2.3" + }, + "devDependencies": { + "@commitlint/cli": "^11.0.0", + "@commitlint/config-conventional": "^11.0.0", + "@microsoft/api-documenter": "7.9.7", + "@microsoft/api-extractor": "7.10.4", + "@types/debug": "0.0.31", + "@types/mime": "^2.0.0", + "@types/mocha": "^7.0.2", + "@types/node": "^14.0.13", + "@types/proxy-from-env": "^1.0.1", + "@types/rimraf": "^2.0.2", + "@types/sinon": "^9.0.4", + "@types/tar-fs": "^1.16.2", + "@types/ws": "^7.2.4", + "@typescript-eslint/eslint-plugin": "^4.4.0", + "@typescript-eslint/parser": "^4.4.0", + "@web/test-runner": "^0.9.2", + "commonmark": "^0.28.1", + "cross-env": "^7.0.2", + "eslint": "^7.10.0", + "eslint-config-prettier": "^6.12.0", + "eslint-plugin-import": "^2.22.0", + "eslint-plugin-mocha": "^8.0.0", + "eslint-plugin-prettier": "^3.1.4", + "eslint-plugin-unicorn": "^22.0.0", + "esprima": "^4.0.0", + "expect": "^25.2.7", + "husky": "^4.3.0", + "jpeg-js": "^0.3.7", + "mime": "^2.0.3", + "minimist": "^1.2.0", + "mocha": "^8.2.0", + "ncp": "^2.0.0", + "pixelmatch": "^4.0.2", + "pngjs": "^5.0.0", + "prettier": "^2.1.2", + "sinon": "^9.0.2", + "standard-version": "^9.0.0", + "text-diff": "^1.0.1", + "ts-node": "^9.0.0", + "typescript": "3.9.5" + }, + "husky": { + "hooks": { + "commit-msg": "commitlint --env HUSKY_GIT_PARAMS" + } + }, + "standard-version": { + "skip": { + "commit": true, + "tag": true + } + } +} diff --git a/remote/test/puppeteer/prettier.config.js b/remote/test/puppeteer/prettier.config.js new file mode 100644 index 0000000000..4c5c41c687 --- /dev/null +++ b/remote/test/puppeteer/prettier.config.js @@ -0,0 +1,5 @@ +module.exports = { + semi: true, + trailingComma: 'es5', + singleQuote: true, +}; diff --git a/remote/test/puppeteer/scripts/ensure-correct-devtools-protocol-package.ts b/remote/test/puppeteer/scripts/ensure-correct-devtools-protocol-package.ts new file mode 100644 index 0000000000..2d6a4a1d1b --- /dev/null +++ b/remote/test/puppeteer/scripts/ensure-correct-devtools-protocol-package.ts @@ -0,0 +1,84 @@ +/** + * Copyright 2020 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. + */ + +/** + * This script ensures that the pinned version of devtools-protocol in + * package.json is the right version for the current revision of Chromium that + * Puppeteer ships with. + * + * The devtools-protocol package publisher runs every hour and checks if there + * are protocol changes. If there are, it will be versioned with the revision + * number of the commit that last changed the .pdl files. + * + * Chromium branches/releases are figured out at a later point in time, so it's + * not true that each Chromium revision will have an exact matching revision + * version of devtools-protocol. To ensure we're using a devtools-protocol that + * is aligned with our revision, we want to find the largest package number + * that's <= the revision that Puppeteer is using. + * + * This script uses npm's `view` function to list all versions in a range and + * find the one closest to our Chromium revision. + */ + +// eslint-disable-next-line import/extensions +import { PUPPETEER_REVISIONS } from '../src/revisions'; +import { execSync } from 'child_process'; + +import packageJson from '../package.json'; + +const currentProtocolPackageInstalledVersion = + packageJson.dependencies['devtools-protocol']; + +/** + * Ensure that the devtools-protocol version is pinned. + */ +if (/^[^0-9]/.test(currentProtocolPackageInstalledVersion)) { + console.log( + `ERROR: devtools-protocol package is not pinned to a specific version.\n` + ); + process.exit(1); +} + +// find the right revision for our Chromium revision + +const command = `npm view "devtools-protocol@<=0.0.${PUPPETEER_REVISIONS.chromium}" version | tail -1`; + +console.log( + 'Checking npm for devtools-protocol revisions:\n', + `'${command}'`, + '\n' +); + +const output = execSync(command, { + encoding: 'utf8', +}); + +const bestRevisionFromNpm = output.split(' ')[1].replace(/'|\n/g, ''); + +if (currentProtocolPackageInstalledVersion !== bestRevisionFromNpm) { + console.log(`ERROR: bad devtools-protocol revision detected: + + Current Puppeteer Chromium revision: ${PUPPETEER_REVISIONS.chromium} + Current devtools-protocol version in package.json: ${currentProtocolPackageInstalledVersion} + Expected devtools-protocol version: ${bestRevisionFromNpm}`); + + process.exit(1); +} + +console.log( + `Correct devtools-protocol version found (${bestRevisionFromNpm}).` +); +process.exit(0); diff --git a/remote/test/puppeteer/scripts/test-install.sh b/remote/test/puppeteer/scripts/test-install.sh new file mode 100755 index 0000000000..e59aa39d00 --- /dev/null +++ b/remote/test/puppeteer/scripts/test-install.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env sh +set -e + +ROOTDIR="$(pwd)" +# Pack the module into a tarball +npm pack +tarball="$(realpath puppeteer-*.tgz)" +TMPDIR="$(mktemp -d)" +cd $TMPDIR +# Check we can install from the tarball. +# This emulates installing from npm and ensures that: +# 1. we publish the right files in the `files` list from package.json +# 2. The install script works and correctly exits without errors +# 3. Requiring Puppeteer from Node works. +npm install --loglevel silent "${tarball}" +node --eval="require('puppeteer')" +ls $TMPDIR/node_modules/puppeteer/.local-chromium/ + +# Again for Firefox +TMPDIR="$(mktemp -d)" +cd $TMPDIR +PUPPETEER_PRODUCT=firefox npm install --loglevel silent "${tarball}" +node --eval="require('puppeteer')" +rm "${tarball}" +ls $TMPDIR/node_modules/puppeteer/.local-firefox/linux-*/firefox/firefox + +# Again for puppeteer-core +cd $ROOTDIR +node ./utils/prepare_puppeteer_core.js +npm pack +tarball="$(realpath puppeteer-core-*.tgz)" +TMPDIR="$(mktemp -d)" +cd $TMPDIR +# Check we can install from the tarball. +# This emulates installing from npm and ensures that: +# 1. we publish the right files in the `files` list from package.json +# 2. The install script works and correctly exits without errors +# 3. Requiring Puppeteer Core from Node works. +npm install --loglevel silent "${tarball}" +node --eval="require('puppeteer-core')" + diff --git a/remote/test/puppeteer/scripts/tsconfig.json b/remote/test/puppeteer/scripts/tsconfig.json new file mode 100644 index 0000000000..c8d842173c --- /dev/null +++ b/remote/test/puppeteer/scripts/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "module": "CommonJS" + } +} diff --git a/remote/test/puppeteer/src/.eslintrc.js b/remote/test/puppeteer/src/.eslintrc.js new file mode 100644 index 0000000000..4ebb9bb1ec --- /dev/null +++ b/remote/test/puppeteer/src/.eslintrc.js @@ -0,0 +1,19 @@ +module.exports = { + extends: '../.eslintrc.js', + /** + * ESLint rules + * + * All available rules: http://eslint.org/docs/rules/ + * + * Rules take the following form: + * "rule-name", [severity, { opts }] + * Severity: 2 == error, 1 == warning, 0 == off. + */ + rules: { + 'no-console': [ + 2, + { allow: ['warn', 'error', 'assert', 'timeStamp', 'time', 'timeEnd'] }, + ], + 'no-debugger': 0, + }, +}; diff --git a/remote/test/puppeteer/src/api-docs-entry.ts b/remote/test/puppeteer/src/api-docs-entry.ts new file mode 100644 index 0000000000..d033a3ed7c --- /dev/null +++ b/remote/test/puppeteer/src/api-docs-entry.ts @@ -0,0 +1,65 @@ +/** + * Copyright 2020 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. + */ + +/* + * This file re-exports any APIs that we want to have documentation generated + * for. It is used by API Extractor to determine what parts of the system to + * document. + * + * The legacy DocLint system and the unit test coverage system use the list of + * modules defined in coverage-utils.js. src/api-docs-entry.ts is ONLY used by + * API Extractor. + * + * Once we have migrated to API Extractor and removed DocLint we can remove the + * duplication and use this file. + */ +export * from './common/Accessibility.js'; +export * from './common/Browser.js'; +export * from './node/BrowserFetcher.js'; +export * from './node/Puppeteer.js'; +export * from './common/Connection.js'; +export * from './common/ConsoleMessage.js'; +export * from './common/Coverage.js'; +export * from './common/DeviceDescriptors.js'; +export * from './common/Dialog.js'; +export * from './common/DOMWorld.js'; +export * from './common/JSHandle.js'; +export * from './common/ExecutionContext.js'; +export * from './common/EventEmitter.js'; +export * from './common/FileChooser.js'; +export * from './common/FrameManager.js'; +export * from './common/Input.js'; +export * from './common/Page.js'; +export * from './common/Product.js'; +export * from './common/Puppeteer.js'; +export * from './common/BrowserConnector.js'; +export * from './node/Launcher.js'; +export * from './node/LaunchOptions.js'; +export * from './common/HTTPRequest.js'; +export * from './common/HTTPResponse.js'; +export * from './common/SecurityDetails.js'; +export * from './common/Target.js'; +export * from './common/Errors.js'; +export * from './common/Tracing.js'; +export * from './common/NetworkManager.js'; +export * from './common/WebWorker.js'; +export * from './common/USKeyboardLayout.js'; +export * from './common/EvalTypes.js'; +export * from './common/PDFOptions.js'; +export * from './common/TimeoutSettings.js'; +export * from './common/LifecycleWatcher.js'; +export * from './common/QueryHandler.js'; +export * from 'devtools-protocol/types/protocol'; diff --git a/remote/test/puppeteer/src/common/Accessibility.ts b/remote/test/puppeteer/src/common/Accessibility.ts new file mode 100644 index 0000000000..69684a4770 --- /dev/null +++ b/remote/test/puppeteer/src/common/Accessibility.ts @@ -0,0 +1,502 @@ +/** + * 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. + */ + +import { CDPSession } from './Connection.js'; +import { ElementHandle } from './JSHandle.js'; +import { Protocol } from 'devtools-protocol'; + +/** + * Represents a Node and the properties of it that are relevant to Accessibility. + * @public + */ +export interface SerializedAXNode { + /** + * The {@link https://www.w3.org/TR/wai-aria/#usage_intro | role} of the node. + */ + role: string; + /** + * A human readable name for the node. + */ + name?: string; + /** + * The current value of the node. + */ + value?: string | number; + /** + * An additional human readable description of the node. + */ + description?: string; + /** + * Any keyboard shortcuts associated with this node. + */ + keyshortcuts?: string; + /** + * A human readable alternative to the role. + */ + roledescription?: string; + /** + * A description of the current value. + */ + valuetext?: string; + disabled?: boolean; + expanded?: boolean; + focused?: boolean; + modal?: boolean; + multiline?: boolean; + /** + * Whether more than one child can be selected. + */ + multiselectable?: boolean; + readonly?: boolean; + required?: boolean; + selected?: boolean; + /** + * Whether the checkbox is checked, or in a + * {@link https://www.w3.org/TR/wai-aria-practices/examples/checkbox/checkbox-2/checkbox-2.html | mixed state}. + */ + checked?: boolean | 'mixed'; + /** + * Whether the node is checked or in a mixed state. + */ + pressed?: boolean | 'mixed'; + /** + * The level of a heading. + */ + level?: number; + valuemin?: number; + valuemax?: number; + autocomplete?: string; + haspopup?: string; + /** + * Whether and in what way this node's value is invalid. + */ + invalid?: string; + orientation?: string; + /** + * Children of this node, if there are any. + */ + children?: SerializedAXNode[]; +} + +/** + * @public + */ +export interface SnapshotOptions { + /** + * Prune uninteresting nodes from the tree. + * @defaultValue true + */ + interestingOnly?: boolean; + /** + * Root node to get the accessibility tree for + * @defaultValue The root node of the entire page. + */ + root?: ElementHandle; +} + +/** + * The Accessibility class provides methods for inspecting Chromium's + * accessibility tree. The accessibility tree is used by assistive technology + * such as {@link https://en.wikipedia.org/wiki/Screen_reader | screen readers} or + * {@link https://en.wikipedia.org/wiki/Switch_access | switches}. + * + * @remarks + * + * Accessibility is a very platform-specific thing. On different platforms, + * there are different screen readers that might have wildly different output. + * + * Blink - Chrome's rendering engine - has a concept of "accessibility tree", + * which is then translated into different platform-specific APIs. Accessibility + * namespace gives users access to the Blink Accessibility Tree. + * + * Most of the accessibility tree gets filtered out when converting from Blink + * AX Tree to Platform-specific AX-Tree or by assistive technologies themselves. + * By default, Puppeteer tries to approximate this filtering, exposing only + * the "interesting" nodes of the tree. + * + * @public + */ +export class Accessibility { + private _client: CDPSession; + + /** + * @internal + */ + constructor(client: CDPSession) { + this._client = client; + } + + /** + * Captures the current state of the accessibility tree. + * The returned object represents the root accessible node of the page. + * + * @remarks + * + * **NOTE** The Chromium accessibility tree contains nodes that go unused on + * most platforms and by most screen readers. Puppeteer will discard them as + * well for an easier to process tree, unless `interestingOnly` is set to + * `false`. + * + * @example + * An example of dumping the entire accessibility tree: + * ```js + * const snapshot = await page.accessibility.snapshot(); + * console.log(snapshot); + * ``` + * + * @example + * An example of logging the focused node's name: + * ```js + * const snapshot = await page.accessibility.snapshot(); + * const node = findFocusedNode(snapshot); + * console.log(node && node.name); + * + * function findFocusedNode(node) { + * if (node.focused) + * return node; + * for (const child of node.children || []) { + * const foundNode = findFocusedNode(child); + * return foundNode; + * } + * return null; + * } + * ``` + * + * @returns An AXNode object representing the snapshot. + * + */ + public async snapshot( + options: SnapshotOptions = {} + ): Promise<SerializedAXNode> { + const { interestingOnly = true, root = null } = options; + const { nodes } = await this._client.send('Accessibility.getFullAXTree'); + let backendNodeId = null; + if (root) { + const { node } = await this._client.send('DOM.describeNode', { + objectId: root._remoteObject.objectId, + }); + backendNodeId = node.backendNodeId; + } + const defaultRoot = AXNode.createTree(nodes); + let needle = defaultRoot; + if (backendNodeId) { + needle = defaultRoot.find( + (node) => node.payload.backendDOMNodeId === backendNodeId + ); + if (!needle) return null; + } + if (!interestingOnly) return this.serializeTree(needle)[0]; + + const interestingNodes = new Set<AXNode>(); + this.collectInterestingNodes(interestingNodes, defaultRoot, false); + if (!interestingNodes.has(needle)) return null; + return this.serializeTree(needle, interestingNodes)[0]; + } + + private serializeTree( + node: AXNode, + interestingNodes?: Set<AXNode> + ): SerializedAXNode[] { + const children: SerializedAXNode[] = []; + for (const child of node.children) + children.push(...this.serializeTree(child, interestingNodes)); + + if (interestingNodes && !interestingNodes.has(node)) return children; + + const serializedNode = node.serialize(); + if (children.length) serializedNode.children = children; + return [serializedNode]; + } + + private collectInterestingNodes( + collection: Set<AXNode>, + node: AXNode, + insideControl: boolean + ): void { + if (node.isInteresting(insideControl)) collection.add(node); + if (node.isLeafNode()) return; + insideControl = insideControl || node.isControl(); + for (const child of node.children) + this.collectInterestingNodes(collection, child, insideControl); + } +} + +class AXNode { + public payload: Protocol.Accessibility.AXNode; + public children: AXNode[] = []; + + private _richlyEditable = false; + private _editable = false; + private _focusable = false; + private _hidden = false; + private _name: string; + private _role: string; + private _ignored: boolean; + private _cachedHasFocusableChild?: boolean; + + constructor(payload: Protocol.Accessibility.AXNode) { + this.payload = payload; + this._name = this.payload.name ? this.payload.name.value : ''; + this._role = this.payload.role ? this.payload.role.value : 'Unknown'; + this._ignored = this.payload.ignored; + + for (const property of this.payload.properties || []) { + if (property.name === 'editable') { + this._richlyEditable = property.value.value === 'richtext'; + this._editable = true; + } + if (property.name === 'focusable') this._focusable = property.value.value; + if (property.name === 'hidden') this._hidden = property.value.value; + } + } + + private _isPlainTextField(): boolean { + if (this._richlyEditable) return false; + if (this._editable) return true; + return this._role === 'textbox' || this._role === 'searchbox'; + } + + private _isTextOnlyObject(): boolean { + const role = this._role; + return role === 'LineBreak' || role === 'text' || role === 'InlineTextBox'; + } + + private _hasFocusableChild(): boolean { + if (this._cachedHasFocusableChild === undefined) { + this._cachedHasFocusableChild = false; + for (const child of this.children) { + if (child._focusable || child._hasFocusableChild()) { + this._cachedHasFocusableChild = true; + break; + } + } + } + return this._cachedHasFocusableChild; + } + + public find(predicate: (x: AXNode) => boolean): AXNode | null { + if (predicate(this)) return this; + for (const child of this.children) { + const result = child.find(predicate); + if (result) return result; + } + return null; + } + + public isLeafNode(): boolean { + if (!this.children.length) return true; + + // These types of objects may have children that we use as internal + // implementation details, but we want to expose them as leaves to platform + // accessibility APIs because screen readers might be confused if they find + // any children. + if (this._isPlainTextField() || this._isTextOnlyObject()) return true; + + // Roles whose children are only presentational according to the ARIA and + // HTML5 Specs should be hidden from screen readers. + // (Note that whilst ARIA buttons can have only presentational children, HTML5 + // buttons are allowed to have content.) + switch (this._role) { + case 'doc-cover': + case 'graphics-symbol': + case 'img': + case 'Meter': + case 'scrollbar': + case 'slider': + case 'separator': + case 'progressbar': + return true; + default: + break; + } + + // Here and below: Android heuristics + if (this._hasFocusableChild()) return false; + if (this._focusable && this._name) return true; + if (this._role === 'heading' && this._name) return true; + return false; + } + + public isControl(): boolean { + switch (this._role) { + case 'button': + case 'checkbox': + case 'ColorWell': + case 'combobox': + case 'DisclosureTriangle': + case 'listbox': + case 'menu': + case 'menubar': + case 'menuitem': + case 'menuitemcheckbox': + case 'menuitemradio': + case 'radio': + case 'scrollbar': + case 'searchbox': + case 'slider': + case 'spinbutton': + case 'switch': + case 'tab': + case 'textbox': + case 'tree': + case 'treeitem': + return true; + default: + return false; + } + } + + public isInteresting(insideControl: boolean): boolean { + const role = this._role; + if (role === 'Ignored' || this._hidden || this._ignored) return false; + + if (this._focusable || this._richlyEditable) return true; + + // If it's not focusable but has a control role, then it's interesting. + if (this.isControl()) return true; + + // A non focusable child of a control is not interesting + if (insideControl) return false; + + return this.isLeafNode() && !!this._name; + } + + public serialize(): SerializedAXNode { + const properties = new Map<string, number | string | boolean>(); + for (const property of this.payload.properties || []) + properties.set(property.name.toLowerCase(), property.value.value); + if (this.payload.name) properties.set('name', this.payload.name.value); + if (this.payload.value) properties.set('value', this.payload.value.value); + if (this.payload.description) + properties.set('description', this.payload.description.value); + + const node: SerializedAXNode = { + role: this._role, + }; + + type UserStringProperty = + | 'name' + | 'value' + | 'description' + | 'keyshortcuts' + | 'roledescription' + | 'valuetext'; + + const userStringProperties: UserStringProperty[] = [ + 'name', + 'value', + 'description', + 'keyshortcuts', + 'roledescription', + 'valuetext', + ]; + const getUserStringPropertyValue = (key: UserStringProperty): string => + properties.get(key) as string; + + for (const userStringProperty of userStringProperties) { + if (!properties.has(userStringProperty)) continue; + + node[userStringProperty] = getUserStringPropertyValue(userStringProperty); + } + + type BooleanProperty = + | 'disabled' + | 'expanded' + | 'focused' + | 'modal' + | 'multiline' + | 'multiselectable' + | 'readonly' + | 'required' + | 'selected'; + const booleanProperties: BooleanProperty[] = [ + 'disabled', + 'expanded', + 'focused', + 'modal', + 'multiline', + 'multiselectable', + 'readonly', + 'required', + 'selected', + ]; + const getBooleanPropertyValue = (key: BooleanProperty): boolean => + properties.get(key) as boolean; + + for (const booleanProperty of booleanProperties) { + // WebArea's treat focus differently than other nodes. They report whether + // their frame has focus, not whether focus is specifically on the root + // node. + if (booleanProperty === 'focused' && this._role === 'WebArea') continue; + const value = getBooleanPropertyValue(booleanProperty); + if (!value) continue; + node[booleanProperty] = getBooleanPropertyValue(booleanProperty); + } + + type TristateProperty = 'checked' | 'pressed'; + const tristateProperties: TristateProperty[] = ['checked', 'pressed']; + for (const tristateProperty of tristateProperties) { + if (!properties.has(tristateProperty)) continue; + const value = properties.get(tristateProperty); + node[tristateProperty] = + value === 'mixed' ? 'mixed' : value === 'true' ? true : false; + } + + type NumbericalProperty = 'level' | 'valuemax' | 'valuemin'; + const numericalProperties: NumbericalProperty[] = [ + 'level', + 'valuemax', + 'valuemin', + ]; + const getNumericalPropertyValue = (key: NumbericalProperty): number => + properties.get(key) as number; + for (const numericalProperty of numericalProperties) { + if (!properties.has(numericalProperty)) continue; + node[numericalProperty] = getNumericalPropertyValue(numericalProperty); + } + + type TokenProperty = + | 'autocomplete' + | 'haspopup' + | 'invalid' + | 'orientation'; + const tokenProperties: TokenProperty[] = [ + 'autocomplete', + 'haspopup', + 'invalid', + 'orientation', + ]; + const getTokenPropertyValue = (key: TokenProperty): string => + properties.get(key) as string; + for (const tokenProperty of tokenProperties) { + const value = getTokenPropertyValue(tokenProperty); + if (!value || value === 'false') continue; + node[tokenProperty] = getTokenPropertyValue(tokenProperty); + } + return node; + } + + public static createTree(payloads: Protocol.Accessibility.AXNode[]): AXNode { + const nodeById = new Map<string, AXNode>(); + for (const payload of payloads) + nodeById.set(payload.nodeId, new AXNode(payload)); + for (const node of nodeById.values()) { + for (const childId of node.payload.childIds || []) + node.children.push(nodeById.get(childId)); + } + return nodeById.values().next().value; + } +} diff --git a/remote/test/puppeteer/src/common/AriaQueryHandler.ts b/remote/test/puppeteer/src/common/AriaQueryHandler.ts new file mode 100644 index 0000000000..9b563e9e02 --- /dev/null +++ b/remote/test/puppeteer/src/common/AriaQueryHandler.ts @@ -0,0 +1,140 @@ +/** + * Copyright 2020 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. + */ + +import { InternalQueryHandler } from './QueryHandler.js'; +import { ElementHandle, JSHandle } from './JSHandle.js'; +import { Protocol } from 'devtools-protocol'; +import { CDPSession } from './Connection.js'; +import { DOMWorld, PageBinding, WaitForSelectorOptions } from './DOMWorld.js'; + +async function queryAXTree( + client: CDPSession, + element: ElementHandle, + accessibleName?: string, + role?: string +): Promise<Protocol.Accessibility.AXNode[]> { + const { nodes } = await client.send('Accessibility.queryAXTree', { + objectId: element._remoteObject.objectId, + accessibleName, + role, + }); + const filteredNodes: Protocol.Accessibility.AXNode[] = nodes.filter( + (node: Protocol.Accessibility.AXNode) => node.role.value !== 'text' + ); + return filteredNodes; +} + +/* + * The selectors consist of an accessible name to query for and optionally + * further aria attributes on the form `[<attribute>=<value>]`. + * Currently, we only support the `name` and `role` attribute. + * The following examples showcase how the syntax works wrt. querying: + * - 'title[role="heading"]' queries for elements with name 'title' and role 'heading'. + * - '[role="img"]' queries for elements with role 'img' and any name. + * - 'label' queries for elements with name 'label' and any role. + * - '[name=""][role="button"]' queries for elements with no name and role 'button'. + */ +type ariaQueryOption = { name?: string; role?: string }; +function parseAriaSelector(selector: string): ariaQueryOption { + const normalize = (value: string): string => value.replace(/ +/g, ' ').trim(); + const knownAttributes = new Set(['name', 'role']); + const queryOptions: ariaQueryOption = {}; + const attributeRegexp = /\[\s*(?<attribute>\w+)\s*=\s*"(?<value>\\.|[^"\\]*)"\s*\]/; + const defaultName = selector.replace( + attributeRegexp, + (_, attribute: string, value: string) => { + attribute = attribute.trim(); + if (!knownAttributes.has(attribute)) + throw new Error( + 'Unkown aria attribute "${groups.attribute}" in selector' + ); + queryOptions[attribute] = normalize(value); + return ''; + } + ); + if (defaultName && !queryOptions.name) + queryOptions.name = normalize(defaultName); + return queryOptions; +} + +const queryOne = async ( + element: ElementHandle, + selector: string +): Promise<ElementHandle | null> => { + const exeCtx = element.executionContext(); + const { name, role } = parseAriaSelector(selector); + const res = await queryAXTree(exeCtx._client, element, name, role); + if (res.length < 1) { + return null; + } + return exeCtx._adoptBackendNodeId(res[0].backendDOMNodeId); +}; + +const waitFor = async ( + domWorld: DOMWorld, + selector: string, + options: WaitForSelectorOptions +): Promise<ElementHandle<Element>> => { + const binding: PageBinding = { + name: 'ariaQuerySelector', + pptrFunction: async (selector: string) => { + const document = await domWorld._document(); + const element = await queryOne(document, selector); + return element; + }, + }; + return domWorld.waitForSelectorInPage( + (_: Element, selector: string) => globalThis.ariaQuerySelector(selector), + selector, + options, + binding + ); +}; + +const queryAll = async ( + element: ElementHandle, + selector: string +): Promise<ElementHandle[]> => { + const exeCtx = element.executionContext(); + const { name, role } = parseAriaSelector(selector); + const res = await queryAXTree(exeCtx._client, element, name, role); + return Promise.all( + res.map((axNode) => exeCtx._adoptBackendNodeId(axNode.backendDOMNodeId)) + ); +}; + +const queryAllArray = async ( + element: ElementHandle, + selector: string +): Promise<JSHandle> => { + const elementHandles = await queryAll(element, selector); + const exeCtx = element.executionContext(); + const jsHandle = exeCtx.evaluateHandle( + (...elements) => elements, + ...elementHandles + ); + return jsHandle; +}; + +/** + * @internal + */ +export const ariaHandler: InternalQueryHandler = { + queryOne, + waitFor, + queryAll, + queryAllArray, +}; diff --git a/remote/test/puppeteer/src/common/Browser.ts b/remote/test/puppeteer/src/common/Browser.ts new file mode 100644 index 0000000000..e3b6a24119 --- /dev/null +++ b/remote/test/puppeteer/src/common/Browser.ts @@ -0,0 +1,734 @@ +/** + * 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. + */ + +import { assert } from './assert.js'; +import { helper } from './helper.js'; +import { Target } from './Target.js'; +import { EventEmitter } from './EventEmitter.js'; +import { Connection, ConnectionEmittedEvents } from './Connection.js'; +import { Protocol } from 'devtools-protocol'; +import { Page } from './Page.js'; +import { ChildProcess } from 'child_process'; +import { Viewport } from './PuppeteerViewport.js'; + +type BrowserCloseCallback = () => Promise<void> | void; + +/** + * @public + */ +export interface WaitForTargetOptions { + /** + * Maximum wait time in milliseconds. Pass `0` to disable the timeout. + * @defaultValue 30 seconds. + */ + timeout?: number; +} + +/** + * All the events a {@link Browser | browser instance} may emit. + * + * @public + */ +export const enum BrowserEmittedEvents { + /** + * Emitted when Puppeteer gets disconnected from the Chromium instance. This + * might happen because of one of the following: + * + * - Chromium is closed or crashed + * + * - The {@link Browser.disconnect | browser.disconnect } method was called. + */ + Disconnected = 'disconnected', + + /** + * Emitted when the url of a target changes. Contains a {@link Target} instance. + * + * @remarks + * + * Note that this includes target changes in incognito browser contexts. + */ + TargetChanged = 'targetchanged', + + /** + * Emitted when a target is created, for example when a new page is opened by + * {@link https://developer.mozilla.org/en-US/docs/Web/API/Window/open | window.open} + * or by {@link Browser.newPage | browser.newPage} + * + * Contains a {@link Target} instance. + * + * @remarks + * + * Note that this includes target creations in incognito browser contexts. + */ + TargetCreated = 'targetcreated', + /** + * Emitted when a target is destroyed, for example when a page is closed. + * Contains a {@link Target} instance. + * + * @remarks + * + * Note that this includes target destructions in incognito browser contexts. + */ + TargetDestroyed = 'targetdestroyed', +} + +/** + * A Browser is created when Puppeteer connects to a Chromium instance, either through + * {@link PuppeteerNode.launch} or {@link Puppeteer.connect}. + * + * @remarks + * + * The Browser class extends from Puppeteer's {@link EventEmitter} class and will + * emit various events which are documented in the {@link BrowserEmittedEvents} enum. + * + * @example + * + * An example of using a {@link Browser} to create a {@link Page}: + * ```js + * const puppeteer = require('puppeteer'); + * + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * await page.goto('https://example.com'); + * await browser.close(); + * })(); + * ``` + * + * @example + * + * An example of disconnecting from and reconnecting to a {@link Browser}: + * ```js + * const puppeteer = require('puppeteer'); + * + * (async () => { + * const browser = await puppeteer.launch(); + * // Store the endpoint to be able to reconnect to Chromium + * const browserWSEndpoint = browser.wsEndpoint(); + * // Disconnect puppeteer from Chromium + * browser.disconnect(); + * + * // Use the endpoint to reestablish a connection + * const browser2 = await puppeteer.connect({browserWSEndpoint}); + * // Close Chromium + * await browser2.close(); + * })(); + * ``` + * + * @public + */ +export class Browser extends EventEmitter { + /** + * @internal + */ + static async create( + connection: Connection, + contextIds: string[], + ignoreHTTPSErrors: boolean, + defaultViewport?: Viewport, + process?: ChildProcess, + closeCallback?: BrowserCloseCallback + ): Promise<Browser> { + const browser = new Browser( + connection, + contextIds, + ignoreHTTPSErrors, + defaultViewport, + process, + closeCallback + ); + await connection.send('Target.setDiscoverTargets', { discover: true }); + return browser; + } + private _ignoreHTTPSErrors: boolean; + private _defaultViewport?: Viewport; + private _process?: ChildProcess; + private _connection: Connection; + private _closeCallback: BrowserCloseCallback; + private _defaultContext: BrowserContext; + private _contexts: Map<string, BrowserContext>; + /** + * @internal + * Used in Target.ts directly so cannot be marked private. + */ + _targets: Map<string, Target>; + + /** + * @internal + */ + constructor( + connection: Connection, + contextIds: string[], + ignoreHTTPSErrors: boolean, + defaultViewport?: Viewport, + process?: ChildProcess, + closeCallback?: BrowserCloseCallback + ) { + super(); + this._ignoreHTTPSErrors = ignoreHTTPSErrors; + this._defaultViewport = defaultViewport; + this._process = process; + this._connection = connection; + this._closeCallback = closeCallback || function (): void {}; + + this._defaultContext = new BrowserContext(this._connection, this, null); + this._contexts = new Map(); + for (const contextId of contextIds) + this._contexts.set( + contextId, + new BrowserContext(this._connection, this, contextId) + ); + + this._targets = new Map(); + this._connection.on(ConnectionEmittedEvents.Disconnected, () => + this.emit(BrowserEmittedEvents.Disconnected) + ); + this._connection.on('Target.targetCreated', this._targetCreated.bind(this)); + this._connection.on( + 'Target.targetDestroyed', + this._targetDestroyed.bind(this) + ); + this._connection.on( + 'Target.targetInfoChanged', + this._targetInfoChanged.bind(this) + ); + } + + /** + * The spawned browser process. Returns `null` if the browser instance was created with + * {@link Puppeteer.connect}. + */ + process(): ChildProcess | null { + return this._process; + } + + /** + * Creates a new incognito browser context. This won't share cookies/cache with other + * browser contexts. + * + * @example + * ```js + * (async () => { + * const browser = await puppeteer.launch(); + * // Create a new incognito browser context. + * const context = await browser.createIncognitoBrowserContext(); + * // Create a new page in a pristine context. + * const page = await context.newPage(); + * // Do stuff + * await page.goto('https://example.com'); + * })(); + * ``` + */ + async createIncognitoBrowserContext(): Promise<BrowserContext> { + const { browserContextId } = await this._connection.send( + 'Target.createBrowserContext' + ); + const context = new BrowserContext( + this._connection, + this, + browserContextId + ); + this._contexts.set(browserContextId, context); + return context; + } + + /** + * Returns an array of all open browser contexts. In a newly created browser, this will + * return a single instance of {@link BrowserContext}. + */ + browserContexts(): BrowserContext[] { + return [this._defaultContext, ...Array.from(this._contexts.values())]; + } + + /** + * Returns the default browser context. The default browser context cannot be closed. + */ + defaultBrowserContext(): BrowserContext { + return this._defaultContext; + } + + /** + * @internal + * Used by BrowserContext directly so cannot be marked private. + */ + async _disposeContext(contextId?: string): Promise<void> { + await this._connection.send('Target.disposeBrowserContext', { + browserContextId: contextId || undefined, + }); + this._contexts.delete(contextId); + } + + private async _targetCreated( + event: Protocol.Target.TargetCreatedEvent + ): Promise<void> { + const targetInfo = event.targetInfo; + const { browserContextId } = targetInfo; + const context = + browserContextId && this._contexts.has(browserContextId) + ? this._contexts.get(browserContextId) + : this._defaultContext; + + const target = new Target( + targetInfo, + context, + () => this._connection.createSession(targetInfo), + this._ignoreHTTPSErrors, + this._defaultViewport + ); + assert( + !this._targets.has(event.targetInfo.targetId), + 'Target should not exist before targetCreated' + ); + this._targets.set(event.targetInfo.targetId, target); + + if (await target._initializedPromise) { + this.emit(BrowserEmittedEvents.TargetCreated, target); + context.emit(BrowserContextEmittedEvents.TargetCreated, target); + } + } + + private async _targetDestroyed(event: { targetId: string }): Promise<void> { + const target = this._targets.get(event.targetId); + target._initializedCallback(false); + this._targets.delete(event.targetId); + target._closedCallback(); + if (await target._initializedPromise) { + this.emit(BrowserEmittedEvents.TargetDestroyed, target); + target + .browserContext() + .emit(BrowserContextEmittedEvents.TargetDestroyed, target); + } + } + + private _targetInfoChanged( + event: Protocol.Target.TargetInfoChangedEvent + ): void { + const target = this._targets.get(event.targetInfo.targetId); + assert(target, 'target should exist before targetInfoChanged'); + const previousURL = target.url(); + const wasInitialized = target._isInitialized; + target._targetInfoChanged(event.targetInfo); + if (wasInitialized && previousURL !== target.url()) { + this.emit(BrowserEmittedEvents.TargetChanged, target); + target + .browserContext() + .emit(BrowserContextEmittedEvents.TargetChanged, target); + } + } + + /** + * The browser websocket endpoint which can be used as an argument to + * {@link Puppeteer.connect}. + * + * @returns The Browser websocket url. + * + * @remarks + * + * The format is `ws://${host}:${port}/devtools/browser/<id>`. + * + * You can find the `webSocketDebuggerUrl` from `http://${host}:${port}/json/version`. + * Learn more about the + * {@link https://chromedevtools.github.io/devtools-protocol | devtools protocol} and + * the {@link + * https://chromedevtools.github.io/devtools-protocol/#how-do-i-access-the-browser-target + * | browser endpoint}. + */ + wsEndpoint(): string { + return this._connection.url(); + } + + /** + * Creates a {@link Page} in the default browser context. + */ + async newPage(): Promise<Page> { + return this._defaultContext.newPage(); + } + + /** + * @internal + * Used by BrowserContext directly so cannot be marked private. + */ + async _createPageInContext(contextId?: string): Promise<Page> { + const { targetId } = await this._connection.send('Target.createTarget', { + url: 'about:blank', + browserContextId: contextId || undefined, + }); + const target = await this._targets.get(targetId); + assert( + await target._initializedPromise, + 'Failed to create target for page' + ); + const page = await target.page(); + return page; + } + + /** + * All active targets inside the Browser. In case of multiple browser contexts, returns + * an array with all the targets in all browser contexts. + */ + targets(): Target[] { + return Array.from(this._targets.values()).filter( + (target) => target._isInitialized + ); + } + + /** + * The target associated with the browser. + */ + target(): Target { + return this.targets().find((target) => target.type() === 'browser'); + } + + /** + * Searches for a target in all browser contexts. + * + * @param predicate - A function to be run for every target. + * @returns The first target found that matches the `predicate` function. + * + * @example + * + * An example of finding a target for a page opened via `window.open`: + * ```js + * await page.evaluate(() => window.open('https://www.example.com/')); + * const newWindowTarget = await browser.waitForTarget(target => target.url() === 'https://www.example.com/'); + * ``` + */ + async waitForTarget( + predicate: (x: Target) => boolean, + options: WaitForTargetOptions = {} + ): Promise<Target> { + const { timeout = 30000 } = options; + const existingTarget = this.targets().find(predicate); + if (existingTarget) return existingTarget; + let resolve; + const targetPromise = new Promise<Target>((x) => (resolve = x)); + this.on(BrowserEmittedEvents.TargetCreated, check); + this.on(BrowserEmittedEvents.TargetChanged, check); + try { + if (!timeout) return await targetPromise; + return await helper.waitWithTimeout<Target>( + targetPromise, + 'target', + timeout + ); + } finally { + this.removeListener(BrowserEmittedEvents.TargetCreated, check); + this.removeListener(BrowserEmittedEvents.TargetChanged, check); + } + + function check(target: Target): void { + if (predicate(target)) resolve(target); + } + } + + /** + * An array of all open pages inside the Browser. + * + * @remarks + * + * In case of multiple browser contexts, returns an array with all the pages in all + * browser contexts. Non-visible pages, such as `"background_page"`, will not be listed + * here. You can find them using {@link Target.page}. + */ + async pages(): Promise<Page[]> { + const contextPages = await Promise.all( + this.browserContexts().map((context) => context.pages()) + ); + // Flatten array. + return contextPages.reduce((acc, x) => acc.concat(x), []); + } + + /** + * A string representing the browser name and version. + * + * @remarks + * + * For headless Chromium, this is similar to `HeadlessChrome/61.0.3153.0`. For + * non-headless, this is similar to `Chrome/61.0.3153.0`. + * + * The format of browser.version() might change with future releases of Chromium. + */ + async version(): Promise<string> { + const version = await this._getVersion(); + return version.product; + } + + /** + * The browser's original user agent. Pages can override the browser user agent with + * {@link Page.setUserAgent}. + */ + async userAgent(): Promise<string> { + const version = await this._getVersion(); + return version.userAgent; + } + + /** + * Closes Chromium and all of its pages (if any were opened). The {@link Browser} object + * itself is considered to be disposed and cannot be used anymore. + */ + async close(): Promise<void> { + await this._closeCallback.call(null); + this.disconnect(); + } + + /** + * Disconnects Puppeteer from the browser, but leaves the Chromium process running. + * After calling `disconnect`, the {@link Browser} object is considered disposed and + * cannot be used anymore. + */ + disconnect(): void { + this._connection.dispose(); + } + + /** + * Indicates that the browser is connected. + */ + isConnected(): boolean { + return !this._connection._closed; + } + + private _getVersion(): Promise<Protocol.Browser.GetVersionResponse> { + return this._connection.send('Browser.getVersion'); + } +} + +export const enum BrowserContextEmittedEvents { + /** + * Emitted when the url of a target inside the browser context changes. + * Contains a {@link Target} instance. + */ + TargetChanged = 'targetchanged', + + /** + * Emitted when a target is created within the browser context, for example + * when a new page is opened by + * {@link https://developer.mozilla.org/en-US/docs/Web/API/Window/open | window.open} + * or by {@link BrowserContext.newPage | browserContext.newPage} + * + * Contains a {@link Target} instance. + */ + TargetCreated = 'targetcreated', + /** + * Emitted when a target is destroyed within the browser context, for example + * when a page is closed. Contains a {@link Target} instance. + */ + TargetDestroyed = 'targetdestroyed', +} + +/** + * BrowserContexts provide a way to operate multiple independent browser + * sessions. When a browser is launched, it has a single BrowserContext used by + * default. The method {@link Browser.newPage | Browser.newPage} creates a page + * in the default browser context. + * + * @remarks + * + * The Browser class extends from Puppeteer's {@link EventEmitter} class and + * will emit various events which are documented in the + * {@link BrowserContextEmittedEvents} enum. + * + * If a page opens another page, e.g. with a `window.open` call, the popup will + * belong to the parent page's browser context. + * + * Puppeteer allows creation of "incognito" browser contexts with + * {@link Browser.createIncognitoBrowserContext | Browser.createIncognitoBrowserContext} + * method. "Incognito" browser contexts don't write any browsing data to disk. + * + * @example + * ```js + * // Create a new incognito browser context + * const context = await browser.createIncognitoBrowserContext(); + * // Create a new page inside context. + * const page = await context.newPage(); + * // ... do stuff with page ... + * await page.goto('https://example.com'); + * // Dispose context once it's no longer needed. + * await context.close(); + * ``` + */ +export class BrowserContext extends EventEmitter { + private _connection: Connection; + private _browser: Browser; + private _id?: string; + + /** + * @internal + */ + constructor(connection: Connection, browser: Browser, contextId?: string) { + super(); + this._connection = connection; + this._browser = browser; + this._id = contextId; + } + + /** + * An array of all active targets inside the browser context. + */ + targets(): Target[] { + return this._browser + .targets() + .filter((target) => target.browserContext() === this); + } + + /** + * This searches for a target in this specific browser context. + * + * @example + * An example of finding a target for a page opened via `window.open`: + * ```js + * await page.evaluate(() => window.open('https://www.example.com/')); + * const newWindowTarget = await browserContext.waitForTarget(target => target.url() === 'https://www.example.com/'); + * ``` + * + * @param predicate - A function to be run for every target + * @param options - An object of options. Accepts a timout, + * which is the maximum wait time in milliseconds. + * Pass `0` to disable the timeout. Defaults to 30 seconds. + * @returns Promise which resolves to the first target found + * that matches the `predicate` function. + */ + waitForTarget( + predicate: (x: Target) => boolean, + options: { timeout?: number } = {} + ): Promise<Target> { + return this._browser.waitForTarget( + (target) => target.browserContext() === this && predicate(target), + options + ); + } + + /** + * An array of all pages inside the browser context. + * + * @returns Promise which resolves to an array of all open pages. + * Non visible pages, such as `"background_page"`, will not be listed here. + * You can find them using {@link Target.page | the target page}. + */ + async pages(): Promise<Page[]> { + const pages = await Promise.all( + this.targets() + .filter((target) => target.type() === 'page') + .map((target) => target.page()) + ); + return pages.filter((page) => !!page); + } + + /** + * Returns whether BrowserContext is incognito. + * The default browser context is the only non-incognito browser context. + * + * @remarks + * The default browser context cannot be closed. + */ + isIncognito(): boolean { + return !!this._id; + } + + /** + * @example + * ```js + * const context = browser.defaultBrowserContext(); + * await context.overridePermissions('https://html5demos.com', ['geolocation']); + * ``` + * + * @param origin - The origin to grant permissions to, e.g. "https://example.com". + * @param permissions - An array of permissions to grant. + * All permissions that are not listed here will be automatically denied. + */ + async overridePermissions( + origin: string, + permissions: string[] + ): Promise<void> { + const webPermissionToProtocol = new Map< + string, + Protocol.Browser.PermissionType + >([ + ['geolocation', 'geolocation'], + ['midi', 'midi'], + ['notifications', 'notifications'], + // TODO: push isn't a valid type? + // ['push', 'push'], + ['camera', 'videoCapture'], + ['microphone', 'audioCapture'], + ['background-sync', 'backgroundSync'], + ['ambient-light-sensor', 'sensors'], + ['accelerometer', 'sensors'], + ['gyroscope', 'sensors'], + ['magnetometer', 'sensors'], + ['accessibility-events', 'accessibilityEvents'], + ['clipboard-read', 'clipboardReadWrite'], + ['clipboard-write', 'clipboardReadWrite'], + ['payment-handler', 'paymentHandler'], + ['idle-detection', 'idleDetection'], + // chrome-specific permissions we have. + ['midi-sysex', 'midiSysex'], + ]); + const protocolPermissions = permissions.map((permission) => { + const protocolPermission = webPermissionToProtocol.get(permission); + if (!protocolPermission) + throw new Error('Unknown permission: ' + permission); + return protocolPermission; + }); + await this._connection.send('Browser.grantPermissions', { + origin, + browserContextId: this._id || undefined, + permissions: protocolPermissions, + }); + } + + /** + * Clears all permission overrides for the browser context. + * + * @example + * ```js + * const context = browser.defaultBrowserContext(); + * context.overridePermissions('https://example.com', ['clipboard-read']); + * // do stuff .. + * context.clearPermissionOverrides(); + * ``` + */ + async clearPermissionOverrides(): Promise<void> { + await this._connection.send('Browser.resetPermissions', { + browserContextId: this._id || undefined, + }); + } + + /** + * Creates a new page in the browser context. + */ + newPage(): Promise<Page> { + return this._browser._createPageInContext(this._id); + } + + /** + * The browser this browser context belongs to. + */ + browser(): Browser { + return this._browser; + } + + /** + * Closes the browser context. All the targets that belong to the browser context + * will be closed. + * + * @remarks + * Only incognito browser contexts can be closed. + */ + async close(): Promise<void> { + assert(this._id, 'Non-incognito profiles cannot be closed!'); + await this._browser._disposeContext(this._id); + } +} diff --git a/remote/test/puppeteer/src/common/BrowserConnector.ts b/remote/test/puppeteer/src/common/BrowserConnector.ts new file mode 100644 index 0000000000..5da9cbc144 --- /dev/null +++ b/remote/test/puppeteer/src/common/BrowserConnector.ts @@ -0,0 +1,120 @@ +/** + * Copyright 2020 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. + */ + +import { ConnectionTransport } from './ConnectionTransport.js'; +import { Browser } from './Browser.js'; +import { assert } from './assert.js'; +import { debugError } from '../common/helper.js'; +import { Connection } from './Connection.js'; +import { getFetch } from './fetch.js'; +import { Viewport } from './PuppeteerViewport.js'; +import { isNode } from '../environment.js'; + +/** + * Generic browser options that can be passed when launching any browser. + * @public + */ +export interface BrowserOptions { + ignoreHTTPSErrors?: boolean; + defaultViewport?: Viewport; + slowMo?: number; +} + +const getWebSocketTransportClass = async () => { + return isNode + ? (await import('../node/NodeWebSocketTransport.js')).NodeWebSocketTransport + : (await import('./BrowserWebSocketTransport.js')) + .BrowserWebSocketTransport; +}; + +/** + * Users should never call this directly; it's called when calling + * `puppeteer.connect`. + * @internal + */ +export const connectToBrowser = async ( + options: BrowserOptions & { + browserWSEndpoint?: string; + browserURL?: string; + transport?: ConnectionTransport; + } +): Promise<Browser> => { + const { + browserWSEndpoint, + browserURL, + ignoreHTTPSErrors = false, + defaultViewport = { width: 800, height: 600 }, + transport, + slowMo = 0, + } = options; + + assert( + Number(!!browserWSEndpoint) + Number(!!browserURL) + Number(!!transport) === + 1, + 'Exactly one of browserWSEndpoint, browserURL or transport must be passed to puppeteer.connect' + ); + + let connection = null; + if (transport) { + connection = new Connection('', transport, slowMo); + } else if (browserWSEndpoint) { + const WebSocketClass = await getWebSocketTransportClass(); + const connectionTransport: ConnectionTransport = await WebSocketClass.create( + browserWSEndpoint + ); + connection = new Connection(browserWSEndpoint, connectionTransport, slowMo); + } else if (browserURL) { + const connectionURL = await getWSEndpoint(browserURL); + const WebSocketClass = await getWebSocketTransportClass(); + const connectionTransport: ConnectionTransport = await WebSocketClass.create( + connectionURL + ); + connection = new Connection(connectionURL, connectionTransport, slowMo); + } + + const { browserContextIds } = await connection.send( + 'Target.getBrowserContexts' + ); + return Browser.create( + connection, + browserContextIds, + ignoreHTTPSErrors, + defaultViewport, + null, + () => connection.send('Browser.close').catch(debugError) + ); +}; + +async function getWSEndpoint(browserURL: string): Promise<string> { + const endpointURL = new URL('/json/version', browserURL); + + const fetch = await getFetch(); + try { + const result = await fetch(endpointURL.toString(), { + method: 'GET', + }); + if (!result.ok) { + throw new Error(`HTTP ${result.statusText}`); + } + const data = await result.json(); + return data.webSocketDebuggerUrl; + } catch (error) { + error.message = + `Failed to fetch browser webSocket URL from ${endpointURL}: ` + + error.message; + throw error; + } +} diff --git a/remote/test/puppeteer/src/common/BrowserWebSocketTransport.ts b/remote/test/puppeteer/src/common/BrowserWebSocketTransport.ts new file mode 100644 index 0000000000..9d0e5c4592 --- /dev/null +++ b/remote/test/puppeteer/src/common/BrowserWebSocketTransport.ts @@ -0,0 +1,55 @@ +/** + * Copyright 2020 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. + */ +import { ConnectionTransport } from './ConnectionTransport.js'; + +export class BrowserWebSocketTransport implements ConnectionTransport { + static create(url: string): Promise<BrowserWebSocketTransport> { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url); + + ws.addEventListener('open', () => + resolve(new BrowserWebSocketTransport(ws)) + ); + ws.addEventListener('error', reject); + }); + } + + private _ws: WebSocket; + onmessage?: (message: string) => void; + onclose?: () => void; + + constructor(ws: WebSocket) { + this._ws = ws; + this._ws.addEventListener('message', (event) => { + if (this.onmessage) this.onmessage.call(null, event.data); + }); + this._ws.addEventListener('close', () => { + if (this.onclose) this.onclose.call(null); + }); + // Silently ignore all errors - we don't know what to do with them. + this._ws.addEventListener('error', () => {}); + this.onmessage = null; + this.onclose = null; + } + + send(message: string): void { + this._ws.send(message); + } + + close(): void { + this._ws.close(); + } +} diff --git a/remote/test/puppeteer/src/common/Connection.ts b/remote/test/puppeteer/src/common/Connection.ts new file mode 100644 index 0000000000..d1383157fa --- /dev/null +++ b/remote/test/puppeteer/src/common/Connection.ts @@ -0,0 +1,347 @@ +/** + * 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. + */ +import { assert } from './assert.js'; +import { debug } from './Debug.js'; +const debugProtocolSend = debug('puppeteer:protocol:SEND ►'); +const debugProtocolReceive = debug('puppeteer:protocol:RECV ◀'); + +import { Protocol } from 'devtools-protocol'; +import { ProtocolMapping } from 'devtools-protocol/types/protocol-mapping.js'; +import { ConnectionTransport } from './ConnectionTransport.js'; +import { EventEmitter } from './EventEmitter.js'; + +interface ConnectionCallback { + resolve: Function; + reject: Function; + error: Error; + method: string; +} + +/** + * Internal events that the Connection class emits. + * + * @internal + */ +export const ConnectionEmittedEvents = { + Disconnected: Symbol('Connection.Disconnected'), +} as const; + +/** + * @internal + */ +export class Connection extends EventEmitter { + _url: string; + _transport: ConnectionTransport; + _delay: number; + _lastId = 0; + _sessions: Map<string, CDPSession> = new Map(); + _closed = false; + + _callbacks: Map<number, ConnectionCallback> = new Map(); + + constructor(url: string, transport: ConnectionTransport, delay = 0) { + super(); + this._url = url; + this._delay = delay; + + this._transport = transport; + this._transport.onmessage = this._onMessage.bind(this); + this._transport.onclose = this._onClose.bind(this); + } + + static fromSession(session: CDPSession): Connection { + return session._connection; + } + + /** + * @param {string} sessionId + * @returns {?CDPSession} + */ + session(sessionId: string): CDPSession | null { + return this._sessions.get(sessionId) || null; + } + + url(): string { + return this._url; + } + + send<T extends keyof ProtocolMapping.Commands>( + method: T, + ...paramArgs: ProtocolMapping.Commands[T]['paramsType'] + ): Promise<ProtocolMapping.Commands[T]['returnType']> { + // There is only ever 1 param arg passed, but the Protocol defines it as an + // array of 0 or 1 items See this comment: + // https://github.com/ChromeDevTools/devtools-protocol/pull/113#issuecomment-412603285 + // which explains why the protocol defines the params this way for better + // type-inference. + // So now we check if there are any params or not and deal with them accordingly. + const params = paramArgs.length ? paramArgs[0] : undefined; + const id = this._rawSend({ method, params }); + return new Promise((resolve, reject) => { + this._callbacks.set(id, { resolve, reject, error: new Error(), method }); + }); + } + + _rawSend(message: Record<string, unknown>): number { + const id = ++this._lastId; + const stringifiedMessage = JSON.stringify( + Object.assign({}, message, { id }) + ); + debugProtocolSend(stringifiedMessage); + this._transport.send(stringifiedMessage); + return id; + } + + async _onMessage(message: string): Promise<void> { + if (this._delay) await new Promise((f) => setTimeout(f, this._delay)); + debugProtocolReceive(message); + const object = JSON.parse(message); + if (object.method === 'Target.attachedToTarget') { + const sessionId = object.params.sessionId; + const session = new CDPSession( + this, + object.params.targetInfo.type, + sessionId + ); + this._sessions.set(sessionId, session); + } else if (object.method === 'Target.detachedFromTarget') { + const session = this._sessions.get(object.params.sessionId); + if (session) { + session._onClosed(); + this._sessions.delete(object.params.sessionId); + } + } + if (object.sessionId) { + const session = this._sessions.get(object.sessionId); + if (session) session._onMessage(object); + } else 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(): void { + 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(); + for (const session of this._sessions.values()) session._onClosed(); + this._sessions.clear(); + this.emit(ConnectionEmittedEvents.Disconnected); + } + + dispose(): void { + this._onClose(); + this._transport.close(); + } + + /** + * @param {Protocol.Target.TargetInfo} targetInfo + * @returns {!Promise<!CDPSession>} + */ + async createSession( + targetInfo: Protocol.Target.TargetInfo + ): Promise<CDPSession> { + const { sessionId } = await this.send('Target.attachToTarget', { + targetId: targetInfo.targetId, + flatten: true, + }); + return this._sessions.get(sessionId); + } +} + +interface CDPSessionOnMessageObject { + id?: number; + method: string; + params: Record<string, unknown>; + error: { message: string; data: any }; + result?: any; +} + +/** + * Internal events that the CDPSession class emits. + * + * @internal + */ +export const CDPSessionEmittedEvents = { + Disconnected: Symbol('CDPSession.Disconnected'), +} as const; + +/** + * The `CDPSession` instances are used to talk raw Chrome Devtools Protocol. + * + * @remarks + * + * Protocol methods can be called with {@link CDPSession.send} method and protocol + * events can be subscribed to with `CDPSession.on` method. + * + * Useful links: {@link https://chromedevtools.github.io/devtools-protocol/ | DevTools Protocol Viewer} + * and {@link https://github.com/aslushnikov/getting-started-with-cdp/blob/master/README.md | Getting Started with DevTools Protocol}. + * + * @example + * ```js + * const client = await page.target().createCDPSession(); + * await client.send('Animation.enable'); + * client.on('Animation.animationCreated', () => console.log('Animation created!')); + * const response = await client.send('Animation.getPlaybackRate'); + * console.log('playback rate is ' + response.playbackRate); + * await client.send('Animation.setPlaybackRate', { + * playbackRate: response.playbackRate / 2 + * }); + * ``` + * + * @public + */ +export class CDPSession extends EventEmitter { + /** + * @internal + */ + _connection: Connection; + private _sessionId: string; + private _targetType: string; + private _callbacks: Map<number, ConnectionCallback> = new Map(); + + /** + * @internal + */ + constructor(connection: Connection, targetType: string, sessionId: string) { + super(); + this._connection = connection; + this._targetType = targetType; + this._sessionId = sessionId; + } + + send<T extends keyof ProtocolMapping.Commands>( + method: T, + ...paramArgs: ProtocolMapping.Commands[T]['paramsType'] + ): Promise<ProtocolMapping.Commands[T]['returnType']> { + if (!this._connection) + return Promise.reject( + new Error( + `Protocol error (${method}): Session closed. Most likely the ${this._targetType} has been closed.` + ) + ); + + // See the comment in Connection#send explaining why we do this. + const params = paramArgs.length ? paramArgs[0] : undefined; + + const id = this._connection._rawSend({ + sessionId: this._sessionId, + method, + /* TODO(jacktfranklin@): once this Firefox bug is solved + * we no longer need the `|| {}` check + * https://bugzilla.mozilla.org/show_bug.cgi?id=1631570 + */ + params: params || {}, + }); + + return new Promise((resolve, reject) => { + this._callbacks.set(id, { resolve, reject, error: new Error(), method }); + }); + } + + /** + * @internal + */ + _onMessage(object: CDPSessionOnMessageObject): void { + if (object.id && this._callbacks.has(object.id)) { + const callback = this._callbacks.get(object.id); + this._callbacks.delete(object.id); + if (object.error) + callback.reject( + createProtocolError(callback.error, callback.method, object) + ); + else callback.resolve(object.result); + } else { + assert(!object.id); + this.emit(object.method, object.params); + } + } + + /** + * Detaches the cdpSession from the target. Once detached, the cdpSession object + * won't emit any events and can't be used to send messages. + */ + async detach(): Promise<void> { + if (!this._connection) + throw new Error( + `Session already detached. Most likely the ${this._targetType} has been closed.` + ); + await this._connection.send('Target.detachFromTarget', { + sessionId: this._sessionId, + }); + } + + /** + * @internal + */ + _onClosed(): void { + for (const callback of this._callbacks.values()) + callback.reject( + rewriteError( + callback.error, + `Protocol error (${callback.method}): Target closed.` + ) + ); + this._callbacks.clear(); + this._connection = null; + this.emit(CDPSessionEmittedEvents.Disconnected); + } +} + +/** + * @param {!Error} error + * @param {string} method + * @param {{error: {message: string, data: any}}} object + * @returns {!Error} + */ +function createProtocolError( + error: Error, + method: string, + object: { error: { message: string; data: any } } +): Error { + 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 + * @returns {!Error} + */ +function rewriteError(error: Error, message: string): Error { + error.message = message; + return error; +} diff --git a/remote/test/puppeteer/src/common/ConnectionTransport.ts b/remote/test/puppeteer/src/common/ConnectionTransport.ts new file mode 100644 index 0000000000..fc3deddb40 --- /dev/null +++ b/remote/test/puppeteer/src/common/ConnectionTransport.ts @@ -0,0 +1,22 @@ +/** + * Copyright 2020 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. + */ + +export interface ConnectionTransport { + send(string); + close(); + onmessage?: (message: string) => void; + onclose?: () => void; +} diff --git a/remote/test/puppeteer/src/common/ConsoleMessage.ts b/remote/test/puppeteer/src/common/ConsoleMessage.ts new file mode 100644 index 0000000000..3387dc59d0 --- /dev/null +++ b/remote/test/puppeteer/src/common/ConsoleMessage.ts @@ -0,0 +1,122 @@ +/** + * Copyright 2020 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. + */ + +import { JSHandle } from './JSHandle.js'; + +/** + * @public + */ +export interface ConsoleMessageLocation { + /** + * URL of the resource if known or `undefined` otherwise. + */ + url?: string; + + /** + * 0-based line number in the resource if known or `undefined` otherwise. + */ + lineNumber?: number; + + /** + * 0-based column number in the resource if known or `undefined` otherwise. + */ + columnNumber?: number; +} + +/** + * The supported types for console messages. + */ +export type ConsoleMessageType = + | 'log' + | 'debug' + | 'info' + | 'error' + | 'warning' + | 'dir' + | 'dirxml' + | 'table' + | 'trace' + | 'clear' + | 'startGroup' + | 'startGroupCollapsed' + | 'endGroup' + | 'assert' + | 'profile' + | 'profileEnd' + | 'count' + | 'timeEnd' + | 'verbose'; + +/** + * ConsoleMessage objects are dispatched by page via the 'console' event. + * @public + */ +export class ConsoleMessage { + private _type: ConsoleMessageType; + private _text: string; + private _args: JSHandle[]; + private _stackTraceLocations: ConsoleMessageLocation[]; + + /** + * @public + */ + constructor( + type: ConsoleMessageType, + text: string, + args: JSHandle[], + stackTraceLocations: ConsoleMessageLocation[] + ) { + this._type = type; + this._text = text; + this._args = args; + this._stackTraceLocations = stackTraceLocations; + } + + /** + * @returns The type of the console message. + */ + type(): ConsoleMessageType { + return this._type; + } + + /** + * @returns The text of the console message. + */ + text(): string { + return this._text; + } + + /** + * @returns An array of arguments passed to the console. + */ + args(): JSHandle[] { + return this._args; + } + + /** + * @returns The location of the console message. + */ + location(): ConsoleMessageLocation { + return this._stackTraceLocations.length ? this._stackTraceLocations[0] : {}; + } + + /** + * @returns The array of locations on the stack of the console message. + */ + stackTrace(): ConsoleMessageLocation[] { + return this._stackTraceLocations; + } +} diff --git a/remote/test/puppeteer/src/common/Coverage.ts b/remote/test/puppeteer/src/common/Coverage.ts new file mode 100644 index 0000000000..63060e656a --- /dev/null +++ b/remote/test/puppeteer/src/common/Coverage.ts @@ -0,0 +1,425 @@ +/** + * 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. + */ + +import { assert } from './assert.js'; +import { helper, debugError, PuppeteerEventListener } from './helper.js'; +import { Protocol } from 'devtools-protocol'; +import { CDPSession } from './Connection.js'; + +import { EVALUATION_SCRIPT_URL } from './ExecutionContext.js'; + +/** + * The CoverageEntry class represents one entry of the coverage report. + * @public + */ +export interface CoverageEntry { + /** + * The URL of the style sheet or script. + */ + url: string; + /** + * The content of the style sheet or script. + */ + text: string; + /** + * The covered range as start and end positions. + */ + ranges: Array<{ start: number; end: number }>; +} + +/** + * Set of configurable options for JS coverage. + * @public + */ +export interface JSCoverageOptions { + /** + * Whether to reset coverage on every navigation. + */ + resetOnNavigation?: boolean; + /** + * Whether anonymous scripts generated by the page should be reported. + */ + reportAnonymousScripts?: boolean; +} + +/** + * Set of configurable options for CSS coverage. + * @public + */ +export interface CSSCoverageOptions { + /** + * Whether to reset coverage on every navigation. + */ + resetOnNavigation?: boolean; +} + +/** + * The Coverage class provides methods to gathers information about parts of + * JavaScript and CSS that were used by the page. + * + * @remarks + * To output coverage in a form consumable by {@link https://github.com/istanbuljs | Istanbul}, + * see {@link https://github.com/istanbuljs/puppeteer-to-istanbul | puppeteer-to-istanbul}. + * + * @example + * An example of using JavaScript and CSS coverage to get percentage of initially + * executed code: + * ```js + * // Enable both JavaScript and CSS coverage + * await Promise.all([ + * page.coverage.startJSCoverage(), + * page.coverage.startCSSCoverage() + * ]); + * // Navigate to page + * await page.goto('https://example.com'); + * // Disable both JavaScript and CSS coverage + * const [jsCoverage, cssCoverage] = await Promise.all([ + * page.coverage.stopJSCoverage(), + * page.coverage.stopCSSCoverage(), + * ]); + * let totalBytes = 0; + * let usedBytes = 0; + * const coverage = [...jsCoverage, ...cssCoverage]; + * for (const entry of coverage) { + * totalBytes += entry.text.length; + * for (const range of entry.ranges) + * usedBytes += range.end - range.start - 1; + * } + * console.log(`Bytes used: ${usedBytes / totalBytes * 100}%`); + * ``` + * @public + */ +export class Coverage { + /** + * @internal + */ + _jsCoverage: JSCoverage; + /** + * @internal + */ + _cssCoverage: CSSCoverage; + + constructor(client: CDPSession) { + this._jsCoverage = new JSCoverage(client); + this._cssCoverage = new CSSCoverage(client); + } + + /** + * @param options - defaults to + * `{ resetOnNavigation : true, reportAnonymousScripts : false }` + * @returns Promise that resolves when coverage is started. + * + * @remarks + * Anonymous scripts are ones that don't have an associated url. These are + * scripts that are dynamically created on the page using `eval` or + * `new Function`. If `reportAnonymousScripts` is set to `true`, anonymous + * scripts will have `__puppeteer_evaluation_script__` as their URL. + */ + async startJSCoverage(options: JSCoverageOptions = {}): Promise<void> { + return await this._jsCoverage.start(options); + } + + /** + * @returns Promise that resolves to the array of coverage reports for + * all scripts. + * + * @remarks + * JavaScript Coverage doesn't include anonymous scripts by default. + * However, scripts with sourceURLs are reported. + */ + async stopJSCoverage(): Promise<CoverageEntry[]> { + return await this._jsCoverage.stop(); + } + + /** + * @param options - defaults to `{ resetOnNavigation : true }` + * @returns Promise that resolves when coverage is started. + */ + async startCSSCoverage(options: CSSCoverageOptions = {}): Promise<void> { + return await this._cssCoverage.start(options); + } + + /** + * @returns Promise that resolves to the array of coverage reports + * for all stylesheets. + * @remarks + * CSS Coverage doesn't include dynamically injected style tags + * without sourceURLs. + */ + async stopCSSCoverage(): Promise<CoverageEntry[]> { + return await this._cssCoverage.stop(); + } +} + +class JSCoverage { + _client: CDPSession; + _enabled = false; + _scriptURLs = new Map<string, string>(); + _scriptSources = new Map<string, string>(); + _eventListeners: PuppeteerEventListener[] = []; + _resetOnNavigation = false; + _reportAnonymousScripts = false; + + constructor(client: CDPSession) { + this._client = client; + } + + async start( + options: { + resetOnNavigation?: boolean; + reportAnonymousScripts?: boolean; + } = {} + ): Promise<void> { + assert(!this._enabled, 'JSCoverage is already enabled'); + const { + resetOnNavigation = true, + reportAnonymousScripts = false, + } = options; + this._resetOnNavigation = resetOnNavigation; + this._reportAnonymousScripts = reportAnonymousScripts; + this._enabled = true; + this._scriptURLs.clear(); + this._scriptSources.clear(); + this._eventListeners = [ + helper.addEventListener( + this._client, + 'Debugger.scriptParsed', + this._onScriptParsed.bind(this) + ), + helper.addEventListener( + this._client, + 'Runtime.executionContextsCleared', + this._onExecutionContextsCleared.bind(this) + ), + ]; + await Promise.all([ + this._client.send('Profiler.enable'), + this._client.send('Profiler.startPreciseCoverage', { + callCount: false, + detailed: true, + }), + this._client.send('Debugger.enable'), + this._client.send('Debugger.setSkipAllPauses', { skip: true }), + ]); + } + + _onExecutionContextsCleared(): void { + if (!this._resetOnNavigation) return; + this._scriptURLs.clear(); + this._scriptSources.clear(); + } + + async _onScriptParsed( + event: Protocol.Debugger.ScriptParsedEvent + ): Promise<void> { + // Ignore puppeteer-injected scripts + if (event.url === EVALUATION_SCRIPT_URL) return; + // Ignore other anonymous scripts unless the reportAnonymousScripts option is true. + if (!event.url && !this._reportAnonymousScripts) return; + try { + const response = await this._client.send('Debugger.getScriptSource', { + scriptId: event.scriptId, + }); + this._scriptURLs.set(event.scriptId, event.url); + this._scriptSources.set(event.scriptId, response.scriptSource); + } catch (error) { + // This might happen if the page has already navigated away. + debugError(error); + } + } + + async stop(): Promise<CoverageEntry[]> { + assert(this._enabled, 'JSCoverage is not enabled'); + this._enabled = false; + + const result = await Promise.all< + Protocol.Profiler.TakePreciseCoverageResponse, + void, + void, + void + >([ + this._client.send('Profiler.takePreciseCoverage'), + this._client.send('Profiler.stopPreciseCoverage'), + this._client.send('Profiler.disable'), + this._client.send('Debugger.disable'), + ]); + + helper.removeEventListeners(this._eventListeners); + + const coverage = []; + const profileResponse = result[0]; + + for (const entry of profileResponse.result) { + let url = this._scriptURLs.get(entry.scriptId); + if (!url && this._reportAnonymousScripts) + url = 'debugger://VM' + entry.scriptId; + const text = this._scriptSources.get(entry.scriptId); + if (text === undefined || url === undefined) continue; + const flattenRanges = []; + for (const func of entry.functions) flattenRanges.push(...func.ranges); + const ranges = convertToDisjointRanges(flattenRanges); + coverage.push({ url, ranges, text }); + } + return coverage; + } +} + +class CSSCoverage { + _client: CDPSession; + _enabled = false; + _stylesheetURLs = new Map<string, string>(); + _stylesheetSources = new Map<string, string>(); + _eventListeners: PuppeteerEventListener[] = []; + _resetOnNavigation = false; + _reportAnonymousScripts = false; + + constructor(client: CDPSession) { + this._client = client; + } + + async start(options: { resetOnNavigation?: boolean } = {}): Promise<void> { + assert(!this._enabled, 'CSSCoverage is already enabled'); + const { resetOnNavigation = true } = options; + this._resetOnNavigation = resetOnNavigation; + this._enabled = true; + this._stylesheetURLs.clear(); + this._stylesheetSources.clear(); + this._eventListeners = [ + helper.addEventListener( + this._client, + 'CSS.styleSheetAdded', + this._onStyleSheet.bind(this) + ), + helper.addEventListener( + this._client, + 'Runtime.executionContextsCleared', + this._onExecutionContextsCleared.bind(this) + ), + ]; + await Promise.all([ + this._client.send('DOM.enable'), + this._client.send('CSS.enable'), + this._client.send('CSS.startRuleUsageTracking'), + ]); + } + + _onExecutionContextsCleared(): void { + if (!this._resetOnNavigation) return; + this._stylesheetURLs.clear(); + this._stylesheetSources.clear(); + } + + async _onStyleSheet(event: Protocol.CSS.StyleSheetAddedEvent): Promise<void> { + const header = event.header; + // Ignore anonymous scripts + if (!header.sourceURL) return; + try { + const response = await this._client.send('CSS.getStyleSheetText', { + styleSheetId: header.styleSheetId, + }); + this._stylesheetURLs.set(header.styleSheetId, header.sourceURL); + this._stylesheetSources.set(header.styleSheetId, response.text); + } catch (error) { + // This might happen if the page has already navigated away. + debugError(error); + } + } + + async stop(): Promise<CoverageEntry[]> { + assert(this._enabled, 'CSSCoverage is not enabled'); + this._enabled = false; + const ruleTrackingResponse = await this._client.send( + 'CSS.stopRuleUsageTracking' + ); + await Promise.all([ + this._client.send('CSS.disable'), + this._client.send('DOM.disable'), + ]); + helper.removeEventListeners(this._eventListeners); + + // aggregate by styleSheetId + const styleSheetIdToCoverage = new Map(); + for (const entry of ruleTrackingResponse.ruleUsage) { + let ranges = styleSheetIdToCoverage.get(entry.styleSheetId); + if (!ranges) { + ranges = []; + styleSheetIdToCoverage.set(entry.styleSheetId, ranges); + } + ranges.push({ + startOffset: entry.startOffset, + endOffset: entry.endOffset, + count: entry.used ? 1 : 0, + }); + } + + const coverage = []; + for (const styleSheetId of this._stylesheetURLs.keys()) { + const url = this._stylesheetURLs.get(styleSheetId); + const text = this._stylesheetSources.get(styleSheetId); + const ranges = convertToDisjointRanges( + styleSheetIdToCoverage.get(styleSheetId) || [] + ); + coverage.push({ url, ranges, text }); + } + + return coverage; + } +} + +function convertToDisjointRanges( + nestedRanges: Array<{ startOffset: number; endOffset: number; count: number }> +): Array<{ start: number; end: number }> { + const points = []; + for (const range of nestedRanges) { + points.push({ offset: range.startOffset, type: 0, range }); + points.push({ offset: range.endOffset, type: 1, range }); + } + // Sort points to form a valid parenthesis sequence. + points.sort((a, b) => { + // Sort with increasing offsets. + if (a.offset !== b.offset) return a.offset - b.offset; + // All "end" points should go before "start" points. + if (a.type !== b.type) return b.type - a.type; + const aLength = a.range.endOffset - a.range.startOffset; + const bLength = b.range.endOffset - b.range.startOffset; + // For two "start" points, the one with longer range goes first. + if (a.type === 0) return bLength - aLength; + // For two "end" points, the one with shorter range goes first. + return aLength - bLength; + }); + + const hitCountStack = []; + const results = []; + let lastOffset = 0; + // Run scanning line to intersect all ranges. + for (const point of points) { + if ( + hitCountStack.length && + lastOffset < point.offset && + hitCountStack[hitCountStack.length - 1] > 0 + ) { + const lastResult = results.length ? results[results.length - 1] : null; + if (lastResult && lastResult.end === lastOffset) + lastResult.end = point.offset; + else results.push({ start: lastOffset, end: point.offset }); + } + lastOffset = point.offset; + if (point.type === 0) hitCountStack.push(point.range.count); + else hitCountStack.pop(); + } + // Filter out empty ranges. + return results.filter((range) => range.end - range.start > 1); +} diff --git a/remote/test/puppeteer/src/common/DOMWorld.ts b/remote/test/puppeteer/src/common/DOMWorld.ts new file mode 100644 index 0000000000..ae74ab9caf --- /dev/null +++ b/remote/test/puppeteer/src/common/DOMWorld.ts @@ -0,0 +1,940 @@ +/** + * Copyright 2019 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. + */ + +import { assert } from './assert.js'; +import { helper, debugError } from './helper.js'; +import { + LifecycleWatcher, + PuppeteerLifeCycleEvent, +} from './LifecycleWatcher.js'; +import { TimeoutError } from './Errors.js'; +import { JSHandle, ElementHandle } from './JSHandle.js'; +import { ExecutionContext } from './ExecutionContext.js'; +import { TimeoutSettings } from './TimeoutSettings.js'; +import { MouseButton } from './Input.js'; +import { FrameManager, Frame } from './FrameManager.js'; +import { getQueryHandlerAndSelector } from './QueryHandler.js'; +import { + SerializableOrJSHandle, + EvaluateHandleFn, + WrapElementHandle, + EvaluateFn, + EvaluateFnReturnType, + UnwrapPromiseLike, +} from './EvalTypes.js'; +import { isNode } from '../environment.js'; +import { Protocol } from 'devtools-protocol'; + +// predicateQueryHandler and checkWaitForOptions are declared here so that +// TypeScript knows about them when used in the predicate function below. +declare const predicateQueryHandler: ( + element: Element | Document, + selector: string +) => Promise<Element | Element[] | NodeListOf<Element>>; +declare const checkWaitForOptions: ( + node: Node, + waitForVisible: boolean, + waitForHidden: boolean +) => Element | null | boolean; + +/** + * @public + */ +export interface WaitForSelectorOptions { + visible?: boolean; + hidden?: boolean; + timeout?: number; +} + +/** + * @internal + */ +export interface PageBinding { + name: string; + pptrFunction: Function; +} + +/** + * @internal + */ +export class DOMWorld { + private _frameManager: FrameManager; + private _frame: Frame; + private _timeoutSettings: TimeoutSettings; + private _documentPromise?: Promise<ElementHandle> = null; + private _contextPromise?: Promise<ExecutionContext> = null; + + private _contextResolveCallback?: (x?: ExecutionContext) => void = null; + + private _detached = false; + /** + * @internal + */ + _waitTasks = new Set<WaitTask>(); + + /** + * @internal + * Contains mapping from functions that should be bound to Puppeteer functions. + */ + _boundFunctions = new Map<string, Function>(); + // Set of bindings that have been registered in the current context. + private _ctxBindings = new Set<string>(); + private static bindingIdentifier = (name: string, contextId: number) => + `${name}_${contextId}`; + + constructor( + frameManager: FrameManager, + frame: Frame, + timeoutSettings: TimeoutSettings + ) { + this._frameManager = frameManager; + this._frame = frame; + this._timeoutSettings = timeoutSettings; + this._setContext(null); + frameManager._client.on('Runtime.bindingCalled', (event) => + this._onBindingCalled(event) + ); + } + + frame(): Frame { + return this._frame; + } + + async _setContext(context?: ExecutionContext): Promise<void> { + if (context) { + this._contextResolveCallback.call(null, context); + this._contextResolveCallback = null; + for (const waitTask of this._waitTasks) waitTask.rerun(); + } else { + this._documentPromise = null; + this._contextPromise = new Promise((fulfill) => { + this._contextResolveCallback = fulfill; + }); + } + } + + _hasContext(): boolean { + return !this._contextResolveCallback; + } + + _detach(): void { + this._detached = true; + for (const waitTask of this._waitTasks) + waitTask.terminate( + new Error('waitForFunction failed: frame got detached.') + ); + } + + executionContext(): Promise<ExecutionContext> { + if (this._detached) + throw new Error( + `Execution context is not available in detached frame "${this._frame.url()}" (are you trying to evaluate?)` + ); + return this._contextPromise; + } + + async evaluateHandle<HandlerType extends JSHandle = JSHandle>( + pageFunction: EvaluateHandleFn, + ...args: SerializableOrJSHandle[] + ): Promise<HandlerType> { + const context = await this.executionContext(); + return context.evaluateHandle(pageFunction, ...args); + } + + async evaluate<T extends EvaluateFn>( + pageFunction: T, + ...args: SerializableOrJSHandle[] + ): Promise<UnwrapPromiseLike<EvaluateFnReturnType<T>>> { + const context = await this.executionContext(); + return context.evaluate<UnwrapPromiseLike<EvaluateFnReturnType<T>>>( + pageFunction, + ...args + ); + } + + async $(selector: string): Promise<ElementHandle | null> { + const document = await this._document(); + const value = await document.$(selector); + return value; + } + + async _document(): Promise<ElementHandle> { + if (this._documentPromise) return this._documentPromise; + this._documentPromise = this.executionContext().then(async (context) => { + const document = await context.evaluateHandle('document'); + return document.asElement(); + }); + return this._documentPromise; + } + + async $x(expression: string): Promise<ElementHandle[]> { + const document = await this._document(); + const value = await document.$x(expression); + return value; + } + + async $eval<ReturnType>( + selector: string, + pageFunction: ( + element: Element, + ...args: unknown[] + ) => ReturnType | Promise<ReturnType>, + ...args: SerializableOrJSHandle[] + ): Promise<WrapElementHandle<ReturnType>> { + const document = await this._document(); + return document.$eval<ReturnType>(selector, pageFunction, ...args); + } + + async $$eval<ReturnType>( + selector: string, + pageFunction: ( + elements: Element[], + ...args: unknown[] + ) => ReturnType | Promise<ReturnType>, + ...args: SerializableOrJSHandle[] + ): Promise<WrapElementHandle<ReturnType>> { + const document = await this._document(); + const value = await document.$$eval<ReturnType>( + selector, + pageFunction, + ...args + ); + return value; + } + + async $$(selector: string): Promise<ElementHandle[]> { + const document = await this._document(); + const value = await document.$$(selector); + return value; + } + + async content(): Promise<string> { + return await this.evaluate(() => { + let retVal = ''; + if (document.doctype) + retVal = new XMLSerializer().serializeToString(document.doctype); + if (document.documentElement) + retVal += document.documentElement.outerHTML; + return retVal; + }); + } + + async setContent( + html: string, + options: { + timeout?: number; + waitUntil?: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[]; + } = {} + ): Promise<void> { + const { + waitUntil = ['load'], + timeout = this._timeoutSettings.navigationTimeout(), + } = options; + // We rely upon the fact that document.open() will reset frame lifecycle with "init" + // lifecycle event. @see https://crrev.com/608658 + await this.evaluate<(x: string) => void>((html) => { + document.open(); + document.write(html); + document.close(); + }, html); + const watcher = new LifecycleWatcher( + this._frameManager, + this._frame, + waitUntil, + timeout + ); + const error = await Promise.race([ + watcher.timeoutOrTerminationPromise(), + watcher.lifecyclePromise(), + ]); + watcher.dispose(); + if (error) throw error; + } + + /** + * Adds a script tag into the current context. + * + * @remarks + * + * You can pass a URL, filepath or string of contents. Note that when running Puppeteer + * in a browser environment you cannot pass a filepath and should use either + * `url` or `content`. + */ + async addScriptTag(options: { + url?: string; + path?: string; + content?: string; + type?: string; + }): Promise<ElementHandle> { + const { url = null, path = null, content = null, type = '' } = options; + if (url !== null) { + try { + const context = await this.executionContext(); + return ( + await context.evaluateHandle(addScriptUrl, url, type) + ).asElement(); + } catch (error) { + throw new Error(`Loading script from ${url} failed`); + } + } + + if (path !== null) { + if (!isNode) { + throw new Error( + 'Cannot pass a filepath to addScriptTag in the browser environment.' + ); + } + const fs = await helper.importFSModule(); + let contents = await fs.promises.readFile(path, 'utf8'); + contents += '//# sourceURL=' + path.replace(/\n/g, ''); + const context = await this.executionContext(); + return ( + await context.evaluateHandle(addScriptContent, contents, type) + ).asElement(); + } + + if (content !== null) { + const context = await this.executionContext(); + return ( + await context.evaluateHandle(addScriptContent, content, type) + ).asElement(); + } + + throw new Error( + 'Provide an object with a `url`, `path` or `content` property' + ); + + async function addScriptUrl( + url: string, + type: string + ): Promise<HTMLElement> { + const script = document.createElement('script'); + script.src = url; + if (type) script.type = type; + const promise = new Promise((res, rej) => { + script.onload = res; + script.onerror = rej; + }); + document.head.appendChild(script); + await promise; + return script; + } + + function addScriptContent( + content: string, + type = 'text/javascript' + ): HTMLElement { + const script = document.createElement('script'); + script.type = type; + script.text = content; + let error = null; + script.onerror = (e) => (error = e); + document.head.appendChild(script); + if (error) throw error; + return script; + } + } + + /** + * Adds a style tag into the current context. + * + * @remarks + * + * You can pass a URL, filepath or string of contents. Note that when running Puppeteer + * in a browser environment you cannot pass a filepath and should use either + * `url` or `content`. + * + */ + async addStyleTag(options: { + url?: string; + path?: string; + content?: string; + }): Promise<ElementHandle> { + const { url = null, path = null, content = null } = options; + if (url !== null) { + try { + const context = await this.executionContext(); + return (await context.evaluateHandle(addStyleUrl, url)).asElement(); + } catch (error) { + throw new Error(`Loading style from ${url} failed`); + } + } + + if (path !== null) { + if (!isNode) { + throw new Error( + 'Cannot pass a filepath to addStyleTag in the browser environment.' + ); + } + const fs = await helper.importFSModule(); + let contents = await fs.promises.readFile(path, 'utf8'); + contents += '/*# sourceURL=' + path.replace(/\n/g, '') + '*/'; + const context = await this.executionContext(); + return ( + await context.evaluateHandle(addStyleContent, contents) + ).asElement(); + } + + if (content !== null) { + const context = await this.executionContext(); + return ( + await context.evaluateHandle(addStyleContent, content) + ).asElement(); + } + + throw new Error( + 'Provide an object with a `url`, `path` or `content` property' + ); + + async function addStyleUrl(url: string): Promise<HTMLElement> { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = url; + const promise = new Promise((res, rej) => { + link.onload = res; + link.onerror = rej; + }); + document.head.appendChild(link); + await promise; + return link; + } + + async function addStyleContent(content: string): Promise<HTMLElement> { + const style = document.createElement('style'); + style.type = 'text/css'; + style.appendChild(document.createTextNode(content)); + const promise = new Promise((res, rej) => { + style.onload = res; + style.onerror = rej; + }); + document.head.appendChild(style); + await promise; + return style; + } + } + + async click( + selector: string, + options: { delay?: number; button?: MouseButton; clickCount?: number } + ): Promise<void> { + const handle = await this.$(selector); + assert(handle, 'No node found for selector: ' + selector); + await handle.click(options); + await handle.dispose(); + } + + async focus(selector: string): Promise<void> { + const handle = await this.$(selector); + assert(handle, 'No node found for selector: ' + selector); + await handle.focus(); + await handle.dispose(); + } + + async hover(selector: string): Promise<void> { + const handle = await this.$(selector); + assert(handle, 'No node found for selector: ' + selector); + await handle.hover(); + await handle.dispose(); + } + + async select(selector: string, ...values: string[]): Promise<string[]> { + const handle = await this.$(selector); + assert(handle, 'No node found for selector: ' + selector); + const result = await handle.select(...values); + await handle.dispose(); + return result; + } + + async tap(selector: string): Promise<void> { + const handle = await this.$(selector); + await handle.tap(); + await handle.dispose(); + } + + async type( + selector: string, + text: string, + options?: { delay: number } + ): Promise<void> { + const handle = await this.$(selector); + assert(handle, 'No node found for selector: ' + selector); + await handle.type(text, options); + await handle.dispose(); + } + + async waitForSelector( + selector: string, + options: WaitForSelectorOptions + ): Promise<ElementHandle | null> { + const { updatedSelector, queryHandler } = getQueryHandlerAndSelector( + selector + ); + return queryHandler.waitFor(this, updatedSelector, options); + } + + // If multiple waitFor are set up asynchronously, we need to wait for the + // first one to set up the binding in the page before running the others. + private _settingUpBinding: Promise<void> | null = null; + /** + * @internal + */ + async addBindingToContext( + context: ExecutionContext, + name: string + ): Promise<void> { + // Previous operation added the binding so we are done. + if ( + this._ctxBindings.has( + DOMWorld.bindingIdentifier(name, context._contextId) + ) + ) { + return; + } + // Wait for other operation to finish + if (this._settingUpBinding) { + await this._settingUpBinding; + return this.addBindingToContext(context, name); + } + + const bind = async (name: string) => { + const expression = helper.pageBindingInitString('internal', name); + try { + await context._client.send('Runtime.addBinding', { + name, + executionContextId: context._contextId, + }); + await context.evaluate(expression); + } catch (error) { + // We could have tried to evaluate in a context which was already + // destroyed. This happens, for example, if the page is navigated while + // we are trying to add the binding + const ctxDestroyed = error.message.includes( + 'Execution context was destroyed' + ); + const ctxNotFound = error.message.includes( + 'Cannot find context with specified id' + ); + if (ctxDestroyed || ctxNotFound) { + return; + } else { + debugError(error); + return; + } + } + this._ctxBindings.add( + DOMWorld.bindingIdentifier(name, context._contextId) + ); + }; + + this._settingUpBinding = bind(name); + await this._settingUpBinding; + this._settingUpBinding = null; + } + + private async _onBindingCalled( + event: Protocol.Runtime.BindingCalledEvent + ): Promise<void> { + let payload: { type: string; name: string; seq: number; args: unknown[] }; + if (!this._hasContext()) return; + const context = await this.executionContext(); + try { + payload = JSON.parse(event.payload); + } catch { + // The binding was either called by something in the page or it was + // called before our wrapper was initialized. + return; + } + const { type, name, seq, args } = payload; + if ( + type !== 'internal' || + !this._ctxBindings.has( + DOMWorld.bindingIdentifier(name, context._contextId) + ) + ) + return; + if (context._contextId !== event.executionContextId) return; + try { + const result = await this._boundFunctions.get(name)(...args); + await context.evaluate(deliverResult, name, seq, result); + } catch (error) { + // The WaitTask may already have been resolved by timing out, or the + // exection context may have been destroyed. + // In both caes, the promises above are rejected with a protocol error. + // We can safely ignores these, as the WaitTask is re-installed in + // the next execution context if needed. + if (error.message.includes('Protocol error')) return; + debugError(error); + } + function deliverResult(name: string, seq: number, result: unknown): void { + globalThis[name].callbacks.get(seq).resolve(result); + globalThis[name].callbacks.delete(seq); + } + } + + /** + * @internal + */ + async waitForSelectorInPage( + queryOne: Function, + selector: string, + options: WaitForSelectorOptions, + binding?: PageBinding + ): Promise<ElementHandle | null> { + const { + visible: waitForVisible = false, + hidden: waitForHidden = false, + timeout = this._timeoutSettings.timeout(), + } = options; + const polling = waitForVisible || waitForHidden ? 'raf' : 'mutation'; + const title = `selector \`${selector}\`${ + waitForHidden ? ' to be hidden' : '' + }`; + async function predicate( + selector: string, + waitForVisible: boolean, + waitForHidden: boolean + ): Promise<Node | null | boolean> { + const node = predicateQueryHandler + ? ((await predicateQueryHandler(document, selector)) as Element) + : document.querySelector(selector); + return checkWaitForOptions(node, waitForVisible, waitForHidden); + } + const waitTaskOptions: WaitTaskOptions = { + domWorld: this, + predicateBody: helper.makePredicateString(predicate, queryOne), + title, + polling, + timeout, + args: [selector, waitForVisible, waitForHidden], + binding, + }; + const waitTask = new WaitTask(waitTaskOptions); + const jsHandle = await waitTask.promise; + const elementHandle = jsHandle.asElement(); + if (!elementHandle) { + await jsHandle.dispose(); + return null; + } + return elementHandle; + } + + async waitForXPath( + xpath: string, + options: WaitForSelectorOptions + ): Promise<ElementHandle | null> { + const { + visible: waitForVisible = false, + hidden: waitForHidden = false, + timeout = this._timeoutSettings.timeout(), + } = options; + const polling = waitForVisible || waitForHidden ? 'raf' : 'mutation'; + const title = `XPath \`${xpath}\`${waitForHidden ? ' to be hidden' : ''}`; + function predicate( + xpath: string, + waitForVisible: boolean, + waitForHidden: boolean + ): Node | null | boolean { + const node = document.evaluate( + xpath, + document, + null, + XPathResult.FIRST_ORDERED_NODE_TYPE, + null + ).singleNodeValue; + return checkWaitForOptions(node, waitForVisible, waitForHidden); + } + const waitTaskOptions: WaitTaskOptions = { + domWorld: this, + predicateBody: helper.makePredicateString(predicate), + title, + polling, + timeout, + args: [xpath, waitForVisible, waitForHidden], + }; + const waitTask = new WaitTask(waitTaskOptions); + const jsHandle = await waitTask.promise; + const elementHandle = jsHandle.asElement(); + if (!elementHandle) { + await jsHandle.dispose(); + return null; + } + return elementHandle; + } + + waitForFunction( + pageFunction: Function | string, + options: { polling?: string | number; timeout?: number } = {}, + ...args: SerializableOrJSHandle[] + ): Promise<JSHandle> { + const { + polling = 'raf', + timeout = this._timeoutSettings.timeout(), + } = options; + const waitTaskOptions: WaitTaskOptions = { + domWorld: this, + predicateBody: pageFunction, + title: 'function', + polling, + timeout, + args, + }; + const waitTask = new WaitTask(waitTaskOptions); + return waitTask.promise; + } + + async title(): Promise<string> { + return this.evaluate(() => document.title); + } +} + +/** + * @internal + */ +export interface WaitTaskOptions { + domWorld: DOMWorld; + predicateBody: Function | string; + title: string; + polling: string | number; + timeout: number; + binding?: PageBinding; + args: SerializableOrJSHandle[]; +} + +/** + * @internal + */ +export class WaitTask { + _domWorld: DOMWorld; + _polling: string | number; + _timeout: number; + _predicateBody: string; + _args: SerializableOrJSHandle[]; + _binding: PageBinding; + _runCount = 0; + promise: Promise<JSHandle>; + _resolve: (x: JSHandle) => void; + _reject: (x: Error) => void; + _timeoutTimer?: NodeJS.Timeout; + _terminated = false; + + constructor(options: WaitTaskOptions) { + if (helper.isString(options.polling)) + assert( + options.polling === 'raf' || options.polling === 'mutation', + 'Unknown polling option: ' + options.polling + ); + else if (helper.isNumber(options.polling)) + assert( + options.polling > 0, + 'Cannot poll with non-positive interval: ' + options.polling + ); + else throw new Error('Unknown polling options: ' + options.polling); + + function getPredicateBody(predicateBody: Function | string) { + if (helper.isString(predicateBody)) return `return (${predicateBody});`; + return `return (${predicateBody})(...args);`; + } + + this._domWorld = options.domWorld; + this._polling = options.polling; + this._timeout = options.timeout; + this._predicateBody = getPredicateBody(options.predicateBody); + this._args = options.args; + this._binding = options.binding; + this._runCount = 0; + this._domWorld._waitTasks.add(this); + if (this._binding) { + this._domWorld._boundFunctions.set( + this._binding.name, + this._binding.pptrFunction + ); + } + this.promise = new Promise<JSHandle>((resolve, reject) => { + this._resolve = resolve; + this._reject = reject; + }); + // Since page navigation requires us to re-install the pageScript, we should track + // timeout on our end. + if (options.timeout) { + const timeoutError = new TimeoutError( + `waiting for ${options.title} failed: timeout ${options.timeout}ms exceeded` + ); + this._timeoutTimer = setTimeout( + () => this.terminate(timeoutError), + options.timeout + ); + } + this.rerun(); + } + + terminate(error: Error): void { + this._terminated = true; + this._reject(error); + this._cleanup(); + } + + async rerun(): Promise<void> { + const runCount = ++this._runCount; + let success: JSHandle = null; + let error: Error = null; + const context = await this._domWorld.executionContext(); + if (this._terminated || runCount !== this._runCount) return; + if (this._binding) { + await this._domWorld.addBindingToContext(context, this._binding.name); + } + if (this._terminated || runCount !== this._runCount) return; + try { + success = await context.evaluateHandle( + waitForPredicatePageFunction, + this._predicateBody, + this._polling, + this._timeout, + ...this._args + ); + } catch (error_) { + error = error_; + } + + if (this._terminated || runCount !== this._runCount) { + if (success) await success.dispose(); + return; + } + + // Ignore timeouts in pageScript - we track timeouts ourselves. + // If the frame's execution context has already changed, `frame.evaluate` will + // throw an error - ignore this predicate run altogether. + if ( + !error && + (await this._domWorld.evaluate((s) => !s, success).catch(() => true)) + ) { + await success.dispose(); + return; + } + if (error) { + if (error.message.includes('TypeError: binding is not a function')) { + return this.rerun(); + } + // When frame is detached the task should have been terminated by the DOMWorld. + // This can fail if we were adding this task while the frame was detached, + // so we terminate here instead. + if ( + error.message.includes( + 'Execution context is not available in detached frame' + ) + ) { + this.terminate( + new Error('waitForFunction failed: frame got detached.') + ); + return; + } + + // When the page is navigated, the promise is rejected. + // We will try again in the new execution context. + if (error.message.includes('Execution context was destroyed')) return; + + // We could have tried to evaluate in a context which was already + // destroyed. + if (error.message.includes('Cannot find context with specified id')) + return; + + this._reject(error); + } else { + this._resolve(success); + } + this._cleanup(); + } + + _cleanup(): void { + clearTimeout(this._timeoutTimer); + this._domWorld._waitTasks.delete(this); + } +} + +async function waitForPredicatePageFunction( + predicateBody: string, + polling: string, + timeout: number, + ...args: unknown[] +): Promise<unknown> { + const predicate = new Function('...args', predicateBody); + let timedOut = false; + if (timeout) setTimeout(() => (timedOut = true), timeout); + if (polling === 'raf') return await pollRaf(); + if (polling === 'mutation') return await pollMutation(); + if (typeof polling === 'number') return await pollInterval(polling); + + /** + * @returns {!Promise<*>} + */ + async function pollMutation(): Promise<unknown> { + const success = await predicate(...args); + if (success) return Promise.resolve(success); + + let fulfill; + const result = new Promise((x) => (fulfill = x)); + const observer = new MutationObserver(async () => { + if (timedOut) { + observer.disconnect(); + fulfill(); + } + const success = await predicate(...args); + if (success) { + observer.disconnect(); + fulfill(success); + } + }); + observer.observe(document, { + childList: true, + subtree: true, + attributes: true, + }); + return result; + } + + async function pollRaf(): Promise<unknown> { + let fulfill; + const result = new Promise((x) => (fulfill = x)); + await onRaf(); + return result; + + async function onRaf(): Promise<unknown> { + if (timedOut) { + fulfill(); + return; + } + const success = await predicate(...args); + if (success) fulfill(success); + else requestAnimationFrame(onRaf); + } + } + + async function pollInterval(pollInterval: number): Promise<unknown> { + let fulfill; + const result = new Promise((x) => (fulfill = x)); + await onTimeout(); + return result; + + async function onTimeout(): Promise<unknown> { + if (timedOut) { + fulfill(); + return; + } + const success = await predicate(...args); + if (success) fulfill(success); + else setTimeout(onTimeout, pollInterval); + } + } +} diff --git a/remote/test/puppeteer/src/common/Debug.ts b/remote/test/puppeteer/src/common/Debug.ts new file mode 100644 index 0000000000..8ff124b094 --- /dev/null +++ b/remote/test/puppeteer/src/common/Debug.ts @@ -0,0 +1,83 @@ +/** + * Copyright 2020 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. + */ + +import { isNode } from '../environment.js'; + +/** + * A debug function that can be used in any environment. + * + * @remarks + * + * If used in Node, it falls back to the + * {@link https://www.npmjs.com/package/debug | debug module}. In the browser it + * uses `console.log`. + * + * @param prefix - this will be prefixed to each log. + * @returns a function that can be called to log to that debug channel. + * + * In Node, use the `DEBUG` environment variable to control logging: + * + * ``` + * DEBUG=* // logs all channels + * DEBUG=foo // logs the `foo` channel + * DEBUG=foo* // logs any channels starting with `foo` + * ``` + * + * In the browser, set `window.__PUPPETEER_DEBUG` to a string: + * + * ``` + * window.__PUPPETEER_DEBUG='*'; // logs all channels + * window.__PUPPETEER_DEBUG='foo'; // logs the `foo` channel + * window.__PUPPETEER_DEBUG='foo*'; // logs any channels starting with `foo` + * ``` + * + * @example + * ``` + * const log = debug('Page'); + * + * log('new page created') + * // logs "Page: new page created" + * ``` + */ +export const debug = (prefix: string): ((...args: unknown[]) => void) => { + if (isNode) { + // eslint-disable-next-line @typescript-eslint/no-var-requires + return require('debug')(prefix); + } + + return (...logArgs: unknown[]): void => { + const debugLevel = globalThis.__PUPPETEER_DEBUG as string; + if (!debugLevel) return; + + const everythingShouldBeLogged = debugLevel === '*'; + + const prefixMatchesDebugLevel = + everythingShouldBeLogged || + /** + * If the debug level is `foo*`, that means we match any prefix that + * starts with `foo`. If the level is `foo`, we match only the prefix + * `foo`. + */ + (debugLevel.endsWith('*') + ? prefix.startsWith(debugLevel) + : prefix === debugLevel); + + if (!prefixMatchesDebugLevel) return; + + // eslint-disable-next-line no-console + console.log(`${prefix}:`, ...logArgs); + }; +}; diff --git a/remote/test/puppeteer/src/common/DeviceDescriptors.ts b/remote/test/puppeteer/src/common/DeviceDescriptors.ts new file mode 100644 index 0000000000..a4a4960b94 --- /dev/null +++ b/remote/test/puppeteer/src/common/DeviceDescriptors.ts @@ -0,0 +1,964 @@ +/** + * 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. + */ + +interface Device { + name: string; + userAgent: string; + viewport: { + width: number; + height: number; + deviceScaleFactor: number; + isMobile: boolean; + hasTouch: boolean; + isLandscape: boolean; + }; +} + +const devices: Device[] = [ + { + name: 'Blackberry PlayBook', + userAgent: + 'Mozilla/5.0 (PlayBook; U; RIM Tablet OS 2.1.0; en-US) AppleWebKit/536.2+ (KHTML like Gecko) Version/7.2.1.0 Safari/536.2+', + viewport: { + width: 600, + height: 1024, + deviceScaleFactor: 1, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Blackberry PlayBook landscape', + userAgent: + 'Mozilla/5.0 (PlayBook; U; RIM Tablet OS 2.1.0; en-US) AppleWebKit/536.2+ (KHTML like Gecko) Version/7.2.1.0 Safari/536.2+', + viewport: { + width: 1024, + height: 600, + deviceScaleFactor: 1, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'BlackBerry Z30', + userAgent: + 'Mozilla/5.0 (BB10; Touch) AppleWebKit/537.10+ (KHTML, like Gecko) Version/10.0.9.2372 Mobile Safari/537.10+', + viewport: { + width: 360, + height: 640, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'BlackBerry Z30 landscape', + userAgent: + 'Mozilla/5.0 (BB10; Touch) AppleWebKit/537.10+ (KHTML, like Gecko) Version/10.0.9.2372 Mobile Safari/537.10+', + viewport: { + width: 640, + height: 360, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Galaxy Note 3', + userAgent: + 'Mozilla/5.0 (Linux; U; Android 4.3; en-us; SM-N900T Build/JSS15J) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', + viewport: { + width: 360, + height: 640, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Galaxy Note 3 landscape', + userAgent: + 'Mozilla/5.0 (Linux; U; Android 4.3; en-us; SM-N900T Build/JSS15J) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', + viewport: { + width: 640, + height: 360, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Galaxy Note II', + userAgent: + 'Mozilla/5.0 (Linux; U; Android 4.1; en-us; GT-N7100 Build/JRO03C) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', + viewport: { + width: 360, + height: 640, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Galaxy Note II landscape', + userAgent: + 'Mozilla/5.0 (Linux; U; Android 4.1; en-us; GT-N7100 Build/JRO03C) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', + viewport: { + width: 640, + height: 360, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Galaxy S III', + userAgent: + 'Mozilla/5.0 (Linux; U; Android 4.0; en-us; GT-I9300 Build/IMM76D) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', + viewport: { + width: 360, + height: 640, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Galaxy S III landscape', + userAgent: + 'Mozilla/5.0 (Linux; U; Android 4.0; en-us; GT-I9300 Build/IMM76D) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', + viewport: { + width: 640, + height: 360, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Galaxy S5', + userAgent: + 'Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 360, + height: 640, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Galaxy S5 landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 640, + height: 360, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPad', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1', + viewport: { + width: 768, + height: 1024, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPad landscape', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1', + viewport: { + width: 1024, + height: 768, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPad Mini', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1', + viewport: { + width: 768, + height: 1024, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPad Mini landscape', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1', + viewport: { + width: 1024, + height: 768, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPad Pro', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1', + viewport: { + width: 1024, + height: 1366, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPad Pro landscape', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1', + viewport: { + width: 1366, + height: 1024, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 4', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 7_1_2 like Mac OS X) AppleWebKit/537.51.2 (KHTML, like Gecko) Version/7.0 Mobile/11D257 Safari/9537.53', + viewport: { + width: 320, + height: 480, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 4 landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 7_1_2 like Mac OS X) AppleWebKit/537.51.2 (KHTML, like Gecko) Version/7.0 Mobile/11D257 Safari/9537.53', + viewport: { + width: 480, + height: 320, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 5', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1', + viewport: { + width: 320, + height: 568, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 5 landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1', + viewport: { + width: 568, + height: 320, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 6', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 375, + height: 667, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 6 landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 667, + height: 375, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 6 Plus', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 414, + height: 736, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 6 Plus landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 736, + height: 414, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 7', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 375, + height: 667, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 7 landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 667, + height: 375, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 7 Plus', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 414, + height: 736, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 7 Plus landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 736, + height: 414, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 8', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 375, + height: 667, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 8 landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 667, + height: 375, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 8 Plus', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 414, + height: 736, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 8 Plus landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 736, + height: 414, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone SE', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1', + viewport: { + width: 320, + height: 568, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone SE landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1', + viewport: { + width: 568, + height: 320, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone X', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 375, + height: 812, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone X landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 812, + height: 375, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone XR', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0 Mobile/15E148 Safari/604.1', + viewport: { + width: 414, + height: 896, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone XR landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0 Mobile/15E148 Safari/604.1', + viewport: { + width: 896, + height: 414, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'JioPhone 2', + userAgent: + 'Mozilla/5.0 (Mobile; LYF/F300B/LYF-F300B-001-01-15-130718-i;Android; rv:48.0) Gecko/48.0 Firefox/48.0 KAIOS/2.5', + viewport: { + width: 240, + height: 320, + deviceScaleFactor: 1, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'JioPhone 2 landscape', + userAgent: + 'Mozilla/5.0 (Mobile; LYF/F300B/LYF-F300B-001-01-15-130718-i;Android; rv:48.0) Gecko/48.0 Firefox/48.0 KAIOS/2.5', + viewport: { + width: 320, + height: 240, + deviceScaleFactor: 1, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Kindle Fire HDX', + userAgent: + 'Mozilla/5.0 (Linux; U; en-us; KFAPWI Build/JDQ39) AppleWebKit/535.19 (KHTML, like Gecko) Silk/3.13 Safari/535.19 Silk-Accelerated=true', + viewport: { + width: 800, + height: 1280, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Kindle Fire HDX landscape', + userAgent: + 'Mozilla/5.0 (Linux; U; en-us; KFAPWI Build/JDQ39) AppleWebKit/535.19 (KHTML, like Gecko) Silk/3.13 Safari/535.19 Silk-Accelerated=true', + viewport: { + width: 1280, + height: 800, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'LG Optimus L70', + userAgent: + 'Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 384, + height: 640, + deviceScaleFactor: 1.25, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'LG Optimus L70 landscape', + userAgent: + 'Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 640, + height: 384, + deviceScaleFactor: 1.25, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Microsoft Lumia 550', + userAgent: + 'Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2486.0 Mobile Safari/537.36 Edge/14.14263', + viewport: { + width: 640, + height: 360, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Microsoft Lumia 950', + userAgent: + 'Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2486.0 Mobile Safari/537.36 Edge/14.14263', + viewport: { + width: 360, + height: 640, + deviceScaleFactor: 4, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Microsoft Lumia 950 landscape', + userAgent: + 'Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2486.0 Mobile Safari/537.36 Edge/14.14263', + viewport: { + width: 640, + height: 360, + deviceScaleFactor: 4, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Nexus 10', + userAgent: + 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Safari/537.36', + viewport: { + width: 800, + height: 1280, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Nexus 10 landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Safari/537.36', + viewport: { + width: 1280, + height: 800, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Nexus 4', + userAgent: + 'Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 384, + height: 640, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Nexus 4 landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 640, + height: 384, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Nexus 5', + userAgent: + 'Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 360, + height: 640, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Nexus 5 landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 640, + height: 360, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Nexus 5X', + userAgent: + 'Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 412, + height: 732, + deviceScaleFactor: 2.625, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Nexus 5X landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 732, + height: 412, + deviceScaleFactor: 2.625, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Nexus 6', + userAgent: + 'Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 412, + height: 732, + deviceScaleFactor: 3.5, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Nexus 6 landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 732, + height: 412, + deviceScaleFactor: 3.5, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Nexus 6P', + userAgent: + 'Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 412, + height: 732, + deviceScaleFactor: 3.5, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Nexus 6P landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 732, + height: 412, + deviceScaleFactor: 3.5, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Nexus 7', + userAgent: + 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Safari/537.36', + viewport: { + width: 600, + height: 960, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Nexus 7 landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Safari/537.36', + viewport: { + width: 960, + height: 600, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Nokia Lumia 520', + userAgent: + 'Mozilla/5.0 (compatible; MSIE 10.0; Windows Phone 8.0; Trident/6.0; IEMobile/10.0; ARM; Touch; NOKIA; Lumia 520)', + viewport: { + width: 320, + height: 533, + deviceScaleFactor: 1.5, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Nokia Lumia 520 landscape', + userAgent: + 'Mozilla/5.0 (compatible; MSIE 10.0; Windows Phone 8.0; Trident/6.0; IEMobile/10.0; ARM; Touch; NOKIA; Lumia 520)', + viewport: { + width: 533, + height: 320, + deviceScaleFactor: 1.5, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Nokia N9', + userAgent: + 'Mozilla/5.0 (MeeGo; NokiaN9) AppleWebKit/534.13 (KHTML, like Gecko) NokiaBrowser/8.5.0 Mobile Safari/534.13', + viewport: { + width: 480, + height: 854, + deviceScaleFactor: 1, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Nokia N9 landscape', + userAgent: + 'Mozilla/5.0 (MeeGo; NokiaN9) AppleWebKit/534.13 (KHTML, like Gecko) NokiaBrowser/8.5.0 Mobile Safari/534.13', + viewport: { + width: 854, + height: 480, + deviceScaleFactor: 1, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Pixel 2', + userAgent: + 'Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 411, + height: 731, + deviceScaleFactor: 2.625, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Pixel 2 landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 731, + height: 411, + deviceScaleFactor: 2.625, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Pixel 2 XL', + userAgent: + 'Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 411, + height: 823, + deviceScaleFactor: 3.5, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Pixel 2 XL landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 823, + height: 411, + deviceScaleFactor: 3.5, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, +]; + +export type DevicesMap = { + [name: string]: Device; +}; + +const devicesMap: DevicesMap = {}; + +for (const device of devices) devicesMap[device.name] = device; + +export { devicesMap }; diff --git a/remote/test/puppeteer/src/common/Dialog.ts b/remote/test/puppeteer/src/common/Dialog.ts new file mode 100644 index 0000000000..48720239b6 --- /dev/null +++ b/remote/test/puppeteer/src/common/Dialog.ts @@ -0,0 +1,111 @@ +/** + * 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. + */ + +import { assert } from './assert.js'; +import { CDPSession } from './Connection.js'; +import { Protocol } from 'devtools-protocol'; + +/** + * Dialog instances are dispatched by the {@link Page} via the `dialog` event. + * + * @remarks + * + * @example + * ```js + * const puppeteer = require('puppeteer'); + * + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * page.on('dialog', async dialog => { + * console.log(dialog.message()); + * await dialog.dismiss(); + * await browser.close(); + * }); + * page.evaluate(() => alert('1')); + * })(); + * ``` + */ +export class Dialog { + private _client: CDPSession; + private _type: Protocol.Page.DialogType; + private _message: string; + private _defaultValue: string; + private _handled = false; + + /** + * @internal + */ + constructor( + client: CDPSession, + type: Protocol.Page.DialogType, + message: string, + defaultValue = '' + ) { + this._client = client; + this._type = type; + this._message = message; + this._defaultValue = defaultValue; + } + + /** + * @returns The type of the dialog. + */ + type(): Protocol.Page.DialogType { + return this._type; + } + + /** + * @returns The message displayed in the dialog. + */ + message(): string { + return this._message; + } + + /** + * @returns The default value of the prompt, or an empty string if the dialog + * is not a `prompt`. + */ + defaultValue(): string { + return this._defaultValue; + } + + /** + * @param promptText - optional text that will be entered in the dialog + * prompt. Has no effect if the dialog's type is not `prompt`. + * + * @returns A promise that resolves when the dialog has been accepted. + */ + async accept(promptText?: string): Promise<void> { + assert(!this._handled, 'Cannot accept dialog which is already handled!'); + this._handled = true; + await this._client.send('Page.handleJavaScriptDialog', { + accept: true, + promptText: promptText, + }); + } + + /** + * @returns A promise which will resolve once the dialog has been dismissed + */ + async dismiss(): Promise<void> { + assert(!this._handled, 'Cannot dismiss dialog which is already handled!'); + this._handled = true; + await this._client.send('Page.handleJavaScriptDialog', { + accept: false, + }); + } +} diff --git a/remote/test/puppeteer/src/common/EmulationManager.ts b/remote/test/puppeteer/src/common/EmulationManager.ts new file mode 100644 index 0000000000..f3130ef3c5 --- /dev/null +++ b/remote/test/puppeteer/src/common/EmulationManager.ts @@ -0,0 +1,58 @@ +/** + * 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. + */ +import { CDPSession } from './Connection.js'; +import { Viewport } from './PuppeteerViewport.js'; +import { Protocol } from 'devtools-protocol'; + +export class EmulationManager { + _client: CDPSession; + _emulatingMobile = false; + _hasTouch = false; + + constructor(client: CDPSession) { + this._client = client; + } + + async emulateViewport(viewport: Viewport): Promise<boolean> { + const mobile = viewport.isMobile || false; + const width = viewport.width; + const height = viewport.height; + const deviceScaleFactor = viewport.deviceScaleFactor || 1; + const screenOrientation: Protocol.Emulation.ScreenOrientation = viewport.isLandscape + ? { angle: 90, type: 'landscapePrimary' } + : { angle: 0, type: 'portraitPrimary' }; + const hasTouch = viewport.hasTouch || false; + + await Promise.all([ + this._client.send('Emulation.setDeviceMetricsOverride', { + mobile, + width, + height, + deviceScaleFactor, + screenOrientation, + }), + this._client.send('Emulation.setTouchEmulationEnabled', { + enabled: hasTouch, + }), + ]); + + const reloadNeeded = + this._emulatingMobile !== mobile || this._hasTouch !== hasTouch; + this._emulatingMobile = mobile; + this._hasTouch = hasTouch; + return reloadNeeded; + } +} diff --git a/remote/test/puppeteer/src/common/Errors.ts b/remote/test/puppeteer/src/common/Errors.ts new file mode 100644 index 0000000000..2ba151495d --- /dev/null +++ b/remote/test/puppeteer/src/common/Errors.ts @@ -0,0 +1,41 @@ +/** + * 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: string) { + super(message); + this.name = this.constructor.name; + Error.captureStackTrace(this, this.constructor); + } +} + +/** + * TimeoutError is emitted whenever certain operations are terminated due to timeout. + * + * @remarks + * + * Example operations are {@link Page.waitForSelector | page.waitForSelector} + * or {@link PuppeteerNode.launch | puppeteer.launch}. + * + * @public + */ +export class TimeoutError extends CustomError {} + +export type PuppeteerErrors = Record<string, typeof CustomError>; + +export const puppeteerErrors: PuppeteerErrors = { + TimeoutError, +}; diff --git a/remote/test/puppeteer/src/common/EvalTypes.ts b/remote/test/puppeteer/src/common/EvalTypes.ts new file mode 100644 index 0000000000..7a9335942a --- /dev/null +++ b/remote/test/puppeteer/src/common/EvalTypes.ts @@ -0,0 +1,81 @@ +/** + * Copyright 2020 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. + */ + +import { JSHandle, ElementHandle } from './JSHandle.js'; + +/** + * @public + */ +export type EvaluateFn<T = unknown> = + | string + | ((arg1: T, ...args: unknown[]) => unknown); + +export type UnwrapPromiseLike<T> = T extends PromiseLike<infer U> ? U : T; + +/** + * @public + */ +export type EvaluateFnReturnType<T extends EvaluateFn> = T extends ( + ...args: unknown[] +) => infer R + ? R + : unknown; + +/** + * @public + */ +export type EvaluateHandleFn = string | ((...args: unknown[]) => unknown); + +/** + * @public + */ +export type Serializable = + | number + | string + | boolean + | null + | BigInt + | JSONArray + | JSONObject; + +/** + * @public + */ +export type JSONArray = Serializable[]; + +/** + * @public + */ +export interface JSONObject { + [key: string]: Serializable; +} + +/** + * @public + */ +export type SerializableOrJSHandle = Serializable | JSHandle; + +/** + * Wraps a DOM element into an ElementHandle instance + * @public + **/ +export type WrapElementHandle<X> = X extends Element ? ElementHandle<X> : X; + +/** + * Unwraps a DOM element out of an ElementHandle instance + * @public + **/ +export type UnwrapElementHandle<X> = X extends ElementHandle<infer E> ? E : X; diff --git a/remote/test/puppeteer/src/common/EventEmitter.ts b/remote/test/puppeteer/src/common/EventEmitter.ts new file mode 100644 index 0000000000..3588f1408b --- /dev/null +++ b/remote/test/puppeteer/src/common/EventEmitter.ts @@ -0,0 +1,144 @@ +import mitt, { + Emitter, + EventType, + Handler, +} from '../../vendor/mitt/src/index.js'; + +/** + * @internal + */ +export interface CommonEventEmitter { + on(event: EventType, handler: Handler): CommonEventEmitter; + off(event: EventType, handler: Handler): CommonEventEmitter; + /* To maintain parity with the built in NodeJS event emitter which uses removeListener + * rather than `off`. + * If you're implementing new code you should use `off`. + */ + addListener(event: EventType, handler: Handler): CommonEventEmitter; + removeListener(event: EventType, handler: Handler): CommonEventEmitter; + emit(event: EventType, eventData?: any): boolean; + once(event: EventType, handler: Handler): CommonEventEmitter; + listenerCount(event: string): number; + + removeAllListeners(event?: EventType): CommonEventEmitter; +} + +/** + * The EventEmitter class that many Puppeteer classes extend. + * + * @remarks + * + * This allows you to listen to events that Puppeteer classes fire and act + * accordingly. Therefore you'll mostly use {@link EventEmitter.on | on} and + * {@link EventEmitter.off | off} to bind + * and unbind to event listeners. + * + * @public + */ +export class EventEmitter implements CommonEventEmitter { + private emitter: Emitter; + private eventsMap = new Map<EventType, Handler[]>(); + + /** + * @internal + */ + constructor() { + this.emitter = mitt(this.eventsMap); + } + + /** + * Bind an event listener to fire when an event occurs. + * @param event - the event type you'd like to listen to. Can be a string or symbol. + * @param handler - the function to be called when the event occurs. + * @returns `this` to enable you to chain calls. + */ + on(event: EventType, handler: Handler): EventEmitter { + this.emitter.on(event, handler); + return this; + } + + /** + * Remove an event listener from firing. + * @param event - the event type you'd like to stop listening to. + * @param handler - the function that should be removed. + * @returns `this` to enable you to chain calls. + */ + off(event: EventType, handler: Handler): EventEmitter { + this.emitter.off(event, handler); + return this; + } + + /** + * Remove an event listener. + * @deprecated please use `off` instead. + */ + removeListener(event: EventType, handler: Handler): EventEmitter { + this.off(event, handler); + return this; + } + + /** + * Add an event listener. + * @deprecated please use `on` instead. + */ + addListener(event: EventType, handler: Handler): EventEmitter { + this.on(event, handler); + return this; + } + + /** + * Emit an event and call any associated listeners. + * + * @param event - the event you'd like to emit + * @param eventData - any data you'd like to emit with the event + * @returns `true` if there are any listeners, `false` if there are not. + */ + emit(event: EventType, eventData?: any): boolean { + this.emitter.emit(event, eventData); + return this.eventListenersCount(event) > 0; + } + + /** + * Like `on` but the listener will only be fired once and then it will be removed. + * @param event - the event you'd like to listen to + * @param handler - the handler function to run when the event occurs + * @returns `this` to enable you to chain calls. + */ + once(event: EventType, handler: Handler): EventEmitter { + const onceHandler: Handler = (eventData) => { + handler(eventData); + this.off(event, onceHandler); + }; + + return this.on(event, onceHandler); + } + + /** + * Gets the number of listeners for a given event. + * + * @param event - the event to get the listener count for + * @returns the number of listeners bound to the given event + */ + listenerCount(event: EventType): number { + return this.eventListenersCount(event); + } + + /** + * Removes all listeners. If given an event argument, it will remove only + * listeners for that event. + * @param event - the event to remove listeners for. + * @returns `this` to enable you to chain calls. + */ + removeAllListeners(event?: EventType): EventEmitter { + if (event) { + this.eventsMap.delete(event); + } else { + this.eventsMap.clear(); + } + return this; + } + + private eventListenersCount(event: EventType): number { + return this.eventsMap.has(event) ? this.eventsMap.get(event).length : 0; + } +} diff --git a/remote/test/puppeteer/src/common/Events.ts b/remote/test/puppeteer/src/common/Events.ts new file mode 100644 index 0000000000..90d8bae785 --- /dev/null +++ b/remote/test/puppeteer/src/common/Events.ts @@ -0,0 +1,97 @@ +/** + * Copyright 2019 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. + */ + +/** + * IMPORTANT: we are mid-way through migrating away from this Events.ts file + * in favour of defining events next to the class that emits them. + * + * However we need to maintain this file for now because the legacy DocLint + * system relies on them. Be aware in the mean time if you make a change here + * you probably need to replicate it in the relevant class. For example if you + * add a new Page event, you should update the PageEmittedEvents enum in + * src/common/Page.ts. + * + * Chat to @jackfranklin if you're unsure. + */ + +export const Events = { + Page: { + Close: 'close', + Console: 'console', + Dialog: 'dialog', + DOMContentLoaded: 'domcontentloaded', + Error: 'error', + // Can't use just 'error' due to node.js special treatment of error events. + // @see https://nodejs.org/api/events.html#events_error_events + PageError: 'pageerror', + Request: 'request', + Response: 'response', + RequestFailed: 'requestfailed', + RequestFinished: 'requestfinished', + FrameAttached: 'frameattached', + FrameDetached: 'framedetached', + FrameNavigated: 'framenavigated', + Load: 'load', + Metrics: 'metrics', + Popup: 'popup', + WorkerCreated: 'workercreated', + WorkerDestroyed: 'workerdestroyed', + }, + + Browser: { + TargetCreated: 'targetcreated', + TargetDestroyed: 'targetdestroyed', + TargetChanged: 'targetchanged', + Disconnected: 'disconnected', + }, + + BrowserContext: { + TargetCreated: 'targetcreated', + TargetDestroyed: 'targetdestroyed', + TargetChanged: 'targetchanged', + }, + + NetworkManager: { + Request: Symbol('Events.NetworkManager.Request'), + Response: Symbol('Events.NetworkManager.Response'), + RequestFailed: Symbol('Events.NetworkManager.RequestFailed'), + RequestFinished: Symbol('Events.NetworkManager.RequestFinished'), + }, + + FrameManager: { + FrameAttached: Symbol('Events.FrameManager.FrameAttached'), + FrameNavigated: Symbol('Events.FrameManager.FrameNavigated'), + FrameDetached: Symbol('Events.FrameManager.FrameDetached'), + LifecycleEvent: Symbol('Events.FrameManager.LifecycleEvent'), + FrameNavigatedWithinDocument: Symbol( + 'Events.FrameManager.FrameNavigatedWithinDocument' + ), + ExecutionContextCreated: Symbol( + 'Events.FrameManager.ExecutionContextCreated' + ), + ExecutionContextDestroyed: Symbol( + 'Events.FrameManager.ExecutionContextDestroyed' + ), + }, + + Connection: { + Disconnected: Symbol('Events.Connection.Disconnected'), + }, + + CDPSession: { + Disconnected: Symbol('Events.CDPSession.Disconnected'), + }, +} as const; diff --git a/remote/test/puppeteer/src/common/ExecutionContext.ts b/remote/test/puppeteer/src/common/ExecutionContext.ts new file mode 100644 index 0000000000..4420410de0 --- /dev/null +++ b/remote/test/puppeteer/src/common/ExecutionContext.ts @@ -0,0 +1,387 @@ +/** + * 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. + */ + +import { assert } from './assert.js'; +import { helper } from './helper.js'; +import { createJSHandle, JSHandle, ElementHandle } from './JSHandle.js'; +import { CDPSession } from './Connection.js'; +import { DOMWorld } from './DOMWorld.js'; +import { Frame } from './FrameManager.js'; +import { Protocol } from 'devtools-protocol'; +import { EvaluateHandleFn, SerializableOrJSHandle } from './EvalTypes.js'; + +export const EVALUATION_SCRIPT_URL = '__puppeteer_evaluation_script__'; +const SOURCE_URL_REGEX = /^[\040\t]*\/\/[@#] sourceURL=\s*(\S*?)\s*$/m; + +/** + * This class represents a context for JavaScript execution. A [Page] might have + * many execution contexts: + * - each + * {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe | + * frame } has "default" execution context that is always created after frame is + * attached to DOM. This context is returned by the + * {@link frame.executionContext()} method. + * - {@link https://developer.chrome.com/extensions | Extension}'s content scripts + * create additional execution contexts. + * + * Besides pages, execution contexts can be found in + * {@link https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API | + * workers }. + * + * @public + */ +export class ExecutionContext { + /** + * @internal + */ + _client: CDPSession; + /** + * @internal + */ + _world: DOMWorld; + /** + * @internal + */ + _contextId: number; + + /** + * @internal + */ + constructor( + client: CDPSession, + contextPayload: Protocol.Runtime.ExecutionContextDescription, + world: DOMWorld + ) { + this._client = client; + this._world = world; + this._contextId = contextPayload.id; + } + + /** + * @remarks + * + * Not every execution context is associated with a frame. For + * example, workers and extensions have execution contexts that are not + * associated with frames. + * + * @returns The frame associated with this execution context. + */ + frame(): Frame | null { + return this._world ? this._world.frame() : null; + } + + /** + * @remarks + * If the function passed to the `executionContext.evaluate` returns a + * Promise, then `executionContext.evaluate` would wait for the promise to + * resolve and return its value. If the function passed to the + * `executionContext.evaluate` returns a non-serializable value, then + * `executionContext.evaluate` resolves to `undefined`. DevTools Protocol also + * supports transferring some additional values that are not serializable by + * `JSON`: `-0`, `NaN`, `Infinity`, `-Infinity`, and bigint literals. + * + * + * @example + * ```js + * const executionContext = await page.mainFrame().executionContext(); + * const result = await executionContext.evaluate(() => Promise.resolve(8 * 7))* ; + * console.log(result); // prints "56" + * ``` + * + * @example + * A string can also be passed in instead of a function. + * + * ```js + * console.log(await executionContext.evaluate('1 + 2')); // prints "3" + * ``` + * + * @example + * {@link JSHandle} instances can be passed as arguments to the + * `executionContext.* evaluate`: + * ```js + * const oneHandle = await executionContext.evaluateHandle(() => 1); + * const twoHandle = await executionContext.evaluateHandle(() => 2); + * const result = await executionContext.evaluate( + * (a, b) => a + b, oneHandle, * twoHandle + * ); + * await oneHandle.dispose(); + * await twoHandle.dispose(); + * console.log(result); // prints '3'. + * ``` + * @param pageFunction a function to be evaluated in the `executionContext` + * @param args argument to pass to the page function + * + * @returns A promise that resolves to the return value of the given function. + */ + async evaluate<ReturnType extends any>( + pageFunction: Function | string, + ...args: unknown[] + ): Promise<ReturnType> { + return await this._evaluateInternal<ReturnType>( + true, + pageFunction, + ...args + ); + } + + /** + * @remarks + * The only difference between `executionContext.evaluate` and + * `executionContext.evaluateHandle` is that `executionContext.evaluateHandle` + * returns an in-page object (a {@link JSHandle}). + * If the function passed to the `executionContext.evaluateHandle` returns a + * Promise, then `executionContext.evaluateHandle` would wait for the + * promise to resolve and return its value. + * + * @example + * ```js + * const context = await page.mainFrame().executionContext(); + * const aHandle = await context.evaluateHandle(() => Promise.resolve(self)); + * aHandle; // Handle for the global object. + * ``` + * + * @example + * A string can also be passed in instead of a function. + * + * ```js + * // Handle for the '3' * object. + * const aHandle = await context.evaluateHandle('1 + 2'); + * ``` + * + * @example + * JSHandle instances can be passed as arguments + * to the `executionContext.* evaluateHandle`: + * + * ```js + * const aHandle = await context.evaluateHandle(() => document.body); + * const resultHandle = await context.evaluateHandle(body => body.innerHTML, * aHandle); + * console.log(await resultHandle.jsonValue()); // prints body's innerHTML + * await aHandle.dispose(); + * await resultHandle.dispose(); + * ``` + * + * @param pageFunction a function to be evaluated in the `executionContext` + * @param args argument to pass to the page function + * + * @returns A promise that resolves to the return value of the given function + * as an in-page object (a {@link JSHandle}). + */ + async evaluateHandle<HandleType extends JSHandle | ElementHandle = JSHandle>( + pageFunction: EvaluateHandleFn, + ...args: SerializableOrJSHandle[] + ): Promise<HandleType> { + return this._evaluateInternal<HandleType>(false, pageFunction, ...args); + } + + private async _evaluateInternal<ReturnType>( + returnByValue: boolean, + pageFunction: Function | string, + ...args: unknown[] + ): Promise<ReturnType> { + const suffix = `//# sourceURL=${EVALUATION_SCRIPT_URL}`; + + if (helper.isString(pageFunction)) { + const contextId = this._contextId; + const expression = pageFunction; + const expressionWithSourceUrl = SOURCE_URL_REGEX.test(expression) + ? expression + : expression + '\n' + suffix; + + const { exceptionDetails, result: remoteObject } = await this._client + .send('Runtime.evaluate', { + expression: expressionWithSourceUrl, + contextId, + returnByValue, + awaitPromise: true, + userGesture: true, + }) + .catch(rewriteError); + + if (exceptionDetails) + throw new Error( + 'Evaluation failed: ' + helper.getExceptionMessage(exceptionDetails) + ); + + return returnByValue + ? helper.valueFromRemoteObject(remoteObject) + : createJSHandle(this, remoteObject); + } + + if (typeof pageFunction !== 'function') + throw new Error( + `Expected to get |string| or |function| as the first argument, but got "${pageFunction}" instead.` + ); + + let functionText = pageFunction.toString(); + try { + new Function('(' + functionText + ')'); + } catch (error) { + // This means we might have a function shorthand. Try another + // time prefixing 'function '. + if (functionText.startsWith('async ')) + functionText = + 'async function ' + functionText.substring('async '.length); + else functionText = 'function ' + functionText; + try { + new Function('(' + functionText + ')'); + } catch (error) { + // We tried hard to serialize, but there's a weird beast here. + throw new Error('Passed function is not well-serializable!'); + } + } + let callFunctionOnPromise; + try { + callFunctionOnPromise = this._client.send('Runtime.callFunctionOn', { + functionDeclaration: functionText + '\n' + suffix + '\n', + executionContextId: this._contextId, + arguments: args.map(convertArgument.bind(this)), + returnByValue, + awaitPromise: true, + userGesture: true, + }); + } catch (error) { + if ( + error instanceof TypeError && + error.message.startsWith('Converting circular structure to JSON') + ) + error.message += ' Are you passing a nested JSHandle?'; + throw error; + } + const { + exceptionDetails, + result: remoteObject, + } = await callFunctionOnPromise.catch(rewriteError); + if (exceptionDetails) + throw new Error( + 'Evaluation failed: ' + helper.getExceptionMessage(exceptionDetails) + ); + return returnByValue + ? helper.valueFromRemoteObject(remoteObject) + : createJSHandle(this, remoteObject); + + /** + * @param {*} arg + * @returns {*} + * @this {ExecutionContext} + */ + function convertArgument(this: ExecutionContext, arg: unknown): unknown { + if (typeof arg === 'bigint') + // eslint-disable-line valid-typeof + return { unserializableValue: `${arg.toString()}n` }; + if (Object.is(arg, -0)) return { unserializableValue: '-0' }; + if (Object.is(arg, Infinity)) return { unserializableValue: 'Infinity' }; + if (Object.is(arg, -Infinity)) + return { unserializableValue: '-Infinity' }; + if (Object.is(arg, NaN)) return { unserializableValue: 'NaN' }; + const objectHandle = arg && arg instanceof JSHandle ? arg : null; + if (objectHandle) { + if (objectHandle._context !== this) + throw new Error( + 'JSHandles can be evaluated only in the context they were created!' + ); + if (objectHandle._disposed) throw new Error('JSHandle is disposed!'); + if (objectHandle._remoteObject.unserializableValue) + return { + unserializableValue: objectHandle._remoteObject.unserializableValue, + }; + if (!objectHandle._remoteObject.objectId) + return { value: objectHandle._remoteObject.value }; + return { objectId: objectHandle._remoteObject.objectId }; + } + return { value: arg }; + } + + function rewriteError(error: Error): Protocol.Runtime.EvaluateResponse { + if (error.message.includes('Object reference chain is too long')) + return { result: { type: 'undefined' } }; + if (error.message.includes("Object couldn't be returned by value")) + return { result: { type: 'undefined' } }; + + if ( + error.message.endsWith('Cannot find context with specified id') || + error.message.endsWith('Inspected target navigated or closed') + ) + throw new Error( + 'Execution context was destroyed, most likely because of a navigation.' + ); + throw error; + } + } + + /** + * This method iterates the JavaScript heap and finds all the objects with the + * given prototype. + * @remarks + * @example + * ```js + * // Create a Map object + * await page.evaluate(() => window.map = new Map()); + * // Get a handle to the Map object prototype + * const mapPrototype = await page.evaluateHandle(() => Map.prototype); + * // Query all map instances into an array + * const mapInstances = await page.queryObjects(mapPrototype); + * // Count amount of map objects in heap + * const count = await page.evaluate(maps => maps.length, mapInstances); + * await mapInstances.dispose(); + * await mapPrototype.dispose(); + * ``` + * + * @param prototypeHandle a handle to the object prototype + * + * @returns A handle to an array of objects with the given prototype. + */ + async queryObjects(prototypeHandle: JSHandle): Promise<JSHandle> { + assert(!prototypeHandle._disposed, 'Prototype JSHandle is disposed!'); + assert( + prototypeHandle._remoteObject.objectId, + 'Prototype JSHandle must not be referencing primitive value' + ); + const response = await this._client.send('Runtime.queryObjects', { + prototypeObjectId: prototypeHandle._remoteObject.objectId, + }); + return createJSHandle(this, response.objects); + } + + /** + * @internal + */ + async _adoptBackendNodeId( + backendNodeId: Protocol.DOM.BackendNodeId + ): Promise<ElementHandle> { + const { object } = await this._client.send('DOM.resolveNode', { + backendNodeId: backendNodeId, + executionContextId: this._contextId, + }); + return createJSHandle(this, object) as ElementHandle; + } + + /** + * @internal + */ + async _adoptElementHandle( + elementHandle: ElementHandle + ): Promise<ElementHandle> { + assert( + elementHandle.executionContext() !== this, + 'Cannot adopt handle that already belongs to this execution context' + ); + assert(this._world, 'Cannot adopt handle without DOMWorld'); + const nodeInfo = await this._client.send('DOM.describeNode', { + objectId: elementHandle._remoteObject.objectId, + }); + return this._adoptBackendNodeId(nodeInfo.node.backendNodeId); + } +} diff --git a/remote/test/puppeteer/src/common/FileChooser.ts b/remote/test/puppeteer/src/common/FileChooser.ts new file mode 100644 index 0000000000..3406918824 --- /dev/null +++ b/remote/test/puppeteer/src/common/FileChooser.ts @@ -0,0 +1,85 @@ +/** + * Copyright 2020 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. + */ + +import { ElementHandle } from './JSHandle.js'; +import { Protocol } from 'devtools-protocol'; +import { assert } from './assert.js'; + +/** + * File choosers let you react to the page requesting for a file. + * @remarks + * `FileChooser` objects are returned via the `page.waitForFileChooser` method. + * @example + * An example of using `FileChooser`: + * ```js + * const [fileChooser] = await Promise.all([ + * page.waitForFileChooser(), + * page.click('#upload-file-button'), // some button that triggers file selection + * ]); + * await fileChooser.accept(['/tmp/myfile.pdf']); + * ``` + * **NOTE** In browsers, only one file chooser can be opened at a time. + * All file choosers must be accepted or canceled. Not doing so will prevent + * subsequent file choosers from appearing. + */ +export class FileChooser { + private _element: ElementHandle; + private _multiple: boolean; + private _handled = false; + + /** + * @internal + */ + constructor( + element: ElementHandle, + event: Protocol.Page.FileChooserOpenedEvent + ) { + this._element = element; + this._multiple = event.mode !== 'selectSingle'; + } + + /** + * Whether file chooser allow for {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#attr-multiple | multiple} file selection. + */ + isMultiple(): boolean { + return this._multiple; + } + + /** + * Accept the file chooser request with given paths. + * @param filePaths - If some of the `filePaths` are relative paths, + * then they are resolved relative to the {@link https://nodejs.org/api/process.html#process_process_cwd | current working directory}. + */ + async accept(filePaths: string[]): Promise<void> { + assert( + !this._handled, + 'Cannot accept FileChooser which is already handled!' + ); + this._handled = true; + await this._element.uploadFile(...filePaths); + } + + /** + * Closes the file chooser without selecting any files. + */ + async cancel(): Promise<void> { + assert( + !this._handled, + 'Cannot cancel FileChooser which is already handled!' + ); + this._handled = true; + } +} diff --git a/remote/test/puppeteer/src/common/FrameManager.ts b/remote/test/puppeteer/src/common/FrameManager.ts new file mode 100644 index 0000000000..e1017487d3 --- /dev/null +++ b/remote/test/puppeteer/src/common/FrameManager.ts @@ -0,0 +1,1309 @@ +/** + * 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. + */ + +import { debug } from '../common/Debug.js'; + +import { EventEmitter } from './EventEmitter.js'; +import { assert } from './assert.js'; +import { helper, debugError } from './helper.js'; +import { ExecutionContext, EVALUATION_SCRIPT_URL } from './ExecutionContext.js'; +import { + LifecycleWatcher, + PuppeteerLifeCycleEvent, +} from './LifecycleWatcher.js'; +import { DOMWorld, WaitForSelectorOptions } from './DOMWorld.js'; +import { NetworkManager } from './NetworkManager.js'; +import { TimeoutSettings } from './TimeoutSettings.js'; +import { CDPSession } from './Connection.js'; +import { JSHandle, ElementHandle } from './JSHandle.js'; +import { MouseButton } from './Input.js'; +import { Page } from './Page.js'; +import { HTTPResponse } from './HTTPResponse.js'; +import { Protocol } from 'devtools-protocol'; +import { + SerializableOrJSHandle, + EvaluateHandleFn, + WrapElementHandle, + EvaluateFn, + EvaluateFnReturnType, + UnwrapPromiseLike, +} from './EvalTypes.js'; + +const UTILITY_WORLD_NAME = '__puppeteer_utility_world__'; + +/** + * We use symbols to prevent external parties listening to these events. + * They are internal to Puppeteer. + * + * @internal + */ +export const FrameManagerEmittedEvents = { + FrameAttached: Symbol('FrameManager.FrameAttached'), + FrameNavigated: Symbol('FrameManager.FrameNavigated'), + FrameDetached: Symbol('FrameManager.FrameDetached'), + LifecycleEvent: Symbol('FrameManager.LifecycleEvent'), + FrameNavigatedWithinDocument: Symbol( + 'FrameManager.FrameNavigatedWithinDocument' + ), + ExecutionContextCreated: Symbol('FrameManager.ExecutionContextCreated'), + ExecutionContextDestroyed: Symbol('FrameManager.ExecutionContextDestroyed'), +}; + +/** + * @internal + */ +export class FrameManager extends EventEmitter { + _client: CDPSession; + private _page: Page; + private _networkManager: NetworkManager; + _timeoutSettings: TimeoutSettings; + private _frames = new Map<string, Frame>(); + private _contextIdToContext = new Map<number, ExecutionContext>(); + private _isolatedWorlds = new Set<string>(); + private _mainFrame: Frame; + + constructor( + client: CDPSession, + page: Page, + ignoreHTTPSErrors: boolean, + timeoutSettings: TimeoutSettings + ) { + super(); + this._client = client; + this._page = page; + this._networkManager = new NetworkManager(client, ignoreHTTPSErrors, this); + this._timeoutSettings = timeoutSettings; + this._client.on('Page.frameAttached', (event) => + this._onFrameAttached(event.frameId, event.parentFrameId) + ); + this._client.on('Page.frameNavigated', (event) => + this._onFrameNavigated(event.frame) + ); + this._client.on('Page.navigatedWithinDocument', (event) => + this._onFrameNavigatedWithinDocument(event.frameId, event.url) + ); + this._client.on('Page.frameDetached', (event) => + this._onFrameDetached(event.frameId) + ); + this._client.on('Page.frameStoppedLoading', (event) => + this._onFrameStoppedLoading(event.frameId) + ); + this._client.on('Runtime.executionContextCreated', (event) => + this._onExecutionContextCreated(event.context) + ); + this._client.on('Runtime.executionContextDestroyed', (event) => + this._onExecutionContextDestroyed(event.executionContextId) + ); + this._client.on('Runtime.executionContextsCleared', () => + this._onExecutionContextsCleared() + ); + this._client.on('Page.lifecycleEvent', (event) => + this._onLifecycleEvent(event) + ); + this._client.on('Target.attachedToTarget', async (event) => + this._onFrameMoved(event) + ); + } + + async initialize(): Promise<void> { + const result = await Promise.all([ + this._client.send('Page.enable'), + this._client.send('Page.getFrameTree'), + ]); + + const { frameTree } = result[1]; + this._handleFrameTree(frameTree); + await Promise.all([ + this._client.send('Page.setLifecycleEventsEnabled', { enabled: true }), + this._client + .send('Runtime.enable') + .then(() => this._ensureIsolatedWorld(UTILITY_WORLD_NAME)), + this._networkManager.initialize(), + ]); + } + + networkManager(): NetworkManager { + return this._networkManager; + } + + async navigateFrame( + frame: Frame, + url: string, + options: { + referer?: string; + timeout?: number; + waitUntil?: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[]; + } = {} + ): Promise<HTTPResponse | null> { + assertNoLegacyNavigationOptions(options); + const { + referer = this._networkManager.extraHTTPHeaders()['referer'], + waitUntil = ['load'], + timeout = this._timeoutSettings.navigationTimeout(), + } = options; + + const watcher = new LifecycleWatcher(this, frame, waitUntil, timeout); + let ensureNewDocumentNavigation = false; + let error = await Promise.race([ + navigate(this._client, url, referer, frame._id), + watcher.timeoutOrTerminationPromise(), + ]); + if (!error) { + error = await Promise.race([ + watcher.timeoutOrTerminationPromise(), + ensureNewDocumentNavigation + ? watcher.newDocumentNavigationPromise() + : watcher.sameDocumentNavigationPromise(), + ]); + } + watcher.dispose(); + if (error) throw error; + return watcher.navigationResponse(); + + async function navigate( + client: CDPSession, + url: string, + referrer: string, + frameId: string + ): Promise<Error | null> { + try { + const response = await client.send('Page.navigate', { + url, + referrer, + frameId, + }); + ensureNewDocumentNavigation = !!response.loaderId; + return response.errorText + ? new Error(`${response.errorText} at ${url}`) + : null; + } catch (error) { + return error; + } + } + } + + async waitForFrameNavigation( + frame: Frame, + options: { + timeout?: number; + waitUntil?: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[]; + } = {} + ): Promise<HTTPResponse | null> { + assertNoLegacyNavigationOptions(options); + const { + waitUntil = ['load'], + timeout = this._timeoutSettings.navigationTimeout(), + } = options; + const watcher = new LifecycleWatcher(this, frame, waitUntil, timeout); + const error = await Promise.race([ + watcher.timeoutOrTerminationPromise(), + watcher.sameDocumentNavigationPromise(), + watcher.newDocumentNavigationPromise(), + ]); + watcher.dispose(); + if (error) throw error; + return watcher.navigationResponse(); + } + + private async _onFrameMoved(event: Protocol.Target.AttachedToTargetEvent) { + if (event.targetInfo.type !== 'iframe') { + return; + } + + // TODO(sadym): Remove debug message once proper OOPIF support is + // implemented: https://github.com/puppeteer/puppeteer/issues/2548 + debug('puppeteer:frame')( + `The frame '${event.targetInfo.targetId}' moved to another session. ` + + `Out-of-process iframes (OOPIF) are not supported by Puppeteer yet. ` + + `https://github.com/puppeteer/puppeteer/issues/2548` + ); + } + + _onLifecycleEvent(event: Protocol.Page.LifecycleEventEvent): void { + const frame = this._frames.get(event.frameId); + if (!frame) return; + frame._onLifecycleEvent(event.loaderId, event.name); + this.emit(FrameManagerEmittedEvents.LifecycleEvent, frame); + } + + _onFrameStoppedLoading(frameId: string): void { + const frame = this._frames.get(frameId); + if (!frame) return; + frame._onLoadingStopped(); + this.emit(FrameManagerEmittedEvents.LifecycleEvent, frame); + } + + _handleFrameTree(frameTree: Protocol.Page.FrameTree): void { + if (frameTree.frame.parentId) + this._onFrameAttached(frameTree.frame.id, frameTree.frame.parentId); + this._onFrameNavigated(frameTree.frame); + if (!frameTree.childFrames) return; + + for (const child of frameTree.childFrames) this._handleFrameTree(child); + } + + page(): Page { + return this._page; + } + + mainFrame(): Frame { + return this._mainFrame; + } + + frames(): Frame[] { + return Array.from(this._frames.values()); + } + + frame(frameId: string): Frame | null { + return this._frames.get(frameId) || null; + } + + _onFrameAttached(frameId: string, parentFrameId?: string): void { + if (this._frames.has(frameId)) return; + assert(parentFrameId); + const parentFrame = this._frames.get(parentFrameId); + const frame = new Frame(this, parentFrame, frameId); + this._frames.set(frame._id, frame); + this.emit(FrameManagerEmittedEvents.FrameAttached, frame); + } + + _onFrameNavigated(framePayload: Protocol.Page.Frame): void { + const isMainFrame = !framePayload.parentId; + let frame = isMainFrame + ? this._mainFrame + : this._frames.get(framePayload.id); + assert( + isMainFrame || frame, + 'We either navigate top level or have old version of the navigated frame' + ); + + // Detach all child frames first. + if (frame) { + for (const child of frame.childFrames()) + this._removeFramesRecursively(child); + } + + // Update or create main frame. + if (isMainFrame) { + if (frame) { + // Update frame id to retain frame identity on cross-process navigation. + this._frames.delete(frame._id); + frame._id = framePayload.id; + } else { + // Initial main frame navigation. + frame = new Frame(this, null, framePayload.id); + } + this._frames.set(framePayload.id, frame); + this._mainFrame = frame; + } + + // Update frame payload. + frame._navigated(framePayload); + + this.emit(FrameManagerEmittedEvents.FrameNavigated, frame); + } + + async _ensureIsolatedWorld(name: string): Promise<void> { + if (this._isolatedWorlds.has(name)) return; + this._isolatedWorlds.add(name); + await this._client.send('Page.addScriptToEvaluateOnNewDocument', { + source: `//# sourceURL=${EVALUATION_SCRIPT_URL}`, + worldName: name, + }), + await Promise.all( + this.frames().map((frame) => + this._client + .send('Page.createIsolatedWorld', { + frameId: frame._id, + grantUniveralAccess: true, + worldName: name, + }) + .catch(debugError) + ) + ); // frames might be removed before we send this + } + + _onFrameNavigatedWithinDocument(frameId: string, url: string): void { + const frame = this._frames.get(frameId); + if (!frame) return; + frame._navigatedWithinDocument(url); + this.emit(FrameManagerEmittedEvents.FrameNavigatedWithinDocument, frame); + this.emit(FrameManagerEmittedEvents.FrameNavigated, frame); + } + + _onFrameDetached(frameId: string): void { + const frame = this._frames.get(frameId); + if (frame) this._removeFramesRecursively(frame); + } + + _onExecutionContextCreated( + contextPayload: Protocol.Runtime.ExecutionContextDescription + ): void { + const auxData = contextPayload.auxData as { frameId?: string }; + const frameId = auxData ? auxData.frameId : null; + const frame = this._frames.get(frameId) || null; + let world = null; + if (frame) { + if (contextPayload.auxData && !!contextPayload.auxData['isDefault']) { + world = frame._mainWorld; + } else if ( + contextPayload.name === UTILITY_WORLD_NAME && + !frame._secondaryWorld._hasContext() + ) { + // In case of multiple sessions to the same target, there's a race between + // connections so we might end up creating multiple isolated worlds. + // We can use either. + world = frame._secondaryWorld; + } + } + if (contextPayload.auxData && contextPayload.auxData['type'] === 'isolated') + this._isolatedWorlds.add(contextPayload.name); + const context = new ExecutionContext(this._client, contextPayload, world); + if (world) world._setContext(context); + this._contextIdToContext.set(contextPayload.id, context); + } + + private _onExecutionContextDestroyed(executionContextId: number): void { + const context = this._contextIdToContext.get(executionContextId); + if (!context) return; + this._contextIdToContext.delete(executionContextId); + if (context._world) context._world._setContext(null); + } + + private _onExecutionContextsCleared(): void { + for (const context of this._contextIdToContext.values()) { + if (context._world) context._world._setContext(null); + } + this._contextIdToContext.clear(); + } + + executionContextById(contextId: number): ExecutionContext { + const context = this._contextIdToContext.get(contextId); + assert(context, 'INTERNAL ERROR: missing context with id = ' + contextId); + return context; + } + + private _removeFramesRecursively(frame: Frame): void { + for (const child of frame.childFrames()) + this._removeFramesRecursively(child); + frame._detach(); + this._frames.delete(frame._id); + this.emit(FrameManagerEmittedEvents.FrameDetached, frame); + } +} + +/** + * @public + */ +export interface FrameWaitForFunctionOptions { + /** + * An interval at which the `pageFunction` is executed, defaults to `raf`. If + * `polling` is a number, then it is treated as an interval in milliseconds at + * which the function would be executed. If `polling` is a string, then it can + * be one of the following values: + * + * - `raf` - to constantly execute `pageFunction` in `requestAnimationFrame` + * callback. This is the tightest polling mode which is suitable to observe + * styling changes. + * + * - `mutation` - to execute `pageFunction` on every DOM mutation. + */ + polling?: string | number; + /** + * Maximum time to wait in milliseconds. Defaults to `30000` (30 seconds). + * Pass `0` to disable the timeout. Puppeteer's default timeout can be changed + * using {@link Page.setDefaultTimeout}. + */ + timeout?: number; +} + +/** + * @public + */ +export interface FrameAddScriptTagOptions { + /** + * the URL of the script to be added. + */ + url?: string; + /** + * The path to a JavaScript file to be injected into the frame. + * @remarks + * If `path` is a relative path, it is resolved relative to the current + * working directory (`process.cwd()` in Node.js). + */ + path?: string; + /** + * Raw JavaScript content to be injected into the frame. + */ + content?: string; + /** + * Set the script's `type`. Use `module` in order to load an ES2015 module. + */ + type?: string; +} + +/** + * @public + */ +export interface FrameAddStyleTagOptions { + /** + * the URL of the CSS file to be added. + */ + url?: string; + /** + * The path to a CSS file to be injected into the frame. + * @remarks + * If `path` is a relative path, it is resolved relative to the current + * working directory (`process.cwd()` in Node.js). + */ + path?: string; + /** + * Raw CSS content to be injected into the frame. + */ + content?: string; +} + +/** + * At every point of time, page exposes its current frame tree via the + * {@link Page.mainFrame | page.mainFrame} and + * {@link Frame.childFrames | frame.childFrames} methods. + * + * @remarks + * + * `Frame` object lifecycles are controlled by three events that are all + * dispatched on the page object: + * + * - {@link PageEmittedEvents.FrameAttached} + * + * - {@link PageEmittedEvents.FrameNavigated} + * + * - {@link PageEmittedEvents.FrameDetached} + * + * @Example + * An example of dumping frame tree: + * + * ```js + * const puppeteer = require('puppeteer'); + * + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * await page.goto('https://www.google.com/chrome/browser/canary.html'); + * dumpFrameTree(page.mainFrame(), ''); + * await browser.close(); + * + * function dumpFrameTree(frame, indent) { + * console.log(indent + frame.url()); + * for (const child of frame.childFrames()) { + * dumpFrameTree(child, indent + ' '); + * } + * } + * })(); + * ``` + * + * @Example + * An example of getting text from an iframe element: + * + * ```js + * const frame = page.frames().find(frame => frame.name() === 'myframe'); + * const text = await frame.$eval('.selector', element => element.textContent); + * console.log(text); + * ``` + * + * @public + */ +export class Frame { + /** + * @internal + */ + _frameManager: FrameManager; + private _parentFrame?: Frame; + /** + * @internal + */ + _id: string; + + private _url = ''; + private _detached = false; + /** + * @internal + */ + _loaderId = ''; + /** + * @internal + */ + _name?: string; + + /** + * @internal + */ + _lifecycleEvents = new Set<string>(); + /** + * @internal + */ + _mainWorld: DOMWorld; + /** + * @internal + */ + _secondaryWorld: DOMWorld; + /** + * @internal + */ + _childFrames: Set<Frame>; + + /** + * @internal + */ + constructor( + frameManager: FrameManager, + parentFrame: Frame | null, + frameId: string + ) { + this._frameManager = frameManager; + this._parentFrame = parentFrame; + this._url = ''; + this._id = frameId; + this._detached = false; + + this._loaderId = ''; + this._mainWorld = new DOMWorld( + frameManager, + this, + frameManager._timeoutSettings + ); + this._secondaryWorld = new DOMWorld( + frameManager, + this, + frameManager._timeoutSettings + ); + + this._childFrames = new Set(); + if (this._parentFrame) this._parentFrame._childFrames.add(this); + } + + /** + * @remarks + * + * `frame.goto` will throw an error if: + * - there's an SSL error (e.g. in case of self-signed certificates). + * + * - target URL is invalid. + * + * - the `timeout` is exceeded during navigation. + * + * - the remote server does not respond or is unreachable. + * + * - the main resource failed to load. + * + * `frame.goto` will not throw an error when any valid HTTP status code is + * returned by the remote server, including 404 "Not Found" and 500 "Internal + * Server Error". The status code for such responses can be retrieved by + * calling {@link HTTPResponse.status}. + * + * NOTE: `frame.goto` either throws an error or returns a main resource + * response. The only exceptions are navigation to `about:blank` or + * navigation to the same URL with a different hash, which would succeed and + * return `null`. + * + * NOTE: Headless mode doesn't support navigation to a PDF document. See + * the {@link https://bugs.chromium.org/p/chromium/issues/detail?id=761295 | upstream + * issue}. + * + * @param url - the URL to navigate the frame to. This should include the + * scheme, e.g. `https://`. + * @param options - navigation options. `waitUntil` is useful to define when + * the navigation should be considered successful - see the docs for + * {@link PuppeteerLifeCycleEvent} for more details. + * + * @returns A promise which resolves to the main resource response. In case of + * multiple redirects, the navigation will resolve with the response of the + * last redirect. + */ + async goto( + url: string, + options: { + referer?: string; + timeout?: number; + waitUntil?: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[]; + } = {} + ): Promise<HTTPResponse | null> { + return await this._frameManager.navigateFrame(this, url, options); + } + + /** + * @remarks + * + * This resolves when the frame navigates to a new URL. It is useful for when + * you run code which will indirectly cause the frame to navigate. Consider + * this example: + * + * ```js + * const [response] = await Promise.all([ + * // The navigation promise resolves after navigation has finished + * frame.waitForNavigation(), + * // Clicking the link will indirectly cause a navigation + * frame.click('a.my-link'), + * ]); + * ``` + * + * Usage of the {@link https://developer.mozilla.org/en-US/docs/Web/API/History_API | History API} to change the URL is considered a navigation. + * + * @param options - options to configure when the navigation is consided finished. + * @returns a promise that resolves when the frame navigates to a new URL. + */ + async waitForNavigation( + options: { + timeout?: number; + waitUntil?: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[]; + } = {} + ): Promise<HTTPResponse | null> { + return await this._frameManager.waitForFrameNavigation(this, options); + } + + /** + * @returns a promise that resolves to the frame's default execution context. + */ + executionContext(): Promise<ExecutionContext> { + return this._mainWorld.executionContext(); + } + + /** + * @remarks + * + * The only difference between {@link Frame.evaluate} and + * `frame.evaluateHandle` is that `evaluateHandle` will return the value + * wrapped in an in-page object. + * + * This method behaves identically to {@link Page.evaluateHandle} except it's + * run within the context of the `frame`, rather than the entire page. + * + * @param pageFunction - a function that is run within the frame + * @param args - arguments to be passed to the pageFunction + */ + async evaluateHandle<HandlerType extends JSHandle = JSHandle>( + pageFunction: EvaluateHandleFn, + ...args: SerializableOrJSHandle[] + ): Promise<HandlerType> { + return this._mainWorld.evaluateHandle<HandlerType>(pageFunction, ...args); + } + + /** + * @remarks + * + * This method behaves identically to {@link Page.evaluate} except it's run + * within the context of the `frame`, rather than the entire page. + * + * @param pageFunction - a function that is run within the frame + * @param args - arguments to be passed to the pageFunction + */ + async evaluate<T extends EvaluateFn>( + pageFunction: T, + ...args: SerializableOrJSHandle[] + ): Promise<UnwrapPromiseLike<EvaluateFnReturnType<T>>> { + return this._mainWorld.evaluate<T>(pageFunction, ...args); + } + + /** + * This method queries the frame for the given selector. + * + * @param selector - a selector to query for. + * @returns A promise which resolves to an `ElementHandle` pointing at the + * element, or `null` if it was not found. + */ + async $(selector: string): Promise<ElementHandle | null> { + return this._mainWorld.$(selector); + } + + /** + * This method evaluates the given XPath expression and returns the results. + * + * @param expression - the XPath expression to evaluate. + */ + async $x(expression: string): Promise<ElementHandle[]> { + return this._mainWorld.$x(expression); + } + + /** + * @remarks + * + * This method runs `document.querySelector` within + * the frame and passes it as the first argument to `pageFunction`. + * + * If `pageFunction` returns a Promise, then `frame.$eval` would wait for + * the promise to resolve and return its value. + * + * @example + * + * ```js + * const searchValue = await frame.$eval('#search', el => el.value); + * ``` + * + * @param selector - the selector to query for + * @param pageFunction - the function to be evaluated in the frame's context + * @param args - additional arguments to pass to `pageFuncton` + */ + async $eval<ReturnType>( + selector: string, + pageFunction: ( + element: Element, + ...args: unknown[] + ) => ReturnType | Promise<ReturnType>, + ...args: SerializableOrJSHandle[] + ): Promise<WrapElementHandle<ReturnType>> { + return this._mainWorld.$eval<ReturnType>(selector, pageFunction, ...args); + } + + /** + * @remarks + * + * This method runs `Array.from(document.querySelectorAll(selector))` within + * the frame and passes it as the first argument to `pageFunction`. + * + * If `pageFunction` returns a Promise, then `frame.$$eval` would wait for + * the promise to resolve and return its value. + * + * @example + * + * ```js + * const divsCounts = await frame.$$eval('div', divs => divs.length); + * ``` + * + * @param selector - the selector to query for + * @param pageFunction - the function to be evaluated in the frame's context + * @param args - additional arguments to pass to `pageFuncton` + */ + async $$eval<ReturnType>( + selector: string, + pageFunction: ( + elements: Element[], + ...args: unknown[] + ) => ReturnType | Promise<ReturnType>, + ...args: SerializableOrJSHandle[] + ): Promise<WrapElementHandle<ReturnType>> { + return this._mainWorld.$$eval<ReturnType>(selector, pageFunction, ...args); + } + + /** + * This runs `document.querySelectorAll` in the frame and returns the result. + * + * @param selector - a selector to search for + * @returns An array of element handles pointing to the found frame elements. + */ + async $$(selector: string): Promise<ElementHandle[]> { + return this._mainWorld.$$(selector); + } + + /** + * @returns the full HTML contents of the frame, including the doctype. + */ + async content(): Promise<string> { + return this._secondaryWorld.content(); + } + + /** + * Set the content of the frame. + * + * @param html - HTML markup to assign to the page. + * @param options - options to configure how long before timing out and at + * what point to consider the content setting successful. + */ + async setContent( + html: string, + options: { + timeout?: number; + waitUntil?: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[]; + } = {} + ): Promise<void> { + return this._secondaryWorld.setContent(html, options); + } + + /** + * @remarks + * + * If the name is empty, it returns the `id` attribute instead. + * + * Note: This value is calculated once when the frame is created, and will not + * update if the attribute is changed later. + * + * @returns the frame's `name` attribute as specified in the tag. + */ + name(): string { + return this._name || ''; + } + + /** + * @returns the frame's URL. + */ + url(): string { + return this._url; + } + + /** + * @returns the parent `Frame`, if any. Detached and main frames return `null`. + */ + parentFrame(): Frame | null { + return this._parentFrame; + } + + /** + * @returns an array of child frames. + */ + childFrames(): Frame[] { + return Array.from(this._childFrames); + } + + /** + * @returns `true` if the frame has been detached, or `false` otherwise. + */ + isDetached(): boolean { + return this._detached; + } + + /** + * Adds a `<script>` tag into the page with the desired url or content. + * + * @param options - configure the script to add to the page. + * + * @returns a promise that resolves to the added tag when the script's + * `onload` event fires or when the script content was injected into the + * frame. + */ + async addScriptTag( + options: FrameAddScriptTagOptions + ): Promise<ElementHandle> { + return this._mainWorld.addScriptTag(options); + } + + /** + * Adds a `<link rel="stylesheet">` tag into the page with the desired url or + * a `<style type="text/css">` tag with the content. + * + * @param options - configure the CSS to add to the page. + * + * @returns a promise that resolves to the added tag when the stylesheets's + * `onload` event fires or when the CSS content was injected into the + * frame. + */ + async addStyleTag(options: FrameAddStyleTagOptions): Promise<ElementHandle> { + return this._mainWorld.addStyleTag(options); + } + + /** + * + * This method clicks the first element found that matches `selector`. + * + * @remarks + * + * This method scrolls the element into view if needed, and then uses + * {@link Page.mouse} to click in the center of the element. If there's no + * element matching `selector`, the method throws an error. + * + * Bear in mind that if `click()` triggers a navigation event and there's a + * separate `page.waitForNavigation()` promise to be resolved, you may end up + * with a race condition that yields unexpected results. The correct pattern + * for click and wait for navigation is the following: + * + * ```javascript + * const [response] = await Promise.all([ + * page.waitForNavigation(waitOptions), + * frame.click(selector, clickOptions), + * ]); + * ``` + * @param selector - the selector to search for to click. If there are + * multiple elements, the first will be clicked. + */ + async click( + selector: string, + options: { + delay?: number; + button?: MouseButton; + clickCount?: number; + } = {} + ): Promise<void> { + return this._secondaryWorld.click(selector, options); + } + + /** + * This method fetches an element with `selector` and focuses it. + * + * @remarks + * If there's no element matching `selector`, the method throws an error. + * + * @param selector - the selector for the element to focus. If there are + * multiple elements, the first will be focused. + */ + async focus(selector: string): Promise<void> { + return this._secondaryWorld.focus(selector); + } + + /** + * This method fetches an element with `selector`, scrolls it into view if + * needed, and then uses {@link Page.mouse} to hover over the center of the + * element. + * + * @remarks + * If there's no element matching `selector`, the method throws an + * + * @param selector - the selector for the element to hover. If there are + * multiple elements, the first will be hovered. + */ + async hover(selector: string): Promise<void> { + return this._secondaryWorld.hover(selector); + } + + /** + * Triggers a `change` and `input` event once all the provided options have + * been selected. + * + * @remarks + * + * If there's no `<select>` element matching `selector`, the + * method throws an error. + * + * @example + * ```js + * frame.select('select#colors', 'blue'); // single selection + * frame.select('select#colors', 'red', 'green', 'blue'); // multiple selections + * ``` + * + * @param selector - a selector to query the frame for + * @param values - an array of values to select. If the `<select>` has the + * `multiple` attribute, all values are considered, otherwise only the first + * one is taken into account. + * @returns the list of values that were successfully selected. + */ + select(selector: string, ...values: string[]): Promise<string[]> { + return this._secondaryWorld.select(selector, ...values); + } + + /** + * This method fetches an element with `selector`, scrolls it into view if + * needed, and then uses {@link Page.touchscreen} to tap in the center of the + * element. + * + * @remarks + * + * If there's no element matching `selector`, the method throws an error. + * + * @param selector - the selector to tap. + * @returns a promise that resolves when the element has been tapped. + */ + async tap(selector: string): Promise<void> { + return this._secondaryWorld.tap(selector); + } + + /** + * Sends a `keydown`, `keypress`/`input`, and `keyup` event for each character + * in the text. + * + * @remarks + * To press a special key, like `Control` or `ArrowDown`, use + * {@link Keyboard.press}. + * + * @example + * ```js + * await frame.type('#mytextarea', 'Hello'); // Types instantly + * await frame.type('#mytextarea', 'World', {delay: 100}); // Types slower, like a user + * ``` + * + * @param selector - the selector for the element to type into. If there are + * multiple the first will be used. + * @param text - text to type into the element + * @param options - takes one option, `delay`, which sets the time to wait + * between key presses in milliseconds. Defaults to `0`. + * + * @returns a promise that resolves when the typing is complete. + */ + async type( + selector: string, + text: string, + options?: { delay: number } + ): Promise<void> { + return this._mainWorld.type(selector, text, options); + } + + /** + * @remarks + * + * This method behaves differently depending on the first parameter. If it's a + * `string`, it will be treated as a `selector` or `xpath` (if the string + * starts with `//`). This method then is a shortcut for + * {@link Frame.waitForSelector} or {@link Frame.waitForXPath}. + * + * If the first argument is a function this method is a shortcut for + * {@link Frame.waitForFunction}. + * + * If the first argument is a `number`, it's treated as a timeout in + * milliseconds and the method returns a promise which resolves after the + * timeout. + * + * @param selectorOrFunctionOrTimeout - a selector, predicate or timeout to + * wait for. + * @param options - optional waiting parameters. + * @param args - arguments to pass to `pageFunction`. + * + * @deprecated Don't use this method directly. Instead use the more explicit + * methods available: {@link Frame.waitForSelector}, + * {@link Frame.waitForXPath}, {@link Frame.waitForFunction} or + * {@link Frame.waitForTimeout}. + */ + waitFor( + selectorOrFunctionOrTimeout: string | number | Function, + options: Record<string, unknown> = {}, + ...args: SerializableOrJSHandle[] + ): Promise<JSHandle | null> { + const xPathPattern = '//'; + + console.warn( + 'waitFor is deprecated and will be removed in a future release. See https://github.com/puppeteer/puppeteer/issues/6214 for details and how to migrate your code.' + ); + + if (helper.isString(selectorOrFunctionOrTimeout)) { + const string = selectorOrFunctionOrTimeout; + if (string.startsWith(xPathPattern)) + return this.waitForXPath(string, options); + return this.waitForSelector(string, options); + } + if (helper.isNumber(selectorOrFunctionOrTimeout)) + return new Promise((fulfill) => + setTimeout(fulfill, selectorOrFunctionOrTimeout) + ); + if (typeof selectorOrFunctionOrTimeout === 'function') + return this.waitForFunction( + selectorOrFunctionOrTimeout, + options, + ...args + ); + return Promise.reject( + new Error( + 'Unsupported target type: ' + typeof selectorOrFunctionOrTimeout + ) + ); + } + + /** + * Causes your script to wait for the given number of milliseconds. + * + * @remarks + * It's generally recommended to not wait for a number of seconds, but instead + * use {@link Frame.waitForSelector}, {@link Frame.waitForXPath} or + * {@link Frame.waitForFunction} to wait for exactly the conditions you want. + * + * @example + * + * Wait for 1 second: + * + * ``` + * await frame.waitForTimeout(1000); + * ``` + * + * @param milliseconds - the number of milliseconds to wait. + */ + waitForTimeout(milliseconds: number): Promise<void> { + return new Promise((resolve) => { + setTimeout(resolve, milliseconds); + }); + } + + /** + * @remarks + * + * + * Wait for the `selector` to appear in page. If at the moment of calling the + * method the `selector` already exists, the method will return immediately. + * If the selector doesn't appear after the `timeout` milliseconds of waiting, + * the function will throw. + * + * This method works across navigations. + * + * @example + * ```js + * const puppeteer = require('puppeteer'); + * + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * let currentURL; + * page.mainFrame() + * .waitForSelector('img') + * .then(() => console.log('First URL with image: ' + currentURL)); + * + * for (currentURL of ['https://example.com', 'https://google.com', 'https://bbc.com']) { + * await page.goto(currentURL); + * } + * await browser.close(); + * })(); + * ``` + * @param selector - the selector to wait for. + * @param options - options to define if the element should be visible and how + * long to wait before timing out. + * @returns a promise which resolves when an element matching the selector + * string is added to the DOM. + */ + async waitForSelector( + selector: string, + options: WaitForSelectorOptions = {} + ): Promise<ElementHandle | null> { + const handle = await this._secondaryWorld.waitForSelector( + selector, + options + ); + if (!handle) return null; + const mainExecutionContext = await this._mainWorld.executionContext(); + const result = await mainExecutionContext._adoptElementHandle(handle); + await handle.dispose(); + return result; + } + + /** + * @remarks + * Wait for the `xpath` to appear in page. If at the moment of calling the + * method the `xpath` already exists, the method will return immediately. If + * the xpath doesn't appear after the `timeout` milliseconds of waiting, the + * function will throw. + * + * For a code example, see the example for {@link Frame.waitForSelector}. That + * function behaves identically other than taking a CSS selector rather than + * an XPath. + * + * @param xpath - the XPath expression to wait for. + * @param options - options to configure the visiblity of the element and how + * long to wait before timing out. + */ + async waitForXPath( + xpath: string, + options: WaitForSelectorOptions = {} + ): Promise<ElementHandle | null> { + const handle = await this._secondaryWorld.waitForXPath(xpath, options); + if (!handle) return null; + const mainExecutionContext = await this._mainWorld.executionContext(); + const result = await mainExecutionContext._adoptElementHandle(handle); + await handle.dispose(); + return result; + } + + /** + * @remarks + * + * @example + * + * The `waitForFunction` can be used to observe viewport size change: + * ```js + * const puppeteer = require('puppeteer'); + * + * (async () => { + * . const browser = await puppeteer.launch(); + * . const page = await browser.newPage(); + * . const watchDog = page.mainFrame().waitForFunction('window.innerWidth < 100'); + * . page.setViewport({width: 50, height: 50}); + * . await watchDog; + * . await browser.close(); + * })(); + * ``` + * + * To pass arguments from Node.js to the predicate of `page.waitForFunction` function: + * + * ```js + * const selector = '.foo'; + * await frame.waitForFunction( + * selector => !!document.querySelector(selector), + * {}, // empty options object + * selector + *); + * ``` + * + * @param pageFunction - the function to evaluate in the frame context. + * @param options - options to configure the polling method and timeout. + * @param args - arguments to pass to the `pageFunction`. + * @returns the promise which resolve when the `pageFunction` returns a truthy value. + */ + waitForFunction( + pageFunction: Function | string, + options: FrameWaitForFunctionOptions = {}, + ...args: SerializableOrJSHandle[] + ): Promise<JSHandle> { + return this._mainWorld.waitForFunction(pageFunction, options, ...args); + } + + /** + * @returns the frame's title. + */ + async title(): Promise<string> { + return this._secondaryWorld.title(); + } + + /** + * @internal + */ + _navigated(framePayload: Protocol.Page.Frame): void { + this._name = framePayload.name; + this._url = `${framePayload.url}${framePayload.urlFragment || ''}`; + } + + /** + * @internal + */ + _navigatedWithinDocument(url: string): void { + this._url = url; + } + + /** + * @internal + */ + _onLifecycleEvent(loaderId: string, name: string): void { + if (name === 'init') { + this._loaderId = loaderId; + this._lifecycleEvents.clear(); + } + this._lifecycleEvents.add(name); + } + + /** + * @internal + */ + _onLoadingStopped(): void { + this._lifecycleEvents.add('DOMContentLoaded'); + this._lifecycleEvents.add('load'); + } + + /** + * @internal + */ + _detach(): void { + this._detached = true; + this._mainWorld._detach(); + this._secondaryWorld._detach(); + if (this._parentFrame) this._parentFrame._childFrames.delete(this); + this._parentFrame = null; + } +} + +function assertNoLegacyNavigationOptions(options: { + [optionName: string]: unknown; +}): void { + assert( + options['networkIdleTimeout'] === undefined, + 'ERROR: networkIdleTimeout option is no longer supported.' + ); + assert( + options['networkIdleInflight'] === undefined, + 'ERROR: networkIdleInflight option is no longer supported.' + ); + assert( + options.waitUntil !== 'networkidle', + 'ERROR: "networkidle" option is no longer supported. Use "networkidle2" instead' + ); +} diff --git a/remote/test/puppeteer/src/common/HTTPRequest.ts b/remote/test/puppeteer/src/common/HTTPRequest.ts new file mode 100644 index 0000000000..f068317616 --- /dev/null +++ b/remote/test/puppeteer/src/common/HTTPRequest.ts @@ -0,0 +1,537 @@ +/** + * Copyright 2020 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. + */ +import { CDPSession } from './Connection.js'; +import { Frame } from './FrameManager.js'; +import { HTTPResponse } from './HTTPResponse.js'; +import { assert } from './assert.js'; +import { helper, debugError } from './helper.js'; +import { Protocol } from 'devtools-protocol'; + +/** + * @public + */ +export interface ContinueRequestOverrides { + /** + * If set, the request URL will change. This is not a redirect. + */ + url?: string; + method?: string; + postData?: string; + headers?: Record<string, string>; +} + +/** + * Required response data to fulfill a request with. + * + * @public + */ +export interface ResponseForRequest { + status: number; + headers: Record<string, string>; + contentType: string; + body: string | Buffer; +} + +/** + * + * Represents an HTTP request sent by a page. + * @remarks + * + * Whenever the page sends a request, such as for a network resource, the + * following events are emitted by Puppeteer's `page`: + * + * - `request`: emitted when the request is issued by the page. + * - `requestfinished` - emitted when the response body is downloaded and the + * request is complete. + * + * If request fails at some point, then instead of `requestfinished` event the + * `requestfailed` event is emitted. + * + * All of these events provide an instance of `HTTPRequest` representing the + * request that occurred: + * + * ``` + * page.on('request', request => ...) + * ``` + * + * NOTE: HTTP Error responses, such as 404 or 503, are still successful + * responses from HTTP standpoint, so request will complete with + * `requestfinished` event. + * + * If request gets a 'redirect' response, the request is successfully finished + * with the `requestfinished` event, and a new request is issued to a + * redirected url. + * + * @public + */ +export class HTTPRequest { + /** + * @internal + */ + _requestId: string; + /** + * @internal + */ + _interceptionId: string; + /** + * @internal + */ + _failureText = null; + /** + * @internal + */ + _response: HTTPResponse | null = null; + /** + * @internal + */ + _fromMemoryCache = false; + /** + * @internal + */ + _redirectChain: HTTPRequest[]; + + private _client: CDPSession; + private _isNavigationRequest: boolean; + private _allowInterception: boolean; + private _interceptionHandled = false; + private _url: string; + private _resourceType: string; + + private _method: string; + private _postData?: string; + private _headers: Record<string, string> = {}; + private _frame: Frame; + + /** + * @internal + */ + constructor( + client: CDPSession, + frame: Frame, + interceptionId: string, + allowInterception: boolean, + event: Protocol.Network.RequestWillBeSentEvent, + redirectChain: HTTPRequest[] + ) { + this._client = client; + this._requestId = event.requestId; + this._isNavigationRequest = + event.requestId === event.loaderId && event.type === 'Document'; + this._interceptionId = interceptionId; + this._allowInterception = allowInterception; + this._url = event.request.url; + this._resourceType = event.type.toLowerCase(); + this._method = event.request.method; + this._postData = event.request.postData; + this._frame = frame; + this._redirectChain = redirectChain; + + for (const key of Object.keys(event.request.headers)) + this._headers[key.toLowerCase()] = event.request.headers[key]; + } + + /** + * @returns the URL of the request + */ + url(): string { + return this._url; + } + + /** + * Contains the request's resource type as it was perceived by the rendering + * engine. + * @remarks + * @returns one of the following: `document`, `stylesheet`, `image`, `media`, + * `font`, `script`, `texttrack`, `xhr`, `fetch`, `eventsource`, `websocket`, + * `manifest`, `other`. + */ + resourceType(): string { + // TODO (@jackfranklin): protocol.d.ts has a type for this, but all the + // string values are uppercase. The Puppeteer docs explicitly say the + // potential values are all lower case, and the constructor takes the event + // type and calls toLowerCase() on it, so we can't reuse the type from the + // protocol.d.ts. Why do we lower case? + return this._resourceType; + } + + /** + * @returns the method used (`GET`, `POST`, etc.) + */ + method(): string { + return this._method; + } + + /** + * @returns the request's post body, if any. + */ + postData(): string | undefined { + return this._postData; + } + + /** + * @returns an object with HTTP headers associated with the request. All + * header names are lower-case. + */ + headers(): Record<string, string> { + return this._headers; + } + + /** + * @returns the response for this request, if a response has been received. + */ + response(): HTTPResponse | null { + return this._response; + } + + /** + * @returns the frame that initiated the request. + */ + frame(): Frame | null { + return this._frame; + } + + /** + * @returns true if the request is the driver of the current frame's navigation. + */ + isNavigationRequest(): boolean { + return this._isNavigationRequest; + } + + /** + * @remarks + * + * `redirectChain` is shared between all the requests of the same chain. + * + * For example, if the website `http://example.com` has a single redirect to + * `https://example.com`, then the chain will contain one request: + * + * ```js + * const response = await page.goto('http://example.com'); + * const chain = response.request().redirectChain(); + * console.log(chain.length); // 1 + * console.log(chain[0].url()); // 'http://example.com' + * ``` + * + * If the website `https://google.com` has no redirects, then the chain will be empty: + * + * ```js + * const response = await page.goto('https://google.com'); + * const chain = response.request().redirectChain(); + * console.log(chain.length); // 0 + * ``` + * + * @returns the chain of requests - if a server responds with at least a + * single redirect, this chain will contain all requests that were redirected. + */ + redirectChain(): HTTPRequest[] { + return this._redirectChain.slice(); + } + + /** + * Access information about the request's failure. + * + * @remarks + * + * @example + * + * Example of logging all failed requests: + * + * ```js + * page.on('requestfailed', request => { + * console.log(request.url() + ' ' + request.failure().errorText); + * }); + * ``` + * + * @returns `null` unless the request failed. If the request fails this can + * return an object with `errorText` containing a human-readable error + * message, e.g. `net::ERR_FAILED`. It is not guaranteeded that there will be + * failure text if the request fails. + */ + failure(): { errorText: string } | null { + if (!this._failureText) return null; + return { + errorText: this._failureText, + }; + } + + /** + * Continues request with optional request overrides. + * + * @remarks + * + * To use this, request + * interception should be enabled with {@link Page.setRequestInterception}. + * + * Exception is immediately thrown if the request interception is not enabled. + * + * @example + * ```js + * await page.setRequestInterception(true); + * page.on('request', request => { + * // Override headers + * const headers = Object.assign({}, request.headers(), { + * foo: 'bar', // set "foo" header + * origin: undefined, // remove "origin" header + * }); + * request.continue({headers}); + * }); + * ``` + * + * @param overrides - optional overrides to apply to the request. + */ + async continue(overrides: ContinueRequestOverrides = {}): Promise<void> { + // Request interception is not supported for data: urls. + if (this._url.startsWith('data:')) return; + assert(this._allowInterception, 'Request Interception is not enabled!'); + assert(!this._interceptionHandled, 'Request is already handled!'); + const { url, method, postData, headers } = overrides; + this._interceptionHandled = true; + + const postDataBinaryBase64 = postData + ? Buffer.from(postData).toString('base64') + : undefined; + + await this._client + .send('Fetch.continueRequest', { + requestId: this._interceptionId, + url, + method, + postData: postDataBinaryBase64, + headers: headers ? headersArray(headers) : undefined, + }) + .catch((error) => { + // In certain cases, protocol will return error if the request was + // already canceled or the page was closed. We should tolerate these + // errors. + debugError(error); + }); + } + + /** + * Fulfills a request with the given response. + * + * @remarks + * + * To use this, request + * interception should be enabled with {@link Page.setRequestInterception}. + * + * Exception is immediately thrown if the request interception is not enabled. + * + * @example + * An example of fulfilling all requests with 404 responses: + * ```js + * await page.setRequestInterception(true); + * page.on('request', request => { + * request.respond({ + * status: 404, + * contentType: 'text/plain', + * body: 'Not Found!' + * }); + * }); + * ``` + * + * NOTE: Mocking responses for dataURL requests is not supported. + * Calling `request.respond` for a dataURL request is a noop. + * + * @param response - the response to fulfill the request with. + */ + async respond(response: ResponseForRequest): Promise<void> { + // Mocking responses for dataURL requests is not currently supported. + if (this._url.startsWith('data:')) return; + assert(this._allowInterception, 'Request Interception is not enabled!'); + assert(!this._interceptionHandled, 'Request is already handled!'); + this._interceptionHandled = true; + + const responseBody: Buffer | null = + response.body && helper.isString(response.body) + ? Buffer.from(response.body) + : (response.body as Buffer) || null; + + const responseHeaders: Record<string, string> = {}; + if (response.headers) { + for (const header of Object.keys(response.headers)) + responseHeaders[header.toLowerCase()] = response.headers[header]; + } + if (response.contentType) + responseHeaders['content-type'] = response.contentType; + if (responseBody && !('content-length' in responseHeaders)) + responseHeaders['content-length'] = String( + Buffer.byteLength(responseBody) + ); + + await this._client + .send('Fetch.fulfillRequest', { + requestId: this._interceptionId, + responseCode: response.status || 200, + responsePhrase: STATUS_TEXTS[response.status || 200], + responseHeaders: headersArray(responseHeaders), + body: responseBody ? responseBody.toString('base64') : undefined, + }) + .catch((error) => { + // In certain cases, protocol will return error if the request was + // already canceled or the page was closed. We should tolerate these + // errors. + debugError(error); + }); + } + + /** + * Aborts a request. + * + * @remarks + * To use this, request interception should be enabled with + * {@link Page.setRequestInterception}. If it is not enabled, this method will + * throw an exception immediately. + * + * @param errorCode - optional error code to provide. + */ + async abort(errorCode: ErrorCode = 'failed'): Promise<void> { + // Request interception is not supported for data: urls. + if (this._url.startsWith('data:')) return; + const errorReason = errorReasons[errorCode]; + assert(errorReason, 'Unknown error code: ' + errorCode); + assert(this._allowInterception, 'Request Interception is not enabled!'); + assert(!this._interceptionHandled, 'Request is already handled!'); + this._interceptionHandled = true; + await this._client + .send('Fetch.failRequest', { + requestId: this._interceptionId, + errorReason, + }) + .catch((error) => { + // In certain cases, protocol will return error if the request was + // already canceled or the page was closed. We should tolerate these + // errors. + debugError(error); + }); + } +} + +/** + * @public + */ +export type ErrorCode = + | 'aborted' + | 'accessdenied' + | 'addressunreachable' + | 'blockedbyclient' + | 'blockedbyresponse' + | 'connectionaborted' + | 'connectionclosed' + | 'connectionfailed' + | 'connectionrefused' + | 'connectionreset' + | 'internetdisconnected' + | 'namenotresolved' + | 'timedout' + | 'failed'; + +const errorReasons: Record<ErrorCode, Protocol.Network.ErrorReason> = { + aborted: 'Aborted', + accessdenied: 'AccessDenied', + addressunreachable: 'AddressUnreachable', + blockedbyclient: 'BlockedByClient', + blockedbyresponse: 'BlockedByResponse', + connectionaborted: 'ConnectionAborted', + connectionclosed: 'ConnectionClosed', + connectionfailed: 'ConnectionFailed', + connectionrefused: 'ConnectionRefused', + connectionreset: 'ConnectionReset', + internetdisconnected: 'InternetDisconnected', + namenotresolved: 'NameNotResolved', + timedout: 'TimedOut', + failed: 'Failed', +} as const; + +function headersArray( + headers: Record<string, string> +): Array<{ name: string; value: string }> { + const result = []; + for (const name in headers) { + if (!Object.is(headers[name], undefined)) + result.push({ name, value: headers[name] + '' }); + } + return result; +} + +// List taken from +// https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml +// with extra 306 and 418 codes. +const STATUS_TEXTS = { + '100': 'Continue', + '101': 'Switching Protocols', + '102': 'Processing', + '103': 'Early Hints', + '200': 'OK', + '201': 'Created', + '202': 'Accepted', + '203': 'Non-Authoritative Information', + '204': 'No Content', + '205': 'Reset Content', + '206': 'Partial Content', + '207': 'Multi-Status', + '208': 'Already Reported', + '226': 'IM Used', + '300': 'Multiple Choices', + '301': 'Moved Permanently', + '302': 'Found', + '303': 'See Other', + '304': 'Not Modified', + '305': 'Use Proxy', + '306': 'Switch Proxy', + '307': 'Temporary Redirect', + '308': 'Permanent Redirect', + '400': 'Bad Request', + '401': 'Unauthorized', + '402': 'Payment Required', + '403': 'Forbidden', + '404': 'Not Found', + '405': 'Method Not Allowed', + '406': 'Not Acceptable', + '407': 'Proxy Authentication Required', + '408': 'Request Timeout', + '409': 'Conflict', + '410': 'Gone', + '411': 'Length Required', + '412': 'Precondition Failed', + '413': 'Payload Too Large', + '414': 'URI Too Long', + '415': 'Unsupported Media Type', + '416': 'Range Not Satisfiable', + '417': 'Expectation Failed', + '418': "I'm a teapot", + '421': 'Misdirected Request', + '422': 'Unprocessable Entity', + '423': 'Locked', + '424': 'Failed Dependency', + '425': 'Too Early', + '426': 'Upgrade Required', + '428': 'Precondition Required', + '429': 'Too Many Requests', + '431': 'Request Header Fields Too Large', + '451': 'Unavailable For Legal Reasons', + '500': 'Internal Server Error', + '501': 'Not Implemented', + '502': 'Bad Gateway', + '503': 'Service Unavailable', + '504': 'Gateway Timeout', + '505': 'HTTP Version Not Supported', + '506': 'Variant Also Negotiates', + '507': 'Insufficient Storage', + '508': 'Loop Detected', + '510': 'Not Extended', + '511': 'Network Authentication Required', +} as const; diff --git a/remote/test/puppeteer/src/common/HTTPResponse.ts b/remote/test/puppeteer/src/common/HTTPResponse.ts new file mode 100644 index 0000000000..3df6c62761 --- /dev/null +++ b/remote/test/puppeteer/src/common/HTTPResponse.ts @@ -0,0 +1,213 @@ +/** + * Copyright 2020 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. + */ +import { CDPSession } from './Connection.js'; +import { Frame } from './FrameManager.js'; +import { HTTPRequest } from './HTTPRequest.js'; +import { SecurityDetails } from './SecurityDetails.js'; +import { Protocol } from 'devtools-protocol'; + +/** + * @public + */ +export interface RemoteAddress { + ip: string; + port: number; +} + +/** + * The HTTPResponse class represents responses which are received by the + * {@link Page} class. + * + * @public + */ +export class HTTPResponse { + private _client: CDPSession; + private _request: HTTPRequest; + private _contentPromise: Promise<Buffer> | null = null; + private _bodyLoadedPromise: Promise<Error | void>; + private _bodyLoadedPromiseFulfill: (err: Error | void) => void; + private _remoteAddress: RemoteAddress; + private _status: number; + private _statusText: string; + private _url: string; + private _fromDiskCache: boolean; + private _fromServiceWorker: boolean; + private _headers: Record<string, string> = {}; + private _securityDetails: SecurityDetails | null; + + /** + * @internal + */ + constructor( + client: CDPSession, + request: HTTPRequest, + responsePayload: Protocol.Network.Response + ) { + this._client = client; + this._request = request; + + this._bodyLoadedPromise = new Promise((fulfill) => { + this._bodyLoadedPromiseFulfill = fulfill; + }); + + this._remoteAddress = { + ip: responsePayload.remoteIPAddress, + port: responsePayload.remotePort, + }; + this._status = responsePayload.status; + this._statusText = responsePayload.statusText; + this._url = request.url(); + this._fromDiskCache = !!responsePayload.fromDiskCache; + this._fromServiceWorker = !!responsePayload.fromServiceWorker; + for (const key of Object.keys(responsePayload.headers)) + this._headers[key.toLowerCase()] = responsePayload.headers[key]; + this._securityDetails = responsePayload.securityDetails + ? new SecurityDetails(responsePayload.securityDetails) + : null; + } + + /** + * @internal + */ + _resolveBody(err: Error | null): void { + return this._bodyLoadedPromiseFulfill(err); + } + + /** + * @returns The IP address and port number used to connect to the remote + * server. + */ + remoteAddress(): RemoteAddress { + return this._remoteAddress; + } + + /** + * @returns The URL of the response. + */ + url(): string { + return this._url; + } + + /** + * @returns True if the response was successful (status in the range 200-299). + */ + ok(): boolean { + // TODO: document === 0 case? + return this._status === 0 || (this._status >= 200 && this._status <= 299); + } + + /** + * @returns The status code of the response (e.g., 200 for a success). + */ + status(): number { + return this._status; + } + + /** + * @returns The status text of the response (e.g. usually an "OK" for a + * success). + */ + statusText(): string { + return this._statusText; + } + + /** + * @returns An object with HTTP headers associated with the response. All + * header names are lower-case. + */ + headers(): Record<string, string> { + return this._headers; + } + + /** + * @returns {@link SecurityDetails} if the response was received over the + * secure connection, or `null` otherwise. + */ + securityDetails(): SecurityDetails | null { + return this._securityDetails; + } + + /** + * @returns Promise which resolves to a buffer with response body. + */ + buffer(): Promise<Buffer> { + if (!this._contentPromise) { + this._contentPromise = this._bodyLoadedPromise.then(async (error) => { + if (error) throw error; + const response = await this._client.send('Network.getResponseBody', { + requestId: this._request._requestId, + }); + return Buffer.from( + response.body, + response.base64Encoded ? 'base64' : 'utf8' + ); + }); + } + return this._contentPromise; + } + + /** + * @returns Promise which resolves to a text representation of response body. + */ + async text(): Promise<string> { + const content = await this.buffer(); + return content.toString('utf8'); + } + + /** + * + * @returns Promise which resolves to a JSON representation of response body. + * + * @remarks + * + * This method will throw if the response body is not parsable via + * `JSON.parse`. + */ + async json(): Promise<any> { + const content = await this.text(); + return JSON.parse(content); + } + + /** + * @returns A matching {@link HTTPRequest} object. + */ + request(): HTTPRequest { + return this._request; + } + + /** + * @returns True if the response was served from either the browser's disk + * cache or memory cache. + */ + fromCache(): boolean { + return this._fromDiskCache || this._request._fromMemoryCache; + } + + /** + * @returns True if the response was served by a service worker. + */ + fromServiceWorker(): boolean { + return this._fromServiceWorker; + } + + /** + * @returns A {@link Frame} that initiated this response, or `null` if + * navigating to error pages. + */ + frame(): Frame | null { + return this._request.frame(); + } +} diff --git a/remote/test/puppeteer/src/common/Input.ts b/remote/test/puppeteer/src/common/Input.ts new file mode 100644 index 0000000000..3123706256 --- /dev/null +++ b/remote/test/puppeteer/src/common/Input.ts @@ -0,0 +1,525 @@ +/** + * 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. + */ + +import { assert } from './assert.js'; +import { CDPSession } from './Connection.js'; +import { keyDefinitions, KeyDefinition, KeyInput } from './USKeyboardLayout.js'; + +type KeyDescription = Required< + Pick<KeyDefinition, 'keyCode' | 'key' | 'text' | 'code' | 'location'> +>; + +/** + * Keyboard provides an api for managing a virtual keyboard. + * The high level api is {@link Keyboard."type"}, + * which takes raw characters and generates proper keydown, keypress/input, + * and keyup events on your page. + * + * @remarks + * For finer control, you can use {@link Keyboard.down}, + * {@link Keyboard.up}, and {@link Keyboard.sendCharacter} + * to manually fire events as if they were generated from a real keyboard. + * + * On MacOS, keyboard shortcuts like `⌘ A` -\> Select All do not work. + * See {@link https://github.com/puppeteer/puppeteer/issues/1313 | #1313}. + * + * @example + * An example of holding down `Shift` in order to select and delete some text: + * ```js + * await page.keyboard.type('Hello World!'); + * await page.keyboard.press('ArrowLeft'); + * + * await page.keyboard.down('Shift'); + * for (let i = 0; i < ' World'.length; i++) + * await page.keyboard.press('ArrowLeft'); + * await page.keyboard.up('Shift'); + * + * await page.keyboard.press('Backspace'); + * // Result text will end up saying 'Hello!' + * ``` + * + * @example + * An example of pressing `A` + * ```js + * await page.keyboard.down('Shift'); + * await page.keyboard.press('KeyA'); + * await page.keyboard.up('Shift'); + * ``` + * + * @public + */ +export class Keyboard { + private _client: CDPSession; + /** @internal */ + _modifiers = 0; + private _pressedKeys = new Set<string>(); + + /** @internal */ + constructor(client: CDPSession) { + this._client = client; + } + + /** + * Dispatches a `keydown` event. + * + * @remarks + * If `key` is a single character and no modifier keys besides `Shift` + * are being held down, a `keypress`/`input` event will also generated. + * The `text` option can be specified to force an input event to be generated. + * If `key` is a modifier key, `Shift`, `Meta`, `Control`, or `Alt`, + * subsequent key presses will be sent with that modifier active. + * To release the modifier key, use {@link Keyboard.up}. + * + * After the key is pressed once, subsequent calls to + * {@link Keyboard.down} will have + * {@link https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/repeat | repeat} + * set to true. To release the key, use {@link Keyboard.up}. + * + * Modifier keys DO influence {@link Keyboard.down}. + * Holding down `Shift` will type the text in upper case. + * + * @param key - Name of key to press, such as `ArrowLeft`. + * See {@link KeyInput} for a list of all key names. + * + * @param options - An object of options. Accepts text which, if specified, + * generates an input event with this text. + */ + async down( + key: KeyInput, + options: { text?: string } = { text: undefined } + ): Promise<void> { + const description = this._keyDescriptionForString(key); + + const autoRepeat = this._pressedKeys.has(description.code); + this._pressedKeys.add(description.code); + this._modifiers |= this._modifierBit(description.key); + + const text = options.text === undefined ? description.text : options.text; + await this._client.send('Input.dispatchKeyEvent', { + type: text ? 'keyDown' : 'rawKeyDown', + modifiers: this._modifiers, + windowsVirtualKeyCode: description.keyCode, + code: description.code, + key: description.key, + text: text, + unmodifiedText: text, + autoRepeat, + location: description.location, + isKeypad: description.location === 3, + }); + } + + private _modifierBit(key: string): number { + if (key === 'Alt') return 1; + if (key === 'Control') return 2; + if (key === 'Meta') return 4; + if (key === 'Shift') return 8; + return 0; + } + + private _keyDescriptionForString(keyString: KeyInput): KeyDescription { + const shift = this._modifiers & 8; + const description = { + key: '', + keyCode: 0, + code: '', + text: '', + location: 0, + }; + + const definition = keyDefinitions[keyString]; + assert(definition, `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 = ''; + + return description; + } + + /** + * Dispatches a `keyup` event. + * + * @param key - Name of key to release, such as `ArrowLeft`. + * See {@link KeyInput | KeyInput} + * for a list of all key names. + */ + async up(key: KeyInput): Promise<void> { + const description = this._keyDescriptionForString(key); + + this._modifiers &= ~this._modifierBit(description.key); + this._pressedKeys.delete(description.code); + await this._client.send('Input.dispatchKeyEvent', { + type: 'keyUp', + modifiers: this._modifiers, + key: description.key, + windowsVirtualKeyCode: description.keyCode, + code: description.code, + location: description.location, + }); + } + + /** + * Dispatches a `keypress` and `input` event. + * This does not send a `keydown` or `keyup` event. + * + * @remarks + * Modifier keys DO NOT effect {@link Keyboard.sendCharacter | Keyboard.sendCharacter}. + * Holding down `Shift` will not type the text in upper case. + * + * @example + * ```js + * page.keyboard.sendCharacter('嗨'); + * ``` + * + * @param char - Character to send into the page. + */ + async sendCharacter(char: string): Promise<void> { + await this._client.send('Input.insertText', { text: char }); + } + + private charIsKey(char: string): char is KeyInput { + return !!keyDefinitions[char]; + } + + /** + * Sends a `keydown`, `keypress`/`input`, + * and `keyup` event for each character in the text. + * + * @remarks + * To press a special key, like `Control` or `ArrowDown`, + * use {@link Keyboard.press}. + * + * Modifier keys DO NOT effect `keyboard.type`. + * Holding down `Shift` will not type the text in upper case. + * + * @example + * ```js + * await page.keyboard.type('Hello'); // Types instantly + * await page.keyboard.type('World', {delay: 100}); // Types slower, like a user + * ``` + * + * @param text - A text to type into a focused element. + * @param options - An object of options. Accepts delay which, + * if specified, is the time to wait between `keydown` and `keyup` in milliseconds. + * Defaults to 0. + */ + async type(text: string, options: { delay?: number } = {}): Promise<void> { + const delay = options.delay || null; + for (const char of text) { + if (this.charIsKey(char)) { + await this.press(char, { delay }); + } else { + if (delay) await new Promise((f) => setTimeout(f, delay)); + await this.sendCharacter(char); + } + } + } + + /** + * Shortcut for {@link Keyboard.down} + * and {@link Keyboard.up}. + * + * @remarks + * If `key` is a single character and no modifier keys besides `Shift` + * are being held down, a `keypress`/`input` event will also generated. + * The `text` option can be specified to force an input event to be generated. + * + * Modifier keys DO effect {@link Keyboard.press}. + * Holding down `Shift` will type the text in upper case. + * + * @param key - Name of key to press, such as `ArrowLeft`. + * See {@link KeyInput} for a list of all key names. + * + * @param options - An object of options. Accepts text which, if specified, + * generates an input event with this text. Accepts delay which, + * if specified, is the time to wait between `keydown` and `keyup` in milliseconds. + * Defaults to 0. + */ + async press( + key: KeyInput, + options: { delay?: number; text?: string } = {} + ): Promise<void> { + const { delay = null } = options; + await this.down(key, options); + if (delay) await new Promise((f) => setTimeout(f, options.delay)); + await this.up(key); + } +} + +/** + * @public + */ +export type MouseButton = 'left' | 'right' | 'middle'; + +/** + * @public + */ +export interface MouseOptions { + button?: MouseButton; + clickCount?: number; +} + +/** + * @public + */ +export interface MouseWheelOptions { + deltaX?: number; + deltaY?: number; +} + +/** + * The Mouse class operates in main-frame CSS pixels + * relative to the top-left corner of the viewport. + * @remarks + * Every `page` object has its own Mouse, accessible with [`page.mouse`](#pagemouse). + * + * @example + * ```js + * // Using ‘page.mouse’ to trace a 100x100 square. + * await page.mouse.move(0, 0); + * await page.mouse.down(); + * await page.mouse.move(0, 100); + * await page.mouse.move(100, 100); + * await page.mouse.move(100, 0); + * await page.mouse.move(0, 0); + * await page.mouse.up(); + * ``` + * + * **Note**: The mouse events trigger synthetic `MouseEvent`s. + * This means that it does not fully replicate the functionality of what a normal user + * would be able to do with their mouse. + * + * For example, dragging and selecting text is not possible using `page.mouse`. + * Instead, you can use the {@link https://developer.mozilla.org/en-US/docs/Web/API/DocumentOrShadowRoot/getSelection | `DocumentOrShadowRoot.getSelection()`} functionality implemented in the platform. + * + * @example + * For example, if you want to select all content between nodes: + * ```js + * await page.evaluate((from, to) => { + * const selection = from.getRootNode().getSelection(); + * const range = document.createRange(); + * range.setStartBefore(from); + * range.setEndAfter(to); + * selection.removeAllRanges(); + * selection.addRange(range); + * }, fromJSHandle, toJSHandle); + * ``` + * If you then would want to copy-paste your selection, you can use the clipboard api: + * ```js + * // The clipboard api does not allow you to copy, unless the tab is focused. + * await page.bringToFront(); + * await page.evaluate(() => { + * // Copy the selected content to the clipboard + * document.execCommand('copy'); + * // Obtain the content of the clipboard as a string + * return navigator.clipboard.readText(); + * }); + * ``` + * **Note**: If you want access to the clipboard API, + * you have to give it permission to do so: + * ```js + * await browser.defaultBrowserContext().overridePermissions( + * '<your origin>', ['clipboard-read', 'clipboard-write'] + * ); + * ``` + * @public + */ +export class Mouse { + private _client: CDPSession; + private _keyboard: Keyboard; + private _x = 0; + private _y = 0; + private _button: MouseButton | 'none' = 'none'; + + /** + * @internal + */ + constructor(client: CDPSession, keyboard: Keyboard) { + this._client = client; + this._keyboard = keyboard; + } + + /** + * Dispatches a `mousemove` event. + * @param x - Horizontal position of the mouse. + * @param y - Vertical position of the mouse. + * @param options - Optional object. If specified, the `steps` property + * sends intermediate `mousemove` events when set to `1` (default). + */ + async move( + x: number, + y: number, + options: { steps?: number } = {} + ): Promise<void> { + 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('Input.dispatchMouseEvent', { + type: 'mouseMoved', + button: this._button, + x: fromX + (this._x - fromX) * (i / steps), + y: fromY + (this._y - fromY) * (i / steps), + modifiers: this._keyboard._modifiers, + }); + } + } + + /** + * Shortcut for `mouse.move`, `mouse.down` and `mouse.up`. + * @param x - Horizontal position of the mouse. + * @param y - Vertical position of the mouse. + * @param options - Optional `MouseOptions`. + */ + async click( + x: number, + y: number, + options: MouseOptions & { delay?: number } = {} + ): Promise<void> { + const { delay = null } = options; + if (delay !== null) { + await Promise.all([this.move(x, y), this.down(options)]); + await new Promise((f) => setTimeout(f, delay)); + await this.up(options); + } else { + await Promise.all([ + this.move(x, y), + this.down(options), + this.up(options), + ]); + } + } + + /** + * Dispatches a `mousedown` event. + * @param options - Optional `MouseOptions`. + */ + async down(options: MouseOptions = {}): Promise<void> { + const { button = 'left', clickCount = 1 } = options; + this._button = button; + await this._client.send('Input.dispatchMouseEvent', { + type: 'mousePressed', + button, + x: this._x, + y: this._y, + modifiers: this._keyboard._modifiers, + clickCount, + }); + } + + /** + * Dispatches a `mouseup` event. + * @param options - Optional `MouseOptions`. + */ + async up(options: MouseOptions = {}): Promise<void> { + const { button = 'left', clickCount = 1 } = options; + this._button = 'none'; + await this._client.send('Input.dispatchMouseEvent', { + type: 'mouseReleased', + button, + x: this._x, + y: this._y, + modifiers: this._keyboard._modifiers, + clickCount, + }); + } + + /** + * Dispatches a `mousewheel` event. + * @param options - Optional: `MouseWheelOptions`. + * + * @example + * An example of zooming into an element: + * ```js + * await page.goto('https://mdn.mozillademos.org/en-US/docs/Web/API/Element/wheel_event$samples/Scaling_an_element_via_the_wheel?revision=1587366'); + * + * const elem = await page.$('div'); + * const boundingBox = await elem.boundingBox(); + * await page.mouse.move( + * boundingBox.x + boundingBox.width / 2, + * boundingBox.y + boundingBox.height / 2 + * ); + * + * await page.mouse.wheel({ deltaY: -100 }) + * ``` + */ + async wheel(options: MouseWheelOptions = {}): Promise<void> { + const { deltaX = 0, deltaY = 0 } = options; + await this._client.send('Input.dispatchMouseEvent', { + type: 'mouseWheel', + x: this._x, + y: this._y, + deltaX, + deltaY, + modifiers: this._keyboard._modifiers, + pointerType: 'mouse', + }); + } +} + +/** + * The Touchscreen class exposes touchscreen events. + * @public + */ +export class Touchscreen { + private _client: CDPSession; + private _keyboard: Keyboard; + + /** + * @internal + */ + constructor(client: CDPSession, keyboard: Keyboard) { + this._client = client; + this._keyboard = keyboard; + } + + /** + * Dispatches a `touchstart` and `touchend` event. + * @param x - Horizontal position of the tap. + * @param y - Vertical position of the tap. + */ + async tap(x: number, y: number): Promise<void> { + const touchPoints = [{ x: Math.round(x), y: Math.round(y) }]; + await this._client.send('Input.dispatchTouchEvent', { + type: 'touchStart', + touchPoints, + modifiers: this._keyboard._modifiers, + }); + await this._client.send('Input.dispatchTouchEvent', { + type: 'touchEnd', + touchPoints: [], + modifiers: this._keyboard._modifiers, + }); + } +} diff --git a/remote/test/puppeteer/src/common/JSHandle.ts b/remote/test/puppeteer/src/common/JSHandle.ts new file mode 100644 index 0000000000..c0ee4070b9 --- /dev/null +++ b/remote/test/puppeteer/src/common/JSHandle.ts @@ -0,0 +1,982 @@ +/** + * Copyright 2019 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. + */ + +import { assert } from './assert.js'; +import { helper, debugError } from './helper.js'; +import { ExecutionContext } from './ExecutionContext.js'; +import { Page } from './Page.js'; +import { CDPSession } from './Connection.js'; +import { KeyInput } from './USKeyboardLayout.js'; +import { FrameManager, Frame } from './FrameManager.js'; +import { getQueryHandlerAndSelector } from './QueryHandler.js'; +import { Protocol } from 'devtools-protocol'; +import { + EvaluateFn, + SerializableOrJSHandle, + EvaluateFnReturnType, + EvaluateHandleFn, + WrapElementHandle, + UnwrapPromiseLike, +} from './EvalTypes.js'; +import { isNode } from '../environment.js'; + +export interface BoxModel { + content: Array<{ x: number; y: number }>; + padding: Array<{ x: number; y: number }>; + border: Array<{ x: number; y: number }>; + margin: Array<{ x: number; y: number }>; + width: number; + height: number; +} + +/** + * @public + */ +export interface BoundingBox { + /** + * the x coordinate of the element in pixels. + */ + x: number; + /** + * the y coordinate of the element in pixels. + */ + y: number; + /** + * the width of the element in pixels. + */ + width: number; + /** + * the height of the element in pixels. + */ + height: number; +} + +/** + * @internal + */ +export function createJSHandle( + context: ExecutionContext, + remoteObject: Protocol.Runtime.RemoteObject +): JSHandle { + const frame = context.frame(); + if (remoteObject.subtype === 'node' && frame) { + const frameManager = frame._frameManager; + return new ElementHandle( + context, + context._client, + remoteObject, + frameManager.page(), + frameManager + ); + } + return new JSHandle(context, context._client, remoteObject); +} + +/** + * Represents an in-page JavaScript object. JSHandles can be created with the + * {@link Page.evaluateHandle | page.evaluateHandle} method. + * + * @example + * ```js + * const windowHandle = await page.evaluateHandle(() => window); + * ``` + * + * JSHandle prevents the referenced JavaScript object from being garbage-collected + * unless the handle is {@link JSHandle.dispose | disposed}. JSHandles are auto- + * disposed when their origin frame gets navigated or the parent context gets destroyed. + * + * JSHandle instances can be used as arguments for {@link Page.$eval}, + * {@link Page.evaluate}, and {@link Page.evaluateHandle}. + * + * @public + */ +export class JSHandle { + /** + * @internal + */ + _context: ExecutionContext; + /** + * @internal + */ + _client: CDPSession; + /** + * @internal + */ + _remoteObject: Protocol.Runtime.RemoteObject; + /** + * @internal + */ + _disposed = false; + + /** + * @internal + */ + constructor( + context: ExecutionContext, + client: CDPSession, + remoteObject: Protocol.Runtime.RemoteObject + ) { + this._context = context; + this._client = client; + this._remoteObject = remoteObject; + } + + /** Returns the execution context the handle belongs to. + */ + executionContext(): ExecutionContext { + return this._context; + } + + /** + * This method passes this handle as the first argument to `pageFunction`. + * If `pageFunction` returns a Promise, then `handle.evaluate` would wait + * for the promise to resolve and return its value. + * + * @example + * ```js + * const tweetHandle = await page.$('.tweet .retweets'); + * expect(await tweetHandle.evaluate(node => node.innerText)).toBe('10'); + * ``` + */ + + async evaluate<T extends EvaluateFn>( + pageFunction: T | string, + ...args: SerializableOrJSHandle[] + ): Promise<UnwrapPromiseLike<EvaluateFnReturnType<T>>> { + return await this.executionContext().evaluate< + UnwrapPromiseLike<EvaluateFnReturnType<T>> + >(pageFunction, this, ...args); + } + + /** + * This method passes this handle as the first argument to `pageFunction`. + * + * @remarks + * + * The only difference between `jsHandle.evaluate` and + * `jsHandle.evaluateHandle` is that `jsHandle.evaluateHandle` + * returns an in-page object (JSHandle). + * + * If the function passed to `jsHandle.evaluateHandle` returns a Promise, + * then `evaluateHandle.evaluateHandle` waits for the promise to resolve and + * returns its value. + * + * See {@link Page.evaluateHandle} for more details. + */ + async evaluateHandle<HandleType extends JSHandle = JSHandle>( + pageFunction: EvaluateHandleFn, + ...args: SerializableOrJSHandle[] + ): Promise<HandleType> { + return await this.executionContext().evaluateHandle( + pageFunction, + this, + ...args + ); + } + + /** Fetches a single property from the referenced object. + */ + async getProperty(propertyName: string): Promise<JSHandle | undefined> { + const objectHandle = await this.evaluateHandle( + (object: HTMLElement, propertyName: string) => { + const result = { __proto__: null }; + result[propertyName] = object[propertyName]; + return result; + }, + propertyName + ); + const properties = await objectHandle.getProperties(); + const result = properties.get(propertyName) || null; + await objectHandle.dispose(); + return result; + } + + /** + * The method returns a map with property names as keys and JSHandle + * instances for the property values. + * + * @example + * ```js + * const listHandle = await page.evaluateHandle(() => document.body.children); + * const properties = await listHandle.getProperties(); + * const children = []; + * for (const property of properties.values()) { + * const element = property.asElement(); + * if (element) + * children.push(element); + * } + * children; // holds elementHandles to all children of document.body + * ``` + */ + async getProperties(): Promise<Map<string, JSHandle>> { + const response = await this._client.send('Runtime.getProperties', { + objectId: this._remoteObject.objectId, + ownProperties: true, + }); + const result = new Map<string, JSHandle>(); + for (const property of response.result) { + if (!property.enumerable) continue; + result.set(property.name, createJSHandle(this._context, property.value)); + } + return result; + } + + /** + * Returns a JSON representation of the object. + * + * @remarks + * + * The JSON is generated by running {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify | JSON.stringify} + * on the object in page and consequent {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse | JSON.parse} in puppeteer. + * **NOTE** The method throws if the referenced object is not stringifiable. + */ + async jsonValue(): Promise<Record<string, unknown>> { + if (this._remoteObject.objectId) { + const response = await this._client.send('Runtime.callFunctionOn', { + functionDeclaration: 'function() { return this; }', + objectId: this._remoteObject.objectId, + returnByValue: true, + awaitPromise: true, + }); + return helper.valueFromRemoteObject(response.result); + } + return helper.valueFromRemoteObject(this._remoteObject); + } + + /** + * Returns either `null` or the object handle itself, if the object handle is + * an instance of {@link ElementHandle}. + */ + asElement(): ElementHandle | null { + // This always returns null, but subclasses can override this and return an + // ElementHandle. + return null; + } + + /** + * Stops referencing the element handle, and resolves when the object handle is + * successfully disposed of. + */ + async dispose(): Promise<void> { + if (this._disposed) return; + this._disposed = true; + await helper.releaseObject(this._client, this._remoteObject); + } + + /** + * Returns a string representation of the JSHandle. + * + * @remarks Useful during debugging. + */ + toString(): string { + if (this._remoteObject.objectId) { + const type = this._remoteObject.subtype || this._remoteObject.type; + return 'JSHandle@' + type; + } + return 'JSHandle:' + helper.valueFromRemoteObject(this._remoteObject); + } +} + +/** + * ElementHandle represents an in-page DOM element. + * + * @remarks + * + * ElementHandles can be created with the {@link Page.$} method. + * + * ```js + * const puppeteer = require('puppeteer'); + * + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * await page.goto('https://example.com'); + * const hrefElement = await page.$('a'); + * await hrefElement.click(); + * // ... + * })(); + * ``` + * + * ElementHandle prevents the DOM element from being garbage-collected unless the + * handle is {@link JSHandle.dispose | disposed}. ElementHandles are auto-disposed + * when their origin frame gets navigated. + * + * ElementHandle instances can be used as arguments in {@link Page.$eval} and + * {@link Page.evaluate} methods. + * + * If you're using TypeScript, ElementHandle takes a generic argument that + * denotes the type of element the handle is holding within. For example, if you + * have a handle to a `<select>` element, you can type it as + * `ElementHandle<HTMLSelectElement>` and you get some nicer type checks. + * + * @public + */ +export class ElementHandle< + ElementType extends Element = Element +> extends JSHandle { + private _page: Page; + private _frameManager: FrameManager; + + /** + * @internal + */ + constructor( + context: ExecutionContext, + client: CDPSession, + remoteObject: Protocol.Runtime.RemoteObject, + page: Page, + frameManager: FrameManager + ) { + super(context, client, remoteObject); + this._client = client; + this._remoteObject = remoteObject; + this._page = page; + this._frameManager = frameManager; + } + + asElement(): ElementHandle<ElementType> | null { + return this; + } + + /** + * Resolves to the content frame for element handles referencing + * iframe nodes, or null otherwise + */ + async contentFrame(): Promise<Frame | null> { + const nodeInfo = await this._client.send('DOM.describeNode', { + objectId: this._remoteObject.objectId, + }); + if (typeof nodeInfo.node.frameId !== 'string') return null; + return this._frameManager.frame(nodeInfo.node.frameId); + } + + private async _scrollIntoViewIfNeeded(): Promise<void> { + const error = await this.evaluate< + ( + element: Element, + pageJavascriptEnabled: boolean + ) => Promise<string | false> + >(async (element, pageJavascriptEnabled) => { + if (!element.isConnected) return 'Node is detached from document'; + if (element.nodeType !== Node.ELEMENT_NODE) + return 'Node is not of type HTMLElement'; + // force-scroll if page's javascript is disabled. + if (!pageJavascriptEnabled) { + element.scrollIntoView({ + block: 'center', + inline: 'center', + // @ts-expect-error Chrome still supports behavior: instant but + // it's not in the spec so TS shouts We don't want to make this + // breaking change in Puppeteer yet so we'll ignore the line. + behavior: 'instant', + }); + return false; + } + const visibleRatio = await new Promise((resolve) => { + const observer = new IntersectionObserver((entries) => { + resolve(entries[0].intersectionRatio); + observer.disconnect(); + }); + observer.observe(element); + }); + if (visibleRatio !== 1.0) { + element.scrollIntoView({ + block: 'center', + inline: 'center', + // @ts-expect-error Chrome still supports behavior: instant but + // it's not in the spec so TS shouts We don't want to make this + // breaking change in Puppeteer yet so we'll ignore the line. + behavior: 'instant', + }); + } + return false; + }, this._page.isJavaScriptEnabled()); + + if (error) throw new Error(error); + } + + private async _clickablePoint(): Promise<{ x: number; y: number }> { + const [result, layoutMetrics] = await Promise.all([ + this._client + .send('DOM.getContentQuads', { + objectId: this._remoteObject.objectId, + }) + .catch(debugError), + this._client.send('Page.getLayoutMetrics'), + ]); + if (!result || !result.quads.length) + throw new Error('Node is either not visible or not an HTMLElement'); + // Filter out quads that have too small area to click into. + const { clientWidth, clientHeight } = layoutMetrics.layoutViewport; + const quads = result.quads + .map((quad) => this._fromProtocolQuad(quad)) + .map((quad) => + this._intersectQuadWithViewport(quad, clientWidth, clientHeight) + ) + .filter((quad) => computeQuadArea(quad) > 1); + if (!quads.length) + throw new Error('Node is either not visible or not an HTMLElement'); + // Return the middle point of the first quad. + const quad = quads[0]; + let x = 0; + let y = 0; + for (const point of quad) { + x += point.x; + y += point.y; + } + return { + x: x / 4, + y: y / 4, + }; + } + + private _getBoxModel(): Promise<void | Protocol.DOM.GetBoxModelResponse> { + const params: Protocol.DOM.GetBoxModelRequest = { + objectId: this._remoteObject.objectId, + }; + return this._client + .send('DOM.getBoxModel', params) + .catch((error) => debugError(error)); + } + + private _fromProtocolQuad(quad: number[]): Array<{ x: number; y: number }> { + return [ + { x: quad[0], y: quad[1] }, + { x: quad[2], y: quad[3] }, + { x: quad[4], y: quad[5] }, + { x: quad[6], y: quad[7] }, + ]; + } + + private _intersectQuadWithViewport( + quad: Array<{ x: number; y: number }>, + width: number, + height: number + ): Array<{ x: number; y: number }> { + return quad.map((point) => ({ + x: Math.min(Math.max(point.x, 0), width), + y: Math.min(Math.max(point.y, 0), height), + })); + } + + /** + * This method scrolls element into view if needed, and then + * uses {@link Page.mouse} to hover over the center of the element. + * If the element is detached from DOM, the method throws an error. + */ + async hover(): Promise<void> { + await this._scrollIntoViewIfNeeded(); + const { x, y } = await this._clickablePoint(); + await this._page.mouse.move(x, y); + } + + /** + * This method scrolls element into view if needed, and then + * uses {@link Page.mouse} to click in the center of the element. + * If the element is detached from DOM, the method throws an error. + */ + async click(options: ClickOptions = {}): Promise<void> { + await this._scrollIntoViewIfNeeded(); + const { x, y } = await this._clickablePoint(); + await this._page.mouse.click(x, y, options); + } + + /** + * Triggers a `change` and `input` event once all the provided options have been + * selected. If there's no `<select>` element matching `selector`, the method + * throws an error. + * + * @example + * ```js + * handle.select('blue'); // single selection + * handle.select('red', 'green', 'blue'); // multiple selections + * ``` + * @param values - Values of options to select. If the `<select>` has the + * `multiple` attribute, all values are considered, otherwise only the first + * one is taken into account. + */ + async select(...values: string[]): Promise<string[]> { + for (const value of values) + assert( + helper.isString(value), + 'Values must be strings. Found value "' + + value + + '" of type "' + + typeof value + + '"' + ); + + return this.evaluate< + (element: HTMLSelectElement, values: string[]) => string[] + >((element, values) => { + if (element.nodeName.toLowerCase() !== 'select') + throw new Error('Element is not a <select> element.'); + + const options = Array.from(element.options); + element.value = undefined; + for (const option of options) { + option.selected = values.includes(option.value); + if (option.selected && !element.multiple) break; + } + element.dispatchEvent(new Event('input', { bubbles: true })); + element.dispatchEvent(new Event('change', { bubbles: true })); + return options + .filter((option) => option.selected) + .map((option) => option.value); + }, values); + } + + /** + * This method expects `elementHandle` to point to an + * {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input | input element}. + * @param filePaths - Sets the value of the file input to these paths. + * If some of the `filePaths` are relative paths, then they are resolved + * relative to the {@link https://nodejs.org/api/process.html#process_process_cwd | current working directory} + */ + async uploadFile(...filePaths: string[]): Promise<void> { + const isMultiple = await this.evaluate< + (element: HTMLInputElement) => boolean + >((element) => element.multiple); + assert( + filePaths.length <= 1 || isMultiple, + 'Multiple file uploads only work with <input type=file multiple>' + ); + + if (!isNode) { + throw new Error( + `JSHandle#uploadFile can only be used in Node environments.` + ); + } + // This import is only needed for `uploadFile`, so keep it scoped here to avoid paying + // the cost unnecessarily. + const path = await import('path'); + const fs = await helper.importFSModule(); + // Locate all files and confirm that they exist. + const files = await Promise.all( + filePaths.map(async (filePath) => { + const resolvedPath: string = path.resolve(filePath); + try { + await fs.promises.access(resolvedPath, fs.constants.R_OK); + } catch (error) { + if (error.code === 'ENOENT') + throw new Error(`${filePath} does not exist or is not readable`); + } + + return resolvedPath; + }) + ); + const { objectId } = this._remoteObject; + const { node } = await this._client.send('DOM.describeNode', { objectId }); + const { backendNodeId } = node; + + // The zero-length array is a special case, it seems that DOM.setFileInputFiles does + // not actually update the files in that case, so the solution is to eval the element + // value to a new FileList directly. + if (files.length === 0) { + await this.evaluate<(element: HTMLInputElement) => void>((element) => { + element.files = new DataTransfer().files; + + // Dispatch events for this case because it should behave akin to a user action. + element.dispatchEvent(new Event('input', { bubbles: true })); + element.dispatchEvent(new Event('change', { bubbles: true })); + }); + } else { + await this._client.send('DOM.setFileInputFiles', { + objectId, + files, + backendNodeId, + }); + } + } + + /** + * This method scrolls element into view if needed, and then uses + * {@link Touchscreen.tap} to tap in the center of the element. + * If the element is detached from DOM, the method throws an error. + */ + async tap(): Promise<void> { + await this._scrollIntoViewIfNeeded(); + const { x, y } = await this._clickablePoint(); + await this._page.touchscreen.tap(x, y); + } + + /** + * Calls {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus | focus} on the element. + */ + async focus(): Promise<void> { + await this.evaluate<(element: HTMLElement) => void>((element) => + element.focus() + ); + } + + /** + * Focuses the element, and then sends a `keydown`, `keypress`/`input`, and + * `keyup` event for each character in the text. + * + * To press a special key, like `Control` or `ArrowDown`, + * use {@link ElementHandle.press}. + * + * @example + * ```js + * await elementHandle.type('Hello'); // Types instantly + * await elementHandle.type('World', {delay: 100}); // Types slower, like a user + * ``` + * + * @example + * An example of typing into a text field and then submitting the form: + * + * ```js + * const elementHandle = await page.$('input'); + * await elementHandle.type('some text'); + * await elementHandle.press('Enter'); + * ``` + */ + async type(text: string, options?: { delay: number }): Promise<void> { + await this.focus(); + await this._page.keyboard.type(text, options); + } + + /** + * Focuses the element, and then uses {@link Keyboard.down} and {@link Keyboard.up}. + * + * @remarks + * If `key` is a single character and no modifier keys besides `Shift` + * are being held down, a `keypress`/`input` event will also be generated. + * The `text` option can be specified to force an input event to be generated. + * + * **NOTE** Modifier keys DO affect `elementHandle.press`. Holding down `Shift` + * will type the text in upper case. + * + * @param key - Name of key to press, such as `ArrowLeft`. + * See {@link KeyInput} for a list of all key names. + */ + async press(key: KeyInput, options?: PressOptions): Promise<void> { + await this.focus(); + await this._page.keyboard.press(key, options); + } + + /** + * This method returns the bounding box of the element (relative to the main frame), + * or `null` if the element is not visible. + */ + async boundingBox(): Promise<BoundingBox | null> { + const result = await this._getBoxModel(); + + if (!result) return null; + + const quad = result.model.border; + const x = Math.min(quad[0], quad[2], quad[4], quad[6]); + const y = Math.min(quad[1], quad[3], quad[5], quad[7]); + const width = Math.max(quad[0], quad[2], quad[4], quad[6]) - x; + const height = Math.max(quad[1], quad[3], quad[5], quad[7]) - y; + + return { x, y, width, height }; + } + + /** + * This method returns boxes of the element, or `null` if the element is not visible. + * + * @remarks + * + * Boxes are represented as an array of points; + * Each Point is an object `{x, y}`. Box points are sorted clock-wise. + */ + async boxModel(): Promise<BoxModel | null> { + const result = await this._getBoxModel(); + + if (!result) return null; + + const { content, padding, border, margin, width, height } = result.model; + return { + content: this._fromProtocolQuad(content), + padding: this._fromProtocolQuad(padding), + border: this._fromProtocolQuad(border), + margin: this._fromProtocolQuad(margin), + width, + height, + }; + } + + /** + * This method scrolls element into view if needed, and then uses + * {@link Page.screenshot} to take a screenshot of the element. + * If the element is detached from DOM, the method throws an error. + */ + async screenshot(options = {}): Promise<string | Buffer | void> { + let needsViewportReset = false; + + let boundingBox = await this.boundingBox(); + assert(boundingBox, 'Node is either not visible or not an HTMLElement'); + + const viewport = this._page.viewport(); + + if ( + viewport && + (boundingBox.width > viewport.width || + boundingBox.height > viewport.height) + ) { + const newViewport = { + width: Math.max(viewport.width, Math.ceil(boundingBox.width)), + height: Math.max(viewport.height, Math.ceil(boundingBox.height)), + }; + await this._page.setViewport(Object.assign({}, viewport, newViewport)); + + needsViewportReset = true; + } + + await this._scrollIntoViewIfNeeded(); + + boundingBox = await this.boundingBox(); + assert(boundingBox, 'Node is either not visible or not an HTMLElement'); + assert(boundingBox.width !== 0, 'Node has 0 width.'); + assert(boundingBox.height !== 0, 'Node has 0 height.'); + + const { + layoutViewport: { pageX, pageY }, + } = await this._client.send('Page.getLayoutMetrics'); + + const clip = Object.assign({}, boundingBox); + clip.x += pageX; + clip.y += pageY; + + const imageData = await this._page.screenshot( + Object.assign( + {}, + { + clip, + }, + options + ) + ); + + if (needsViewportReset) await this._page.setViewport(viewport); + + return imageData; + } + + /** + * Runs `element.querySelector` within the page. If no element matches the selector, + * the return value resolves to `null`. + */ + async $(selector: string): Promise<ElementHandle | null> { + const { updatedSelector, queryHandler } = getQueryHandlerAndSelector( + selector + ); + return queryHandler.queryOne(this, updatedSelector); + } + + /** + * Runs `element.querySelectorAll` within the page. If no elements match the selector, + * the return value resolves to `[]`. + */ + async $$(selector: string): Promise<ElementHandle[]> { + const { updatedSelector, queryHandler } = getQueryHandlerAndSelector( + selector + ); + return queryHandler.queryAll(this, updatedSelector); + } + + /** + * This method runs `document.querySelector` within the element and passes it as + * the first argument to `pageFunction`. If there's no element matching `selector`, + * the method throws an error. + * + * If `pageFunction` returns a Promise, then `frame.$eval` would wait for the promise + * to resolve and return its value. + * + * @example + * ```js + * const tweetHandle = await page.$('.tweet'); + * expect(await tweetHandle.$eval('.like', node => node.innerText)).toBe('100'); + * expect(await tweetHandle.$eval('.retweets', node => node.innerText)).toBe('10'); + * ``` + */ + async $eval<ReturnType>( + selector: string, + pageFunction: ( + element: Element, + ...args: unknown[] + ) => ReturnType | Promise<ReturnType>, + ...args: SerializableOrJSHandle[] + ): Promise<WrapElementHandle<ReturnType>> { + const elementHandle = await this.$(selector); + if (!elementHandle) + throw new Error( + `Error: failed to find element matching selector "${selector}"` + ); + const result = await elementHandle.evaluate< + ( + element: Element, + ...args: SerializableOrJSHandle[] + ) => ReturnType | Promise<ReturnType> + >(pageFunction, ...args); + await elementHandle.dispose(); + + /** + * This `as` is a little unfortunate but helps TS understand the behavior of + * `elementHandle.evaluate`. If evaluate returns an element it will return an + * ElementHandle instance, rather than the plain object. All the + * WrapElementHandle type does is wrap ReturnType into + * ElementHandle<ReturnType> if it is an ElementHandle, or leave it alone as + * ReturnType if it isn't. + */ + return result as WrapElementHandle<ReturnType>; + } + + /** + * This method runs `document.querySelectorAll` within the element and passes it as + * the first argument to `pageFunction`. If there's no element matching `selector`, + * the method throws an error. + * + * If `pageFunction` returns a Promise, then `frame.$$eval` would wait for the + * promise to resolve and return its value. + * + * @example + * ```html + * <div class="feed"> + * <div class="tweet">Hello!</div> + * <div class="tweet">Hi!</div> + * </div> + * ``` + * + * @example + * ```js + * const feedHandle = await page.$('.feed'); + * expect(await feedHandle.$$eval('.tweet', nodes => nodes.map(n => n.innerText))) + * .toEqual(['Hello!', 'Hi!']); + * ``` + */ + async $$eval<ReturnType>( + selector: string, + pageFunction: ( + elements: Element[], + ...args: unknown[] + ) => ReturnType | Promise<ReturnType>, + ...args: SerializableOrJSHandle[] + ): Promise<WrapElementHandle<ReturnType>> { + const { updatedSelector, queryHandler } = getQueryHandlerAndSelector( + selector + ); + const arrayHandle = await queryHandler.queryAllArray(this, updatedSelector); + const result = await arrayHandle.evaluate< + ( + elements: Element[], + ...args: unknown[] + ) => ReturnType | Promise<ReturnType> + >(pageFunction, ...args); + await arrayHandle.dispose(); + /* This `as` exists for the same reason as the `as` in $eval above. + * See the comment there for a full explanation. + */ + return result as WrapElementHandle<ReturnType>; + } + + /** + * The method evaluates the XPath expression relative to the elementHandle. + * If there are no such elements, the method will resolve to an empty array. + * @param expression - Expression to {@link https://developer.mozilla.org/en-US/docs/Web/API/Document/evaluate | evaluate} + */ + async $x(expression: string): Promise<ElementHandle[]> { + const arrayHandle = await this.evaluateHandle( + (element: Document, expression: string) => { + const document = element.ownerDocument || element; + const iterator = document.evaluate( + expression, + element, + null, + XPathResult.ORDERED_NODE_ITERATOR_TYPE + ); + const array = []; + let item; + while ((item = iterator.iterateNext())) array.push(item); + return array; + }, + expression + ); + const properties = await arrayHandle.getProperties(); + await arrayHandle.dispose(); + const result = []; + for (const property of properties.values()) { + const elementHandle = property.asElement(); + if (elementHandle) result.push(elementHandle); + } + return result; + } + + /** + * Resolves to true if the element is visible in the current viewport. + */ + async isIntersectingViewport(): Promise<boolean> { + return await this.evaluate<(element: Element) => Promise<boolean>>( + async (element) => { + const visibleRatio = await new Promise((resolve) => { + const observer = new IntersectionObserver((entries) => { + resolve(entries[0].intersectionRatio); + observer.disconnect(); + }); + observer.observe(element); + }); + return visibleRatio > 0; + } + ); + } +} + +/** + * @public + */ +export interface ClickOptions { + /** + * Time to wait between `mousedown` and `mouseup` in milliseconds. + * + * @defaultValue 0 + */ + delay?: number; + /** + * @defaultValue 'left' + */ + button?: 'left' | 'right' | 'middle'; + /** + * @defaultValue 1 + */ + clickCount?: number; +} + +/** + * @public + */ +export interface PressOptions { + /** + * Time to wait between `keydown` and `keyup` in milliseconds. Defaults to 0. + */ + delay?: number; + /** + * If specified, generates an input event with this text. + */ + text?: string; +} + +function computeQuadArea(quad: Array<{ x: number; y: number }>): number { + // Compute sum of all directed areas of adjacent triangles + // https://en.wikipedia.org/wiki/Polygon#Simple_polygons + let area = 0; + for (let i = 0; i < quad.length; ++i) { + const p1 = quad[i]; + const p2 = quad[(i + 1) % quad.length]; + area += (p1.x * p2.y - p2.x * p1.y) / 2; + } + return Math.abs(area); +} diff --git a/remote/test/puppeteer/src/common/LifecycleWatcher.ts b/remote/test/puppeteer/src/common/LifecycleWatcher.ts new file mode 100644 index 0000000000..2b8ab60421 --- /dev/null +++ b/remote/test/puppeteer/src/common/LifecycleWatcher.ts @@ -0,0 +1,244 @@ +/** + * Copyright 2019 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. + */ + +import { assert } from './assert.js'; +import { helper, PuppeteerEventListener } from './helper.js'; +import { TimeoutError } from './Errors.js'; +import { + FrameManager, + Frame, + FrameManagerEmittedEvents, +} from './FrameManager.js'; +import { HTTPRequest } from './HTTPRequest.js'; +import { HTTPResponse } from './HTTPResponse.js'; +import { NetworkManagerEmittedEvents } from './NetworkManager.js'; +import { CDPSessionEmittedEvents } from './Connection.js'; + +export type PuppeteerLifeCycleEvent = + | 'load' + | 'domcontentloaded' + | 'networkidle0' + | 'networkidle2'; +type ProtocolLifeCycleEvent = + | 'load' + | 'DOMContentLoaded' + | 'networkIdle' + | 'networkAlmostIdle'; + +const puppeteerToProtocolLifecycle = new Map< + PuppeteerLifeCycleEvent, + ProtocolLifeCycleEvent +>([ + ['load', 'load'], + ['domcontentloaded', 'DOMContentLoaded'], + ['networkidle0', 'networkIdle'], + ['networkidle2', 'networkAlmostIdle'], +]); + +/** + * @internal + */ +export class LifecycleWatcher { + _expectedLifecycle: ProtocolLifeCycleEvent[]; + _frameManager: FrameManager; + _frame: Frame; + _timeout: number; + _navigationRequest?: HTTPRequest; + _eventListeners: PuppeteerEventListener[]; + _initialLoaderId: string; + + _sameDocumentNavigationPromise: Promise<Error | null>; + _sameDocumentNavigationCompleteCallback: (x?: Error) => void; + + _lifecyclePromise: Promise<void>; + _lifecycleCallback: () => void; + + _newDocumentNavigationPromise: Promise<Error | null>; + _newDocumentNavigationCompleteCallback: (x?: Error) => void; + + _terminationPromise: Promise<Error | null>; + _terminationCallback: (x?: Error) => void; + + _timeoutPromise: Promise<TimeoutError | null>; + + _maximumTimer?: NodeJS.Timeout; + _hasSameDocumentNavigation?: boolean; + + constructor( + frameManager: FrameManager, + frame: Frame, + waitUntil: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[], + timeout: number + ) { + if (Array.isArray(waitUntil)) waitUntil = waitUntil.slice(); + else if (typeof waitUntil === 'string') waitUntil = [waitUntil]; + this._expectedLifecycle = waitUntil.map((value) => { + const protocolEvent = puppeteerToProtocolLifecycle.get(value); + assert(protocolEvent, 'Unknown value for options.waitUntil: ' + value); + return protocolEvent; + }); + + this._frameManager = frameManager; + this._frame = frame; + this._initialLoaderId = frame._loaderId; + this._timeout = timeout; + this._navigationRequest = null; + this._eventListeners = [ + helper.addEventListener( + frameManager._client, + CDPSessionEmittedEvents.Disconnected, + () => + this._terminate( + new Error('Navigation failed because browser has disconnected!') + ) + ), + helper.addEventListener( + this._frameManager, + FrameManagerEmittedEvents.LifecycleEvent, + this._checkLifecycleComplete.bind(this) + ), + helper.addEventListener( + this._frameManager, + FrameManagerEmittedEvents.FrameNavigatedWithinDocument, + this._navigatedWithinDocument.bind(this) + ), + helper.addEventListener( + this._frameManager, + FrameManagerEmittedEvents.FrameDetached, + this._onFrameDetached.bind(this) + ), + helper.addEventListener( + this._frameManager.networkManager(), + NetworkManagerEmittedEvents.Request, + this._onRequest.bind(this) + ), + ]; + + this._sameDocumentNavigationPromise = new Promise<Error | null>( + (fulfill) => { + this._sameDocumentNavigationCompleteCallback = fulfill; + } + ); + + this._lifecyclePromise = new Promise((fulfill) => { + this._lifecycleCallback = fulfill; + }); + + this._newDocumentNavigationPromise = new Promise((fulfill) => { + this._newDocumentNavigationCompleteCallback = fulfill; + }); + + this._timeoutPromise = this._createTimeoutPromise(); + this._terminationPromise = new Promise((fulfill) => { + this._terminationCallback = fulfill; + }); + this._checkLifecycleComplete(); + } + + _onRequest(request: HTTPRequest): void { + if (request.frame() !== this._frame || !request.isNavigationRequest()) + return; + this._navigationRequest = request; + } + + _onFrameDetached(frame: Frame): void { + if (this._frame === frame) { + this._terminationCallback.call( + null, + new Error('Navigating frame was detached') + ); + return; + } + this._checkLifecycleComplete(); + } + + navigationResponse(): HTTPResponse | null { + return this._navigationRequest ? this._navigationRequest.response() : null; + } + + _terminate(error: Error): void { + this._terminationCallback.call(null, error); + } + + sameDocumentNavigationPromise(): Promise<Error | null> { + return this._sameDocumentNavigationPromise; + } + + newDocumentNavigationPromise(): Promise<Error | null> { + return this._newDocumentNavigationPromise; + } + + lifecyclePromise(): Promise<void> { + return this._lifecyclePromise; + } + + timeoutOrTerminationPromise(): Promise<Error | TimeoutError | null> { + return Promise.race([this._timeoutPromise, this._terminationPromise]); + } + + _createTimeoutPromise(): Promise<TimeoutError | null> { + if (!this._timeout) return new Promise(() => {}); + const errorMessage = + 'Navigation timeout of ' + this._timeout + ' ms exceeded'; + return new Promise( + (fulfill) => (this._maximumTimer = setTimeout(fulfill, this._timeout)) + ).then(() => new TimeoutError(errorMessage)); + } + + _navigatedWithinDocument(frame: Frame): void { + if (frame !== this._frame) return; + this._hasSameDocumentNavigation = true; + this._checkLifecycleComplete(); + } + + _checkLifecycleComplete(): void { + // We expect navigation to commit. + if (!checkLifecycle(this._frame, this._expectedLifecycle)) return; + this._lifecycleCallback(); + if ( + this._frame._loaderId === this._initialLoaderId && + !this._hasSameDocumentNavigation + ) + return; + if (this._hasSameDocumentNavigation) + this._sameDocumentNavigationCompleteCallback(); + if (this._frame._loaderId !== this._initialLoaderId) + this._newDocumentNavigationCompleteCallback(); + + /** + * @param {!Frame} frame + * @param {!Array<string>} expectedLifecycle + * @returns {boolean} + */ + function checkLifecycle( + frame: Frame, + expectedLifecycle: ProtocolLifeCycleEvent[] + ): boolean { + for (const event of expectedLifecycle) { + if (!frame._lifecycleEvents.has(event)) return false; + } + for (const child of frame.childFrames()) { + if (!checkLifecycle(child, expectedLifecycle)) return false; + } + return true; + } + } + + dispose(): void { + helper.removeEventListeners(this._eventListeners); + clearTimeout(this._maximumTimer); + } +} diff --git a/remote/test/puppeteer/src/common/NetworkManager.ts b/remote/test/puppeteer/src/common/NetworkManager.ts new file mode 100644 index 0000000000..52b0aee0bf --- /dev/null +++ b/remote/test/puppeteer/src/common/NetworkManager.ts @@ -0,0 +1,340 @@ +/** + * 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. + */ +import { EventEmitter } from './EventEmitter.js'; +import { assert } from './assert.js'; +import { helper, debugError } from './helper.js'; +import { Protocol } from 'devtools-protocol'; +import { CDPSession } from './Connection.js'; +import { FrameManager } from './FrameManager.js'; +import { HTTPRequest } from './HTTPRequest.js'; +import { HTTPResponse } from './HTTPResponse.js'; + +/** + * @public + */ +export interface Credentials { + username: string; + password: string; +} + +/** + * We use symbols to prevent any external parties listening to these events. + * They are internal to Puppeteer. + * + * @internal + */ +export const NetworkManagerEmittedEvents = { + Request: Symbol('NetworkManager.Request'), + Response: Symbol('NetworkManager.Response'), + RequestFailed: Symbol('NetworkManager.RequestFailed'), + RequestFinished: Symbol('NetworkManager.RequestFinished'), +} as const; + +/** + * @internal + */ +export class NetworkManager extends EventEmitter { + _client: CDPSession; + _ignoreHTTPSErrors: boolean; + _frameManager: FrameManager; + _requestIdToRequest = new Map<string, HTTPRequest>(); + _requestIdToRequestWillBeSentEvent = new Map< + string, + Protocol.Network.RequestWillBeSentEvent + >(); + _extraHTTPHeaders: Record<string, string> = {}; + _offline = false; + _credentials?: Credentials = null; + _attemptedAuthentications = new Set<string>(); + _userRequestInterceptionEnabled = false; + _protocolRequestInterceptionEnabled = false; + _userCacheDisabled = false; + _requestIdToInterceptionId = new Map<string, string>(); + + constructor( + client: CDPSession, + ignoreHTTPSErrors: boolean, + frameManager: FrameManager + ) { + super(); + this._client = client; + this._ignoreHTTPSErrors = ignoreHTTPSErrors; + this._frameManager = frameManager; + + this._client.on('Fetch.requestPaused', this._onRequestPaused.bind(this)); + this._client.on('Fetch.authRequired', this._onAuthRequired.bind(this)); + this._client.on( + 'Network.requestWillBeSent', + this._onRequestWillBeSent.bind(this) + ); + this._client.on( + 'Network.requestServedFromCache', + this._onRequestServedFromCache.bind(this) + ); + this._client.on( + 'Network.responseReceived', + this._onResponseReceived.bind(this) + ); + this._client.on( + 'Network.loadingFinished', + this._onLoadingFinished.bind(this) + ); + this._client.on('Network.loadingFailed', this._onLoadingFailed.bind(this)); + } + + async initialize(): Promise<void> { + await this._client.send('Network.enable'); + if (this._ignoreHTTPSErrors) + await this._client.send('Security.setIgnoreCertificateErrors', { + ignore: true, + }); + } + + async authenticate(credentials?: Credentials): Promise<void> { + this._credentials = credentials; + await this._updateProtocolRequestInterception(); + } + + async setExtraHTTPHeaders( + extraHTTPHeaders: Record<string, string> + ): Promise<void> { + this._extraHTTPHeaders = {}; + for (const key of Object.keys(extraHTTPHeaders)) { + const value = extraHTTPHeaders[key]; + assert( + helper.isString(value), + `Expected value of header "${key}" to be String, but "${typeof value}" is found.` + ); + this._extraHTTPHeaders[key.toLowerCase()] = value; + } + await this._client.send('Network.setExtraHTTPHeaders', { + headers: this._extraHTTPHeaders, + }); + } + + extraHTTPHeaders(): Record<string, string> { + return Object.assign({}, this._extraHTTPHeaders); + } + + async setOfflineMode(value: boolean): Promise<void> { + if (this._offline === value) return; + this._offline = value; + await this._client.send('Network.emulateNetworkConditions', { + offline: this._offline, + // values of 0 remove any active throttling. crbug.com/456324#c9 + latency: 0, + downloadThroughput: -1, + uploadThroughput: -1, + }); + } + + async setUserAgent(userAgent: string): Promise<void> { + await this._client.send('Network.setUserAgentOverride', { userAgent }); + } + + async setCacheEnabled(enabled: boolean): Promise<void> { + this._userCacheDisabled = !enabled; + await this._updateProtocolCacheDisabled(); + } + + async setRequestInterception(value: boolean): Promise<void> { + this._userRequestInterceptionEnabled = value; + await this._updateProtocolRequestInterception(); + } + + async _updateProtocolRequestInterception(): Promise<void> { + const enabled = this._userRequestInterceptionEnabled || !!this._credentials; + if (enabled === this._protocolRequestInterceptionEnabled) return; + this._protocolRequestInterceptionEnabled = enabled; + if (enabled) { + await Promise.all([ + this._updateProtocolCacheDisabled(), + this._client.send('Fetch.enable', { + handleAuthRequests: true, + patterns: [{ urlPattern: '*' }], + }), + ]); + } else { + await Promise.all([ + this._updateProtocolCacheDisabled(), + this._client.send('Fetch.disable'), + ]); + } + } + + async _updateProtocolCacheDisabled(): Promise<void> { + await this._client.send('Network.setCacheDisabled', { + cacheDisabled: + this._userCacheDisabled || this._protocolRequestInterceptionEnabled, + }); + } + + _onRequestWillBeSent(event: Protocol.Network.RequestWillBeSentEvent): void { + // Request interception doesn't happen for data URLs with Network Service. + if ( + this._protocolRequestInterceptionEnabled && + !event.request.url.startsWith('data:') + ) { + const requestId = event.requestId; + const interceptionId = this._requestIdToInterceptionId.get(requestId); + if (interceptionId) { + this._onRequest(event, interceptionId); + this._requestIdToInterceptionId.delete(requestId); + } else { + this._requestIdToRequestWillBeSentEvent.set(event.requestId, event); + } + return; + } + this._onRequest(event, null); + } + + _onAuthRequired(event: Protocol.Fetch.AuthRequiredEvent): void { + /* TODO(jacktfranklin): This is defined in protocol.d.ts but not + * in an easily referrable way - we should look at exposing it. + */ + type AuthResponse = 'Default' | 'CancelAuth' | 'ProvideCredentials'; + let response: AuthResponse = 'Default'; + if (this._attemptedAuthentications.has(event.requestId)) { + response = 'CancelAuth'; + } else if (this._credentials) { + response = 'ProvideCredentials'; + this._attemptedAuthentications.add(event.requestId); + } + const { username, password } = this._credentials || { + username: undefined, + password: undefined, + }; + this._client + .send('Fetch.continueWithAuth', { + requestId: event.requestId, + authChallengeResponse: { response, username, password }, + }) + .catch(debugError); + } + + _onRequestPaused(event: Protocol.Fetch.RequestPausedEvent): void { + if ( + !this._userRequestInterceptionEnabled && + this._protocolRequestInterceptionEnabled + ) { + this._client + .send('Fetch.continueRequest', { + requestId: event.requestId, + }) + .catch(debugError); + } + + const requestId = event.networkId; + const interceptionId = event.requestId; + if (requestId && this._requestIdToRequestWillBeSentEvent.has(requestId)) { + const requestWillBeSentEvent = this._requestIdToRequestWillBeSentEvent.get( + requestId + ); + this._onRequest(requestWillBeSentEvent, interceptionId); + this._requestIdToRequestWillBeSentEvent.delete(requestId); + } else { + this._requestIdToInterceptionId.set(requestId, interceptionId); + } + } + + _onRequest( + event: Protocol.Network.RequestWillBeSentEvent, + interceptionId?: string + ): void { + let redirectChain = []; + if (event.redirectResponse) { + const request = this._requestIdToRequest.get(event.requestId); + // If we connect late to the target, we could have missed the + // requestWillBeSent event. + if (request) { + this._handleRequestRedirect(request, event.redirectResponse); + redirectChain = request._redirectChain; + } + } + const frame = event.frameId + ? this._frameManager.frame(event.frameId) + : null; + const request = new HTTPRequest( + this._client, + frame, + interceptionId, + this._userRequestInterceptionEnabled, + event, + redirectChain + ); + this._requestIdToRequest.set(event.requestId, request); + this.emit(NetworkManagerEmittedEvents.Request, request); + } + + _onRequestServedFromCache( + event: Protocol.Network.RequestServedFromCacheEvent + ): void { + const request = this._requestIdToRequest.get(event.requestId); + if (request) request._fromMemoryCache = true; + } + + _handleRequestRedirect( + request: HTTPRequest, + responsePayload: Protocol.Network.Response + ): void { + const response = new HTTPResponse(this._client, request, responsePayload); + request._response = response; + request._redirectChain.push(request); + response._resolveBody( + new Error('Response body is unavailable for redirect responses') + ); + this._requestIdToRequest.delete(request._requestId); + this._attemptedAuthentications.delete(request._interceptionId); + this.emit(NetworkManagerEmittedEvents.Response, response); + this.emit(NetworkManagerEmittedEvents.RequestFinished, request); + } + + _onResponseReceived(event: Protocol.Network.ResponseReceivedEvent): void { + const request = this._requestIdToRequest.get(event.requestId); + // FileUpload sends a response without a matching request. + if (!request) return; + const response = new HTTPResponse(this._client, request, event.response); + request._response = response; + this.emit(NetworkManagerEmittedEvents.Response, response); + } + + _onLoadingFinished(event: Protocol.Network.LoadingFinishedEvent): void { + const request = this._requestIdToRequest.get(event.requestId); + // For certain requestIds we never receive requestWillBeSent event. + // @see https://crbug.com/750469 + if (!request) return; + + // Under certain conditions we never get the Network.responseReceived + // event from protocol. @see https://crbug.com/883475 + if (request.response()) request.response()._resolveBody(null); + this._requestIdToRequest.delete(request._requestId); + this._attemptedAuthentications.delete(request._interceptionId); + this.emit(NetworkManagerEmittedEvents.RequestFinished, request); + } + + _onLoadingFailed(event: Protocol.Network.LoadingFailedEvent): void { + const request = this._requestIdToRequest.get(event.requestId); + // For certain requestIds we never receive requestWillBeSent event. + // @see https://crbug.com/750469 + if (!request) return; + request._failureText = event.errorText; + const response = request.response(); + if (response) response._resolveBody(null); + this._requestIdToRequest.delete(request._requestId); + this._attemptedAuthentications.delete(request._interceptionId); + this.emit(NetworkManagerEmittedEvents.RequestFailed, request); + } +} diff --git a/remote/test/puppeteer/src/common/PDFOptions.ts b/remote/test/puppeteer/src/common/PDFOptions.ts new file mode 100644 index 0000000000..743085904a --- /dev/null +++ b/remote/test/puppeteer/src/common/PDFOptions.ts @@ -0,0 +1,179 @@ +/** + * Copyright 2020 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. + */ + +/** + * @public + */ +export interface PDFMargin { + top?: string | number; + bottom?: string | number; + left?: string | number; + right?: string | number; +} + +/** + * All the valid paper format types when printing a PDF. + * + * @remarks + * + * The sizes of each format are as follows: + * - `Letter`: 8.5in x 11in + * + * - `Legal`: 8.5in x 14in + * + * - `Tabloid`: 11in x 17in + * + * - `Ledger`: 17in x 11in + * + * - `A0`: 33.1in x 46.8in + * + * - `A1`: 23.4in x 33.1in + * + * - `A2`: 16.54in x 23.4in + * + * - `A3`: 11.7in x 16.54in + * + * - `A4`: 8.27in x 11.7in + * + * - `A5`: 5.83in x 8.27in + * + * - `A6`: 4.13in x 5.83in + * + * @public + */ +export type PaperFormat = + | 'letter' + | 'legal' + | 'tabloid' + | 'ledger' + | 'a0' + | 'a1' + | 'a2' + | 'a3' + | 'a4' + | 'a5' + | 'a6'; + +/** + * Valid options to configure PDF generation via {@link Page.pdf}. + * @public + */ +export interface PDFOptions { + /** + * Scales the rendering of the web page. Amount must be between `0.1` and `2`. + * @defaultValue 1 + */ + scale?: number; + /** + * Whether to show the header and footer. + * @defaultValue false + */ + displayHeaderFooter?: boolean; + /** + * HTML template for the print header. Should be valid HTML with the following + * classes used to inject values into them: + * - `date` formatted print date + * + * - `title` document title + * + * - `url` document location + * + * - `pageNumber` current page number + * + * - `totalPages` total pages in the document + */ + headerTemplate?: string; + /** + * HTML template for the print footer. Has the same constraints and support + * for special classes as {@link PDFOptions.headerTemplate}. + */ + footerTemplate?: string; + /** + * Set to `true` to print background graphics. + * @defaultValue false + */ + printBackground?: boolean; + /** + * Whether to print in landscape orientation. + * @defaultValue = false + */ + landscape?: boolean; + /** + * Paper ranges to print, e.g. `1-5, 8, 11-13`. + * @defaultValue The empty string, which means all pages are printed. + */ + pageRanges?: string; + /** + * @remarks + * If set, this takes priority over the `width` and `height` options. + * @defaultValue `letter`. + */ + format?: PaperFormat; + /** + * Sets the width of paper. You can pass in a number or a string with a unit. + */ + width?: string | number; + /** + * Sets the height of paper. You can pass in a number or a string with a unit. + */ + height?: string | number; + /** + * Give any CSS `@page` size declared in the page priority over what is + * declared in the `width` or `height` or `format` option. + * @defaultValue `false`, which will scale the content to fit the paper size. + */ + preferCSSPageSize?: boolean; + /** + * Set the PDF margins. + * @defaultValue no margins are set. + */ + margin?: PDFMargin; + /** + * The path to save the file to. + * + * @remarks + * + * If the path is relative, it's resolved relative to the current working directory. + * + * @defaultValue the empty string, which means the PDF will not be written to disk. + */ + path?: string; +} + +/** + * @internal + */ +export interface PaperFormatDimensions { + width: number; + height: number; +} + +/** + * @internal + */ +export const paperFormats: Record<PaperFormat, PaperFormatDimensions> = { + letter: { width: 8.5, height: 11 }, + legal: { width: 8.5, height: 14 }, + tabloid: { width: 11, height: 17 }, + ledger: { width: 17, height: 11 }, + a0: { width: 33.1, height: 46.8 }, + a1: { width: 23.4, height: 33.1 }, + a2: { width: 16.54, height: 23.4 }, + a3: { width: 11.7, height: 16.54 }, + a4: { width: 8.27, height: 11.7 }, + a5: { width: 5.83, height: 8.27 }, + a6: { width: 4.13, height: 5.83 }, +} as const; diff --git a/remote/test/puppeteer/src/common/Page.ts b/remote/test/puppeteer/src/common/Page.ts new file mode 100644 index 0000000000..7194649bef --- /dev/null +++ b/remote/test/puppeteer/src/common/Page.ts @@ -0,0 +1,2013 @@ +/** + * 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. + */ + +import { EventEmitter } from './EventEmitter.js'; +import { + Connection, + CDPSession, + CDPSessionEmittedEvents, +} from './Connection.js'; +import { Dialog } from './Dialog.js'; +import { EmulationManager } from './EmulationManager.js'; +import { + Frame, + FrameManager, + FrameManagerEmittedEvents, +} from './FrameManager.js'; +import { Keyboard, Mouse, Touchscreen, MouseButton } from './Input.js'; +import { Tracing } from './Tracing.js'; +import { assert } from './assert.js'; +import { helper, debugError } from './helper.js'; +import { Coverage } from './Coverage.js'; +import { WebWorker } from './WebWorker.js'; +import { Browser, BrowserContext } from './Browser.js'; +import { Target } from './Target.js'; +import { createJSHandle, JSHandle, ElementHandle } from './JSHandle.js'; +import { Viewport } from './PuppeteerViewport.js'; +import { Credentials, NetworkManagerEmittedEvents } from './NetworkManager.js'; +import { HTTPRequest } from './HTTPRequest.js'; +import { HTTPResponse } from './HTTPResponse.js'; +import { Accessibility } from './Accessibility.js'; +import { TimeoutSettings } from './TimeoutSettings.js'; +import { FileChooser } from './FileChooser.js'; +import { ConsoleMessage, ConsoleMessageType } from './ConsoleMessage.js'; +import { PuppeteerLifeCycleEvent } from './LifecycleWatcher.js'; +import { Protocol } from 'devtools-protocol'; +import { + SerializableOrJSHandle, + EvaluateHandleFn, + WrapElementHandle, + EvaluateFn, + EvaluateFnReturnType, + UnwrapPromiseLike, +} from './EvalTypes.js'; +import { PDFOptions, paperFormats } from './PDFOptions.js'; +import { isNode } from '../environment.js'; + +/** + * @public + */ +export interface Metrics { + Timestamp?: number; + Documents?: number; + Frames?: number; + JSEventListeners?: number; + Nodes?: number; + LayoutCount?: number; + RecalcStyleCount?: number; + LayoutDuration?: number; + RecalcStyleDuration?: number; + ScriptDuration?: number; + TaskDuration?: number; + JSHeapUsedSize?: number; + JSHeapTotalSize?: number; +} + +/** + * @public + */ +export interface WaitTimeoutOptions { + /** + * Maximum wait time in milliseconds, defaults to 30 seconds, pass `0` to + * disable the timeout. + * + * @remarks + * The default value can be changed by using the + * {@link Page.setDefaultTimeout} method. + */ + timeout?: number; +} + +/** + * @public + */ +export interface WaitForOptions { + /** + * Maximum wait time in milliseconds, defaults to 30 seconds, pass `0` to + * disable the timeout. + * + * @remarks + * The default value can be changed by using the + * {@link Page.setDefaultTimeout} or {@link Page.setDefaultNavigationTimeout} + * methods. + */ + timeout?: number; + waitUntil?: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[]; +} + +/** + * @public + */ +export interface GeolocationOptions { + /** + * Latitude between -90 and 90. + */ + longitude: number; + /** + * Longitude between -180 and 180. + */ + latitude: number; + /** + * Optional non-negative accuracy value. + */ + accuracy?: number; +} + +interface MediaFeature { + name: string; + value: string; +} + +interface ScreenshotClip { + x: number; + y: number; + width: number; + height: number; +} + +interface ScreenshotOptions { + type?: 'png' | 'jpeg'; + path?: string; + fullPage?: boolean; + clip?: ScreenshotClip; + quality?: number; + omitBackground?: boolean; + encoding?: string; +} + +/** + * All the events that a page instance may emit. + * + * @public + */ +export const enum PageEmittedEvents { + /** Emitted when the page closes. */ + Close = 'close', + /** + * Emitted when JavaScript within the page calls one of console API methods, + * e.g. `console.log` or `console.dir`. Also emitted if the page throws an + * error or a warning. + * + * @remarks + * + * A `console` event provides a {@link ConsoleMessage} representing the + * console message that was logged. + * + * @example + * An example of handling `console` event: + * ```js + * page.on('console', msg => { + * for (let i = 0; i < msg.args().length; ++i) + * console.log(`${i}: ${msg.args()[i]}`); + * }); + * page.evaluate(() => console.log('hello', 5, {foo: 'bar'})); + * ``` + */ + Console = 'console', + /** + * Emitted when a JavaScript dialog appears, such as `alert`, `prompt`, + * `confirm` or `beforeunload`. Puppeteer can respond to the dialog via + * {@link Dialog.accept} or {@link Dialog.dismiss}. + */ + Dialog = 'dialog', + /** + * Emitted when the JavaScript + * {@link https://developer.mozilla.org/en-US/docs/Web/Events/DOMContentLoaded | DOMContentLoaded } event is dispatched. + */ + DOMContentLoaded = 'domcontentloaded', + /** + * Emitted when the page crashes. Will contain an `Error`. + */ + Error = 'error', + /** Emitted when a frame is attached. Will contain a {@link Frame}. */ + FrameAttached = 'frameattached', + /** Emitted when a frame is detached. Will contain a {@link Frame}. */ + FrameDetached = 'framedetached', + /** Emitted when a frame is navigated to a new URL. Will contain a {@link Frame}. */ + FrameNavigated = 'framenavigated', + /** + * Emitted when the JavaScript + * {@link https://developer.mozilla.org/en-US/docs/Web/Events/load | load} + * event is dispatched. + */ + Load = 'load', + /** + * Emitted when the JavaScript code makes a call to `console.timeStamp`. For + * the list of metrics see {@link Page.metrics | page.metrics}. + * + * @remarks + * Contains an object with two properties: + * - `title`: the title passed to `console.timeStamp` + * - `metrics`: objec containing metrics as key/value pairs. The values will + * be `number`s. + */ + Metrics = 'metrics', + /** + * Emitted when an uncaught exception happens within the page. + * Contains an `Error`. + */ + PageError = 'pageerror', + /** + * Emitted when the page opens a new tab or window. + * + * Contains a {@link Page} corresponding to the popup window. + * + * @example + * + * ```js + * const [popup] = await Promise.all([ + * new Promise(resolve => page.once('popup', resolve)), + * page.click('a[target=_blank]'), + * ]); + * ``` + * + * ```js + * const [popup] = await Promise.all([ + * new Promise(resolve => page.once('popup', resolve)), + * page.evaluate(() => window.open('https://example.com')), + * ]); + * ``` + */ + Popup = 'popup', + /** + * Emitted when a page issues a request and contains a {@link HTTPRequest}. + * + * @remarks + * The object is readonly. See {@Page.setRequestInterception} for intercepting + * and mutating requests. + */ + Request = 'request', + /** + * Emitted when a request fails, for example by timing out. + * + * Contains a {@link HTTPRequest}. + * + * @remarks + * + * NOTE: HTTP Error responses, such as 404 or 503, are still successful + * responses from HTTP standpoint, so request will complete with + * `requestfinished` event and not with `requestfailed`. + */ + RequestFailed = 'requestfailed', + /** + * Emitted when a request finishes successfully. Contains a {@link HTTPRequest}. + */ + RequestFinished = 'requestfinished', + /** + * Emitted when a response is received. Contains a {@link HTTPResponse}. + */ + Response = 'response', + /** + * Emitted when a dedicated + * {@link https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API | WebWorker} + * is spawned by the page. + */ + WorkerCreated = 'workercreated', + /** + * Emitted when a dedicated + * {@link https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API | WebWorker} + * is destroyed by the page. + */ + WorkerDestroyed = 'workerdestroyed', +} + +class ScreenshotTaskQueue { + _chain: Promise<Buffer | string | void>; + + constructor() { + this._chain = Promise.resolve<Buffer | string | void>(undefined); + } + + public postTask( + task: () => Promise<Buffer | string> + ): Promise<Buffer | string | void> { + const result = this._chain.then(task); + this._chain = result.catch(() => {}); + return result; + } +} + +/** + * Page provides methods to interact with a single tab or + * {@link https://developer.chrome.com/extensions/background_pages | extension background page} in Chromium. + * + * @remarks + * + * One Browser instance might have multiple Page instances. + * + * @example + * This example creates a page, navigates it to a URL, and then * saves a screenshot: + * ```js + * const puppeteer = require('puppeteer'); + * + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * await page.goto('https://example.com'); + * await page.screenshot({path: 'screenshot.png'}); + * await browser.close(); + * })(); + * ``` + * + * The Page class extends from Puppeteer's {@link EventEmitter} class and will + * emit various events which are documented in the {@link PageEmittedEvents} enum. + * + * @example + * This example logs a message for a single page `load` event: + * ```js + * page.once('load', () => console.log('Page loaded!')); + * ``` + * + * To unsubscribe from events use the `off` method: + * + * ```js + * function logRequest(interceptedRequest) { + * console.log('A request was made:', interceptedRequest.url()); + * } + * page.on('request', logRequest); + * // Sometime later... + * page.off('request', logRequest); + * ``` + * @public + */ +export class Page extends EventEmitter { + /** + * @internal + */ + static async create( + client: CDPSession, + target: Target, + ignoreHTTPSErrors: boolean, + defaultViewport: Viewport | null + ): Promise<Page> { + const page = new Page(client, target, ignoreHTTPSErrors); + await page._initialize(); + if (defaultViewport) await page.setViewport(defaultViewport); + return page; + } + + private _closed = false; + private _client: CDPSession; + private _target: Target; + private _keyboard: Keyboard; + private _mouse: Mouse; + private _timeoutSettings = new TimeoutSettings(); + private _touchscreen: Touchscreen; + private _accessibility: Accessibility; + private _frameManager: FrameManager; + private _emulationManager: EmulationManager; + private _tracing: Tracing; + private _pageBindings = new Map<string, Function>(); + private _coverage: Coverage; + private _javascriptEnabled = true; + private _viewport: Viewport | null; + private _screenshotTaskQueue: ScreenshotTaskQueue; + private _workers = new Map<string, WebWorker>(); + // TODO: improve this typedef - it's a function that takes a file chooser or + // something? + private _fileChooserInterceptors = new Set<Function>(); + + private _disconnectPromise?: Promise<Error>; + + /** + * @internal + */ + constructor(client: CDPSession, target: Target, ignoreHTTPSErrors: boolean) { + super(); + this._client = client; + this._target = target; + this._keyboard = new Keyboard(client); + this._mouse = new Mouse(client, this._keyboard); + this._touchscreen = new Touchscreen(client, this._keyboard); + this._accessibility = new Accessibility(client); + this._frameManager = new FrameManager( + client, + this, + ignoreHTTPSErrors, + this._timeoutSettings + ); + this._emulationManager = new EmulationManager(client); + this._tracing = new Tracing(client); + this._coverage = new Coverage(client); + this._screenshotTaskQueue = new ScreenshotTaskQueue(); + this._viewport = null; + + client.on('Target.attachedToTarget', (event) => { + if (event.targetInfo.type !== 'worker') { + // If we don't detach from service workers, they will never die. + client + .send('Target.detachFromTarget', { + sessionId: event.sessionId, + }) + .catch(debugError); + return; + } + const session = Connection.fromSession(client).session(event.sessionId); + const worker = new WebWorker( + session, + event.targetInfo.url, + this._addConsoleMessage.bind(this), + this._handleException.bind(this) + ); + this._workers.set(event.sessionId, worker); + this.emit(PageEmittedEvents.WorkerCreated, worker); + }); + client.on('Target.detachedFromTarget', (event) => { + const worker = this._workers.get(event.sessionId); + if (!worker) return; + this.emit(PageEmittedEvents.WorkerDestroyed, worker); + this._workers.delete(event.sessionId); + }); + + this._frameManager.on(FrameManagerEmittedEvents.FrameAttached, (event) => + this.emit(PageEmittedEvents.FrameAttached, event) + ); + this._frameManager.on(FrameManagerEmittedEvents.FrameDetached, (event) => + this.emit(PageEmittedEvents.FrameDetached, event) + ); + this._frameManager.on(FrameManagerEmittedEvents.FrameNavigated, (event) => + this.emit(PageEmittedEvents.FrameNavigated, event) + ); + + const networkManager = this._frameManager.networkManager(); + networkManager.on(NetworkManagerEmittedEvents.Request, (event) => + this.emit(PageEmittedEvents.Request, event) + ); + networkManager.on(NetworkManagerEmittedEvents.Response, (event) => + this.emit(PageEmittedEvents.Response, event) + ); + networkManager.on(NetworkManagerEmittedEvents.RequestFailed, (event) => + this.emit(PageEmittedEvents.RequestFailed, event) + ); + networkManager.on(NetworkManagerEmittedEvents.RequestFinished, (event) => + this.emit(PageEmittedEvents.RequestFinished, event) + ); + this._fileChooserInterceptors = new Set(); + + client.on('Page.domContentEventFired', () => + this.emit(PageEmittedEvents.DOMContentLoaded) + ); + client.on('Page.loadEventFired', () => this.emit(PageEmittedEvents.Load)); + client.on('Runtime.consoleAPICalled', (event) => this._onConsoleAPI(event)); + client.on('Runtime.bindingCalled', (event) => this._onBindingCalled(event)); + client.on('Page.javascriptDialogOpening', (event) => this._onDialog(event)); + client.on('Runtime.exceptionThrown', (exception) => + this._handleException(exception.exceptionDetails) + ); + client.on('Inspector.targetCrashed', () => this._onTargetCrashed()); + client.on('Performance.metrics', (event) => this._emitMetrics(event)); + client.on('Log.entryAdded', (event) => this._onLogEntryAdded(event)); + client.on('Page.fileChooserOpened', (event) => this._onFileChooser(event)); + this._target._isClosedPromise.then(() => { + this.emit(PageEmittedEvents.Close); + this._closed = true; + }); + } + + private async _initialize(): Promise<void> { + await Promise.all([ + this._frameManager.initialize(), + this._client.send('Target.setAutoAttach', { + autoAttach: true, + waitForDebuggerOnStart: false, + flatten: true, + }), + this._client.send('Performance.enable'), + this._client.send('Log.enable'), + ]); + } + + private async _onFileChooser( + event: Protocol.Page.FileChooserOpenedEvent + ): Promise<void> { + if (!this._fileChooserInterceptors.size) return; + const frame = this._frameManager.frame(event.frameId); + const context = await frame.executionContext(); + const element = await context._adoptBackendNodeId(event.backendNodeId); + const interceptors = Array.from(this._fileChooserInterceptors); + this._fileChooserInterceptors.clear(); + const fileChooser = new FileChooser(element, event); + for (const interceptor of interceptors) interceptor.call(null, fileChooser); + } + + /** + * @returns `true` if the page has JavaScript enabled, `false` otherwise. + */ + public isJavaScriptEnabled(): boolean { + return this._javascriptEnabled; + } + + /** + * @param options - Optional waiting parameters + * @returns Resolves after a page requests a file picker. + */ + async waitForFileChooser( + options: WaitTimeoutOptions = {} + ): Promise<FileChooser> { + if (!this._fileChooserInterceptors.size) + await this._client.send('Page.setInterceptFileChooserDialog', { + enabled: true, + }); + + const { timeout = this._timeoutSettings.timeout() } = options; + let callback; + const promise = new Promise<FileChooser>((x) => (callback = x)); + this._fileChooserInterceptors.add(callback); + return helper + .waitWithTimeout<FileChooser>( + promise, + 'waiting for file chooser', + timeout + ) + .catch((error) => { + this._fileChooserInterceptors.delete(callback); + throw error; + }); + } + + /** + * Sets the page's geolocation. + * + * @remarks + * Consider using {@link BrowserContext.overridePermissions} to grant + * permissions for the page to read its geolocation. + * + * @example + * ```js + * await page.setGeolocation({latitude: 59.95, longitude: 30.31667}); + * ``` + */ + async setGeolocation(options: GeolocationOptions): Promise<void> { + const { longitude, latitude, accuracy = 0 } = options; + if (longitude < -180 || longitude > 180) + throw new Error( + `Invalid longitude "${longitude}": precondition -180 <= LONGITUDE <= 180 failed.` + ); + if (latitude < -90 || latitude > 90) + throw new Error( + `Invalid latitude "${latitude}": precondition -90 <= LATITUDE <= 90 failed.` + ); + if (accuracy < 0) + throw new Error( + `Invalid accuracy "${accuracy}": precondition 0 <= ACCURACY failed.` + ); + await this._client.send('Emulation.setGeolocationOverride', { + longitude, + latitude, + accuracy, + }); + } + + /** + * @returns A target this page was created from. + */ + target(): Target { + return this._target; + } + + /** + * @returns The browser this page belongs to. + */ + browser(): Browser { + return this._target.browser(); + } + + /** + * @returns The browser context that the page belongs to + */ + browserContext(): BrowserContext { + return this._target.browserContext(); + } + + private _onTargetCrashed(): void { + this.emit('error', new Error('Page crashed!')); + } + + private _onLogEntryAdded(event: Protocol.Log.EntryAddedEvent): void { + const { level, text, args, source, url, lineNumber } = event.entry; + if (args) args.map((arg) => helper.releaseObject(this._client, arg)); + if (source !== 'worker') + this.emit( + PageEmittedEvents.Console, + new ConsoleMessage(level, text, [], [{ url, lineNumber }]) + ); + } + + /** + * @returns The page's main frame. + */ + mainFrame(): Frame { + return this._frameManager.mainFrame(); + } + + get keyboard(): Keyboard { + return this._keyboard; + } + + get touchscreen(): Touchscreen { + return this._touchscreen; + } + + get coverage(): Coverage { + return this._coverage; + } + + get tracing(): Tracing { + return this._tracing; + } + + get accessibility(): Accessibility { + return this._accessibility; + } + + /** + * @returns An array of all frames attached to the page. + */ + frames(): Frame[] { + return this._frameManager.frames(); + } + + /** + * @returns all of the dedicated + * {@link https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API | WebWorkers} + * associated with the page. + */ + workers(): WebWorker[] { + return Array.from(this._workers.values()); + } + + /** + * @param value - Whether to enable request interception. + * + * @remarks + * Activating request interception enables {@link HTTPRequest.abort}, + * {@link HTTPRequest.continue} and {@link HTTPRequest.respond} methods. This + * provides the capability to modify network requests that are made by a page. + * + * Once request interception is enabled, every request will stall unless it's + * continued, responded or aborted. + * + * **NOTE** Enabling request interception disables page caching. + * + * @example + * An example of a naïve request interceptor that aborts all image requests: + * ```js + * const puppeteer = require('puppeteer'); + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * await page.setRequestInterception(true); + * page.on('request', interceptedRequest => { + * if (interceptedRequest.url().endsWith('.png') || + * interceptedRequest.url().endsWith('.jpg')) + * interceptedRequest.abort(); + * else + * interceptedRequest.continue(); + * }); + * await page.goto('https://example.com'); + * await browser.close(); + * })(); + * ``` + */ + async setRequestInterception(value: boolean): Promise<void> { + return this._frameManager.networkManager().setRequestInterception(value); + } + + /** + * @param enabled - When `true`, enables offline mode for the page. + */ + setOfflineMode(enabled: boolean): Promise<void> { + return this._frameManager.networkManager().setOfflineMode(enabled); + } + + /** + * @param timeout - Maximum navigation time in milliseconds. + */ + setDefaultNavigationTimeout(timeout: number): void { + this._timeoutSettings.setDefaultNavigationTimeout(timeout); + } + + /** + * @param timeout - Maximum time in milliseconds. + */ + setDefaultTimeout(timeout: number): void { + this._timeoutSettings.setDefaultTimeout(timeout); + } + + /** + * Runs `document.querySelector` within the page. If no element matches the + * selector, the return value resolves to `null`. + * + * @remarks + * Shortcut for {@link Frame.$ | Page.mainFrame().$(selector) }. + * + * @param selector - A + * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors | selector} + * to query page for. + */ + async $(selector: string): Promise<ElementHandle | null> { + return this.mainFrame().$(selector); + } + + /** + * @remarks + * + * The only difference between {@link Page.evaluate | page.evaluate} and + * `page.evaluateHandle` is that `evaluateHandle` will return the value + * wrapped in an in-page object. + * + * If the function passed to `page.evaluteHandle` returns a Promise, the + * function will wait for the promise to resolve and return its value. + * + * You can pass a string instead of a function (although functions are + * recommended as they are easier to debug and use with TypeScript): + * + * @example + * ``` + * const aHandle = await page.evaluateHandle('document') + * ``` + * + * @example + * {@link JSHandle} instances can be passed as arguments to the `pageFunction`: + * ``` + * const aHandle = await page.evaluateHandle(() => document.body); + * const resultHandle = await page.evaluateHandle(body => body.innerHTML, aHandle); + * console.log(await resultHandle.jsonValue()); + * await resultHandle.dispose(); + * ``` + * + * Most of the time this function returns a {@link JSHandle}, + * but if `pageFunction` returns a reference to an element, + * you instead get an {@link ElementHandle} back: + * + * @example + * ``` + * const button = await page.evaluateHandle(() => document.querySelector('button')); + * // can call `click` because `button` is an `ElementHandle` + * await button.click(); + * ``` + * + * The TypeScript definitions assume that `evaluateHandle` returns + * a `JSHandle`, but if you know it's going to return an + * `ElementHandle`, pass it as the generic argument: + * + * ``` + * const button = await page.evaluateHandle<ElementHandle>(...); + * ``` + * + * @param pageFunction - a function that is run within the page + * @param args - arguments to be passed to the pageFunction + */ + async evaluateHandle<HandlerType extends JSHandle = JSHandle>( + pageFunction: EvaluateHandleFn, + ...args: SerializableOrJSHandle[] + ): Promise<HandlerType> { + const context = await this.mainFrame().executionContext(); + return context.evaluateHandle<HandlerType>(pageFunction, ...args); + } + + /** + * This method iterates the JavaScript heap and finds all objects with the + * given prototype. + * + * @remarks + * + * @example + * + * ```js + * // Create a Map object + * await page.evaluate(() => window.map = new Map()); + * // Get a handle to the Map object prototype + * const mapPrototype = await page.evaluateHandle(() => Map.prototype); + * // Query all map instances into an array + * const mapInstances = await page.queryObjects(mapPrototype); + * // Count amount of map objects in heap + * const count = await page.evaluate(maps => maps.length, mapInstances); + * await mapInstances.dispose(); + * await mapPrototype.dispose(); + * ``` + * @param prototypeHandle - a handle to the object prototype. + */ + async queryObjects(prototypeHandle: JSHandle): Promise<JSHandle> { + const context = await this.mainFrame().executionContext(); + return context.queryObjects(prototypeHandle); + } + + /** + * This method runs `document.querySelector` within the page and passes the + * result as the first argument to the `pageFunction`. + * + * @remarks + * + * If no element is found matching `selector`, the method will throw an error. + * + * If `pageFunction` returns a promise `$eval` will wait for the promise to + * resolve and then return its value. + * + * @example + * + * ``` + * const searchValue = await page.$eval('#search', el => el.value); + * const preloadHref = await page.$eval('link[rel=preload]', el => el.href); + * const html = await page.$eval('.main-container', el => el.outerHTML); + * ``` + * + * If you are using TypeScript, you may have to provide an explicit type to the + * first argument of the `pageFunction`. + * By default it is typed as `Element`, but you may need to provide a more + * specific sub-type: + * + * @example + * + * ``` + * // if you don't provide HTMLInputElement here, TS will error + * // as `value` is not on `Element` + * const searchValue = await page.$eval('#search', (el: HTMLInputElement) => el.value); + * ``` + * + * The compiler should be able to infer the return type + * from the `pageFunction` you provide. If it is unable to, you can use the generic + * type to tell the compiler what return type you expect from `$eval`: + * + * @example + * + * ``` + * // The compiler can infer the return type in this case, but if it can't + * // or if you want to be more explicit, provide it as the generic type. + * const searchValue = await page.$eval<string>( + * '#search', (el: HTMLInputElement) => el.value + * ); + * ``` + * + * @param selector - the + * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors | selector} + * to query for + * @param pageFunction - the function to be evaluated in the page context. + * Will be passed the result of `document.querySelector(selector)` as its + * first argument. + * @param args - any additional arguments to pass through to `pageFunction`. + * + * @returns The result of calling `pageFunction`. If it returns an element it + * is wrapped in an {@link ElementHandle}, else the raw value itself is + * returned. + */ + async $eval<ReturnType>( + selector: string, + pageFunction: ( + element: Element, + /* Unfortunately this has to be unknown[] because it's hard to get + * TypeScript to understand that the arguments will be left alone unless + * they are an ElementHandle, in which case they will be unwrapped. + * The nice thing about unknown vs any is that unknown will force the user + * to type the item before using it to avoid errors. + * + * TODO(@jackfranklin): We could fix this by using overloads like + * DefinitelyTyped does: + * https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/puppeteer/index.d.ts#L114 + */ + ...args: unknown[] + ) => ReturnType | Promise<ReturnType>, + ...args: SerializableOrJSHandle[] + ): Promise<WrapElementHandle<ReturnType>> { + return this.mainFrame().$eval<ReturnType>(selector, pageFunction, ...args); + } + + /** + * This method runs `Array.from(document.querySelectorAll(selector))` within + * the page and passes the result as the first argument to the `pageFunction`. + * + * @remarks + * + * If `pageFunction` returns a promise `$$eval` will wait for the promise to + * resolve and then return its value. + * + * @example + * + * ``` + * // get the amount of divs on the page + * const divCount = await page.$$eval('div', divs => divs.length); + * + * // get the text content of all the `.options` elements: + * const options = await page.$$eval('div > span.options', options => { + * return options.map(option => option.textContent) + * }); + * ``` + * + * If you are using TypeScript, you may have to provide an explicit type to the + * first argument of the `pageFunction`. + * By default it is typed as `Element[]`, but you may need to provide a more + * specific sub-type: + * + * @example + * + * ``` + * // if you don't provide HTMLInputElement here, TS will error + * // as `value` is not on `Element` + * await page.$$eval('input', (elements: HTMLInputElement[]) => { + * return elements.map(e => e.value); + * }); + * ``` + * + * The compiler should be able to infer the return type + * from the `pageFunction` you provide. If it is unable to, you can use the generic + * type to tell the compiler what return type you expect from `$$eval`: + * + * @example + * + * ``` + * // The compiler can infer the return type in this case, but if it can't + * // or if you want to be more explicit, provide it as the generic type. + * const allInputValues = await page.$$eval<string[]>( + * 'input', (elements: HTMLInputElement[]) => elements.map(e => e.textContent) + * ); + * ``` + * + * @param selector the + * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors | selector} + * to query for + * @param pageFunction the function to be evaluated in the page context. Will + * be passed the result of `Array.from(document.querySelectorAll(selector))` + * as its first argument. + * @param args any additional arguments to pass through to `pageFunction`. + * + * @returns The result of calling `pageFunction`. If it returns an element it + * is wrapped in an {@link ElementHandle}, else the raw value itself is + * returned. + */ + async $$eval<ReturnType>( + selector: string, + pageFunction: ( + elements: Element[], + /* These have to be typed as unknown[] for the same reason as the $eval + * definition above, please see that comment for more details and the TODO + * that will improve things. + */ + ...args: unknown[] + ) => ReturnType | Promise<ReturnType>, + ...args: SerializableOrJSHandle[] + ): Promise<WrapElementHandle<ReturnType>> { + return this.mainFrame().$$eval<ReturnType>(selector, pageFunction, ...args); + } + + async $$(selector: string): Promise<ElementHandle[]> { + return this.mainFrame().$$(selector); + } + + async $x(expression: string): Promise<ElementHandle[]> { + return this.mainFrame().$x(expression); + } + + /** + * If no URLs are specified, this method returns cookies for the current page + * URL. If URLs are specified, only cookies for those URLs are returned. + */ + async cookies(...urls: string[]): Promise<Protocol.Network.Cookie[]> { + const originalCookies = ( + await this._client.send('Network.getCookies', { + urls: urls.length ? urls : [this.url()], + }) + ).cookies; + + const unsupportedCookieAttributes = ['priority']; + const filterUnsupportedAttributes = ( + cookie: Protocol.Network.Cookie + ): Protocol.Network.Cookie => { + for (const attr of unsupportedCookieAttributes) delete cookie[attr]; + return cookie; + }; + return originalCookies.map(filterUnsupportedAttributes); + } + + async deleteCookie( + ...cookies: Protocol.Network.DeleteCookiesRequest[] + ): Promise<void> { + const pageURL = this.url(); + for (const cookie of cookies) { + const item = Object.assign({}, cookie); + if (!cookie.url && pageURL.startsWith('http')) item.url = pageURL; + await this._client.send('Network.deleteCookies', item); + } + } + + async setCookie(...cookies: Protocol.Network.CookieParam[]): Promise<void> { + const pageURL = this.url(); + const startsWithHTTP = pageURL.startsWith('http'); + const items = cookies.map((cookie) => { + const item = Object.assign({}, cookie); + if (!item.url && startsWithHTTP) item.url = pageURL; + assert( + item.url !== 'about:blank', + `Blank page can not have cookie "${item.name}"` + ); + assert( + !String.prototype.startsWith.call(item.url || '', 'data:'), + `Data URL page can not have cookie "${item.name}"` + ); + return item; + }); + await this.deleteCookie(...items); + if (items.length) + await this._client.send('Network.setCookies', { cookies: items }); + } + + async addScriptTag(options: { + url?: string; + path?: string; + content?: string; + type?: string; + }): Promise<ElementHandle> { + return this.mainFrame().addScriptTag(options); + } + + async addStyleTag(options: { + url?: string; + path?: string; + content?: string; + }): Promise<ElementHandle> { + return this.mainFrame().addStyleTag(options); + } + + async exposeFunction( + name: string, + puppeteerFunction: Function + ): Promise<void> { + if (this._pageBindings.has(name)) + throw new Error( + `Failed to add page binding with name ${name}: window['${name}'] already exists!` + ); + this._pageBindings.set(name, puppeteerFunction); + + const expression = helper.pageBindingInitString('exposedFun', name); + await this._client.send('Runtime.addBinding', { name: name }); + await this._client.send('Page.addScriptToEvaluateOnNewDocument', { + source: expression, + }); + await Promise.all( + this.frames().map((frame) => frame.evaluate(expression).catch(debugError)) + ); + } + + async authenticate(credentials: Credentials): Promise<void> { + return this._frameManager.networkManager().authenticate(credentials); + } + + async setExtraHTTPHeaders(headers: Record<string, string>): Promise<void> { + return this._frameManager.networkManager().setExtraHTTPHeaders(headers); + } + + async setUserAgent(userAgent: string): Promise<void> { + return this._frameManager.networkManager().setUserAgent(userAgent); + } + + async metrics(): Promise<Metrics> { + const response = await this._client.send('Performance.getMetrics'); + return this._buildMetricsObject(response.metrics); + } + + private _emitMetrics(event: Protocol.Performance.MetricsEvent): void { + this.emit(PageEmittedEvents.Metrics, { + title: event.title, + metrics: this._buildMetricsObject(event.metrics), + }); + } + + private _buildMetricsObject( + metrics?: Protocol.Performance.Metric[] + ): Metrics { + const result = {}; + for (const metric of metrics || []) { + if (supportedMetrics.has(metric.name)) result[metric.name] = metric.value; + } + return result; + } + + private _handleException( + exceptionDetails: Protocol.Runtime.ExceptionDetails + ): void { + const message = helper.getExceptionMessage(exceptionDetails); + const err = new Error(message); + err.stack = ''; // Don't report clientside error with a node stack attached + this.emit(PageEmittedEvents.PageError, err); + } + + private async _onConsoleAPI( + event: Protocol.Runtime.ConsoleAPICalledEvent + ): Promise<void> { + if (event.executionContextId === 0) { + // DevTools protocol stores the last 1000 console messages. These + // messages are always reported even for removed execution contexts. In + // this case, they are marked with executionContextId = 0 and are + // reported upon enabling Runtime agent. + // + // Ignore these messages since: + // - there's no execution context we can use to operate with message + // arguments + // - these messages are reported before Puppeteer clients can subscribe + // to the 'console' + // page event. + // + // @see https://github.com/puppeteer/puppeteer/issues/3865 + return; + } + const context = this._frameManager.executionContextById( + event.executionContextId + ); + const values = event.args.map((arg) => createJSHandle(context, arg)); + this._addConsoleMessage(event.type, values, event.stackTrace); + } + + private async _onBindingCalled( + event: Protocol.Runtime.BindingCalledEvent + ): Promise<void> { + let payload: { type: string; name: string; seq: number; args: unknown[] }; + try { + payload = JSON.parse(event.payload); + } catch { + // The binding was either called by something in the page or it was + // called before our wrapper was initialized. + return; + } + const { type, name, seq, args } = payload; + if (type !== 'exposedFun' || !this._pageBindings.has(name)) return; + let expression = null; + try { + const result = await this._pageBindings.get(name)(...args); + expression = helper.pageBindingDeliverResultString(name, seq, result); + } catch (error) { + if (error instanceof Error) + expression = helper.pageBindingDeliverErrorString( + name, + seq, + error.message, + error.stack + ); + else + expression = helper.pageBindingDeliverErrorValueString( + name, + seq, + error + ); + } + this._client + .send('Runtime.evaluate', { + expression, + contextId: event.executionContextId, + }) + .catch(debugError); + } + + private _addConsoleMessage( + type: ConsoleMessageType, + args: JSHandle[], + stackTrace?: Protocol.Runtime.StackTrace + ): void { + if (!this.listenerCount(PageEmittedEvents.Console)) { + args.forEach((arg) => arg.dispose()); + return; + } + const textTokens = []; + for (const arg of args) { + const remoteObject = arg._remoteObject; + if (remoteObject.objectId) textTokens.push(arg.toString()); + else textTokens.push(helper.valueFromRemoteObject(remoteObject)); + } + const stackTraceLocations = []; + if (stackTrace) { + for (const callFrame of stackTrace.callFrames) { + stackTraceLocations.push({ + url: callFrame.url, + lineNumber: callFrame.lineNumber, + columnNumber: callFrame.columnNumber, + }); + } + } + const message = new ConsoleMessage( + type, + textTokens.join(' '), + args, + stackTraceLocations + ); + this.emit(PageEmittedEvents.Console, message); + } + + private _onDialog(event: Protocol.Page.JavascriptDialogOpeningEvent): void { + let dialogType = null; + const validDialogTypes = new Set<Protocol.Page.DialogType>([ + 'alert', + 'confirm', + 'prompt', + 'beforeunload', + ]); + + if (validDialogTypes.has(event.type)) { + dialogType = event.type as Protocol.Page.DialogType; + } + assert(dialogType, 'Unknown javascript dialog type: ' + event.type); + + const dialog = new Dialog( + this._client, + dialogType, + event.message, + event.defaultPrompt + ); + this.emit(PageEmittedEvents.Dialog, dialog); + } + + url(): string { + return this.mainFrame().url(); + } + + async content(): Promise<string> { + return await this._frameManager.mainFrame().content(); + } + + async setContent(html: string, options: WaitForOptions = {}): Promise<void> { + await this._frameManager.mainFrame().setContent(html, options); + } + + async goto( + url: string, + options: WaitForOptions & { referer?: string } = {} + ): Promise<HTTPResponse> { + return await this._frameManager.mainFrame().goto(url, options); + } + + async reload(options?: WaitForOptions): Promise<HTTPResponse | null> { + const result = await Promise.all<HTTPResponse, void>([ + this.waitForNavigation(options), + this._client.send('Page.reload'), + ]); + + return result[0]; + } + + async waitForNavigation( + options: WaitForOptions = {} + ): Promise<HTTPResponse | null> { + return await this._frameManager.mainFrame().waitForNavigation(options); + } + + private _sessionClosePromise(): Promise<Error> { + if (!this._disconnectPromise) + this._disconnectPromise = new Promise((fulfill) => + this._client.once(CDPSessionEmittedEvents.Disconnected, () => + fulfill(new Error('Target closed')) + ) + ); + return this._disconnectPromise; + } + + async waitForRequest( + urlOrPredicate: string | Function, + options: { timeout?: number } = {} + ): Promise<HTTPRequest> { + const { timeout = this._timeoutSettings.timeout() } = options; + return helper.waitForEvent( + this._frameManager.networkManager(), + NetworkManagerEmittedEvents.Request, + (request) => { + if (helper.isString(urlOrPredicate)) + return urlOrPredicate === request.url(); + if (typeof urlOrPredicate === 'function') + return !!urlOrPredicate(request); + return false; + }, + timeout, + this._sessionClosePromise() + ); + } + + async waitForResponse( + urlOrPredicate: string | Function, + options: { timeout?: number } = {} + ): Promise<HTTPResponse> { + const { timeout = this._timeoutSettings.timeout() } = options; + return helper.waitForEvent( + this._frameManager.networkManager(), + NetworkManagerEmittedEvents.Response, + (response) => { + if (helper.isString(urlOrPredicate)) + return urlOrPredicate === response.url(); + if (typeof urlOrPredicate === 'function') + return !!urlOrPredicate(response); + return false; + }, + timeout, + this._sessionClosePromise() + ); + } + + async goBack(options: WaitForOptions = {}): Promise<HTTPResponse | null> { + return this._go(-1, options); + } + + async goForward(options: WaitForOptions = {}): Promise<HTTPResponse | null> { + return this._go(+1, options); + } + + private async _go( + delta: number, + options: WaitForOptions + ): Promise<HTTPResponse | null> { + const history = await this._client.send('Page.getNavigationHistory'); + const entry = history.entries[history.currentIndex + delta]; + if (!entry) return null; + const result = await Promise.all([ + this.waitForNavigation(options), + this._client.send('Page.navigateToHistoryEntry', { entryId: entry.id }), + ]); + return result[0]; + } + + async bringToFront(): Promise<void> { + await this._client.send('Page.bringToFront'); + } + + async emulate(options: { + viewport: Viewport; + userAgent: string; + }): Promise<void> { + await Promise.all([ + this.setViewport(options.viewport), + this.setUserAgent(options.userAgent), + ]); + } + + async setJavaScriptEnabled(enabled: boolean): Promise<void> { + if (this._javascriptEnabled === enabled) return; + this._javascriptEnabled = enabled; + await this._client.send('Emulation.setScriptExecutionDisabled', { + value: !enabled, + }); + } + + async setBypassCSP(enabled: boolean): Promise<void> { + await this._client.send('Page.setBypassCSP', { enabled }); + } + + async emulateMediaType(type?: string): Promise<void> { + assert( + type === 'screen' || type === 'print' || type === null, + 'Unsupported media type: ' + type + ); + await this._client.send('Emulation.setEmulatedMedia', { + media: type || '', + }); + } + + async emulateMediaFeatures(features?: MediaFeature[]): Promise<void> { + if (features === null) + await this._client.send('Emulation.setEmulatedMedia', { features: null }); + if (Array.isArray(features)) { + features.every((mediaFeature) => { + const name = mediaFeature.name; + assert( + /^prefers-(?:color-scheme|reduced-motion)$/.test(name), + 'Unsupported media feature: ' + name + ); + return true; + }); + await this._client.send('Emulation.setEmulatedMedia', { + features: features, + }); + } + } + + async emulateTimezone(timezoneId?: string): Promise<void> { + try { + await this._client.send('Emulation.setTimezoneOverride', { + timezoneId: timezoneId || '', + }); + } catch (error) { + if (error.message.includes('Invalid timezone')) + throw new Error(`Invalid timezone ID: ${timezoneId}`); + throw error; + } + } + + /** + * Emulates the idle state. + * If no arguments set, clears idle state emulation. + * + * @example + * ```js + * // set idle emulation + * await page.emulateIdleState({isUserActive: true, isScreenUnlocked: false}); + * + * // do some checks here + * ... + * + * // clear idle emulation + * await page.emulateIdleState(); + * ``` + * + * @param overrides Mock idle state. If not set, clears idle overrides + * @param isUserActive Mock isUserActive + * @param isScreenUnlocked Mock isScreenUnlocked + */ + async emulateIdleState(overrides?: { + isUserActive: boolean; + isScreenUnlocked: boolean; + }): Promise<void> { + if (overrides) { + await this._client.send('Emulation.setIdleOverride', { + isUserActive: overrides.isUserActive, + isScreenUnlocked: overrides.isScreenUnlocked, + }); + } else { + await this._client.send('Emulation.clearIdleOverride'); + } + } + + /** + * Simulates the given vision deficiency on the page. + * + * @example + * ```js + * const puppeteer = require('puppeteer'); + * + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * await page.goto('https://v8.dev/blog/10-years'); + * + * await page.emulateVisionDeficiency('achromatopsia'); + * await page.screenshot({ path: 'achromatopsia.png' }); + * + * await page.emulateVisionDeficiency('deuteranopia'); + * await page.screenshot({ path: 'deuteranopia.png' }); + * + * await page.emulateVisionDeficiency('blurredVision'); + * await page.screenshot({ path: 'blurred-vision.png' }); + * + * await browser.close(); + * })(); + * ``` + * + * @param type - the type of deficiency to simulate, or `'none'` to reset. + */ + async emulateVisionDeficiency( + type?: Protocol.Emulation.SetEmulatedVisionDeficiencyRequest['type'] + ): Promise<void> { + const visionDeficiencies = new Set< + Protocol.Emulation.SetEmulatedVisionDeficiencyRequest['type'] + >([ + 'none', + 'achromatopsia', + 'blurredVision', + 'deuteranopia', + 'protanopia', + 'tritanopia', + ]); + try { + assert( + !type || visionDeficiencies.has(type), + `Unsupported vision deficiency: ${type}` + ); + await this._client.send('Emulation.setEmulatedVisionDeficiency', { + type: type || 'none', + }); + } catch (error) { + throw error; + } + } + + async setViewport(viewport: Viewport): Promise<void> { + const needsReload = await this._emulationManager.emulateViewport(viewport); + this._viewport = viewport; + if (needsReload) await this.reload(); + } + + viewport(): Viewport | null { + return this._viewport; + } + + /** + * @remarks + * + * Evaluates a function in the page's context and returns the result. + * + * If the function passed to `page.evaluteHandle` returns a Promise, the + * function will wait for the promise to resolve and return its value. + * + * @example + * + * ```js + * const result = await frame.evaluate(() => { + * return Promise.resolve(8 * 7); + * }); + * console.log(result); // prints "56" + * ``` + * + * You can pass a string instead of a function (although functions are + * recommended as they are easier to debug and use with TypeScript): + * + * @example + * ``` + * const aHandle = await page.evaluate('1 + 2'); + * ``` + * + * To get the best TypeScript experience, you should pass in as the + * generic the type of `pageFunction`: + * + * ``` + * const aHandle = await page.evaluate<() => number>(() => 2); + * ``` + * + * @example + * + * {@link ElementHandle} instances (including {@link JSHandle}s) can be passed + * as arguments to the `pageFunction`: + * + * ``` + * const bodyHandle = await page.$('body'); + * const html = await page.evaluate(body => body.innerHTML, bodyHandle); + * await bodyHandle.dispose(); + * ``` + * + * @param pageFunction - a function that is run within the page + * @param args - arguments to be passed to the pageFunction + * + * @returns the return value of `pageFunction`. + */ + async evaluate<T extends EvaluateFn>( + pageFunction: T, + ...args: SerializableOrJSHandle[] + ): Promise<UnwrapPromiseLike<EvaluateFnReturnType<T>>> { + return this._frameManager.mainFrame().evaluate<T>(pageFunction, ...args); + } + + async evaluateOnNewDocument( + pageFunction: Function | string, + ...args: unknown[] + ): Promise<void> { + const source = helper.evaluationString(pageFunction, ...args); + await this._client.send('Page.addScriptToEvaluateOnNewDocument', { + source, + }); + } + + async setCacheEnabled(enabled = true): Promise<void> { + await this._frameManager.networkManager().setCacheEnabled(enabled); + } + + async screenshot( + options: ScreenshotOptions = {} + ): Promise<Buffer | string | void> { + let screenshotType = null; + // options.type takes precedence over inferring the type from options.path + // because it may be a 0-length file with no extension created beforehand + // (i.e. as a temp file). + if (options.type) { + assert( + options.type === 'png' || options.type === 'jpeg', + 'Unknown options.type value: ' + options.type + ); + screenshotType = options.type; + } else if (options.path) { + const filePath = options.path; + const extension = filePath + .slice(filePath.lastIndexOf('.') + 1) + .toLowerCase(); + if (extension === 'png') screenshotType = 'png'; + else if (extension === 'jpg' || extension === 'jpeg') + screenshotType = 'jpeg'; + assert( + screenshotType, + `Unsupported screenshot type for extension \`.${extension}\`` + ); + } + + if (!screenshotType) screenshotType = 'png'; + + if (options.quality) { + assert( + screenshotType === 'jpeg', + 'options.quality is unsupported for the ' + + screenshotType + + ' screenshots' + ); + assert( + typeof options.quality === 'number', + 'Expected options.quality to be a number but found ' + + typeof options.quality + ); + assert( + Number.isInteger(options.quality), + 'Expected options.quality to be an integer' + ); + assert( + options.quality >= 0 && options.quality <= 100, + 'Expected options.quality to be between 0 and 100 (inclusive), got ' + + options.quality + ); + } + assert( + !options.clip || !options.fullPage, + 'options.clip and options.fullPage are exclusive' + ); + if (options.clip) { + assert( + typeof options.clip.x === 'number', + 'Expected options.clip.x to be a number but found ' + + typeof options.clip.x + ); + assert( + typeof options.clip.y === 'number', + 'Expected options.clip.y to be a number but found ' + + typeof options.clip.y + ); + assert( + typeof options.clip.width === 'number', + 'Expected options.clip.width to be a number but found ' + + typeof options.clip.width + ); + assert( + typeof options.clip.height === 'number', + 'Expected options.clip.height to be a number but found ' + + typeof options.clip.height + ); + assert( + options.clip.width !== 0, + 'Expected options.clip.width not to be 0.' + ); + assert( + options.clip.height !== 0, + 'Expected options.clip.height not to be 0.' + ); + } + return this._screenshotTaskQueue.postTask(() => + this._screenshotTask(screenshotType, options) + ); + } + + private async _screenshotTask( + format: 'png' | 'jpeg', + options?: ScreenshotOptions + ): Promise<Buffer | string> { + await this._client.send('Target.activateTarget', { + targetId: this._target._targetId, + }); + let clip = options.clip ? processClip(options.clip) : undefined; + + if (options.fullPage) { + const metrics = await this._client.send('Page.getLayoutMetrics'); + const width = Math.ceil(metrics.contentSize.width); + const height = Math.ceil(metrics.contentSize.height); + + // Overwrite clip for full page at all times. + clip = { x: 0, y: 0, width, height, scale: 1 }; + const { isMobile = false, deviceScaleFactor = 1, isLandscape = false } = + this._viewport || {}; + const screenOrientation: Protocol.Emulation.ScreenOrientation = isLandscape + ? { angle: 90, type: 'landscapePrimary' } + : { angle: 0, type: 'portraitPrimary' }; + await this._client.send('Emulation.setDeviceMetricsOverride', { + mobile: isMobile, + width, + height, + deviceScaleFactor, + screenOrientation, + }); + } + const shouldSetDefaultBackground = + options.omitBackground && format === 'png'; + if (shouldSetDefaultBackground) + await this._client.send('Emulation.setDefaultBackgroundColorOverride', { + color: { r: 0, g: 0, b: 0, a: 0 }, + }); + const result = await this._client.send('Page.captureScreenshot', { + format, + quality: options.quality, + clip, + }); + if (shouldSetDefaultBackground) + await this._client.send('Emulation.setDefaultBackgroundColorOverride'); + + if (options.fullPage && this._viewport) + await this.setViewport(this._viewport); + + const buffer = + options.encoding === 'base64' + ? result.data + : Buffer.from(result.data, 'base64'); + if (!isNode && options.path) { + throw new Error( + 'Screenshots can only be written to a file path in a Node environment.' + ); + } + const fs = await helper.importFSModule(); + if (options.path) await fs.promises.writeFile(options.path, buffer); + return buffer; + + function processClip( + clip: ScreenshotClip + ): ScreenshotClip & { scale: number } { + const x = Math.round(clip.x); + const y = Math.round(clip.y); + const width = Math.round(clip.width + clip.x - x); + const height = Math.round(clip.height + clip.y - y); + return { x, y, width, height, scale: 1 }; + } + } + + /** + * Generatees a PDF of the page with the `print` CSS media type. + * @remarks + * + * IMPORTANT: PDF generation is only supported in Chrome headless mode. + * + * To generate a PDF with the `screen` media type, call + * {@link Page.emulateMediaType | `page.emulateMediaType('screen')`} before + * calling `page.pdf()`. + * + * By default, `page.pdf()` generates a pdf with modified colors for printing. + * Use the + * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/-webkit-print-color-adjust | `-webkit-print-color-adjust`} + * property to force rendering of exact colors. + * + * + * @param options - options for generating the PDF. + */ + async pdf(options: PDFOptions = {}): Promise<Buffer> { + const { + scale = 1, + displayHeaderFooter = false, + headerTemplate = '', + footerTemplate = '', + printBackground = false, + landscape = false, + pageRanges = '', + preferCSSPageSize = false, + margin = {}, + path = null, + } = options; + + let paperWidth = 8.5; + let paperHeight = 11; + if (options.format) { + const format = paperFormats[options.format.toLowerCase()]; + assert(format, 'Unknown paper format: ' + options.format); + paperWidth = format.width; + paperHeight = format.height; + } else { + paperWidth = convertPrintParameterToInches(options.width) || paperWidth; + paperHeight = + convertPrintParameterToInches(options.height) || paperHeight; + } + + const marginTop = convertPrintParameterToInches(margin.top) || 0; + const marginLeft = convertPrintParameterToInches(margin.left) || 0; + const marginBottom = convertPrintParameterToInches(margin.bottom) || 0; + const marginRight = convertPrintParameterToInches(margin.right) || 0; + + const result = await this._client.send('Page.printToPDF', { + transferMode: 'ReturnAsStream', + landscape, + displayHeaderFooter, + headerTemplate, + footerTemplate, + printBackground, + scale, + paperWidth, + paperHeight, + marginTop, + marginBottom, + marginLeft, + marginRight, + pageRanges, + preferCSSPageSize, + }); + return await helper.readProtocolStream(this._client, result.stream, path); + } + + async title(): Promise<string> { + return this.mainFrame().title(); + } + + async close( + options: { runBeforeUnload?: boolean } = { runBeforeUnload: undefined } + ): Promise<void> { + assert( + !!this._client._connection, + 'Protocol error: Connection closed. Most likely the page has been closed.' + ); + const runBeforeUnload = !!options.runBeforeUnload; + if (runBeforeUnload) { + await this._client.send('Page.close'); + } else { + await this._client._connection.send('Target.closeTarget', { + targetId: this._target._targetId, + }); + await this._target._isClosedPromise; + } + } + + isClosed(): boolean { + return this._closed; + } + + get mouse(): Mouse { + return this._mouse; + } + + click( + selector: string, + options: { + delay?: number; + button?: MouseButton; + clickCount?: number; + } = {} + ): Promise<void> { + return this.mainFrame().click(selector, options); + } + + focus(selector: string): Promise<void> { + return this.mainFrame().focus(selector); + } + + hover(selector: string): Promise<void> { + return this.mainFrame().hover(selector); + } + + select(selector: string, ...values: string[]): Promise<string[]> { + return this.mainFrame().select(selector, ...values); + } + + tap(selector: string): Promise<void> { + return this.mainFrame().tap(selector); + } + + type( + selector: string, + text: string, + options?: { delay: number } + ): Promise<void> { + return this.mainFrame().type(selector, text, options); + } + + /** + * @remarks + * + * This method behaves differently depending on the first parameter. If it's a + * `string`, it will be treated as a `selector` or `xpath` (if the string + * starts with `//`). This method then is a shortcut for + * {@link Page.waitForSelector} or {@link Page.waitForXPath}. + * + * If the first argument is a function this method is a shortcut for + * {@link Page.waitForFunction}. + * + * If the first argument is a `number`, it's treated as a timeout in + * milliseconds and the method returns a promise which resolves after the + * timeout. + * + * @param selectorOrFunctionOrTimeout - a selector, predicate or timeout to + * wait for. + * @param options - optional waiting parameters. + * @param args - arguments to pass to `pageFunction`. + * + * @deprecated Don't use this method directly. Instead use the more explicit + * methods available: {@link Page.waitForSelector}, + * {@link Page.waitForXPath}, {@link Page.waitForFunction} or + * {@link Page.waitForTimeout}. + */ + waitFor( + selectorOrFunctionOrTimeout: string | number | Function, + options: { + visible?: boolean; + hidden?: boolean; + timeout?: number; + polling?: string | number; + } = {}, + ...args: SerializableOrJSHandle[] + ): Promise<JSHandle> { + return this.mainFrame().waitFor( + selectorOrFunctionOrTimeout, + options, + ...args + ); + } + + /** + * Causes your script to wait for the given number of milliseconds. + * + * @remarks + * + * It's generally recommended to not wait for a number of seconds, but instead + * use {@link Page.waitForSelector}, {@link Page.waitForXPath} or + * {@link Page.waitForFunction} to wait for exactly the conditions you want. + * + * @example + * + * Wait for 1 second: + * + * ``` + * await page.waitForTimeout(1000); + * ``` + * + * @param milliseconds - the number of milliseconds to wait. + */ + waitForTimeout(milliseconds: number): Promise<void> { + return this.mainFrame().waitForTimeout(milliseconds); + } + + waitForSelector( + selector: string, + options: { + visible?: boolean; + hidden?: boolean; + timeout?: number; + } = {} + ): Promise<ElementHandle | null> { + return this.mainFrame().waitForSelector(selector, options); + } + + waitForXPath( + xpath: string, + options: { + visible?: boolean; + hidden?: boolean; + timeout?: number; + } = {} + ): Promise<ElementHandle | null> { + return this.mainFrame().waitForXPath(xpath, options); + } + + waitForFunction( + pageFunction: Function | string, + options: { + timeout?: number; + polling?: string | number; + } = {}, + ...args: SerializableOrJSHandle[] + ): Promise<JSHandle> { + return this.mainFrame().waitForFunction(pageFunction, options, ...args); + } +} + +const supportedMetrics = new Set<string>([ + 'Timestamp', + 'Documents', + 'Frames', + 'JSEventListeners', + 'Nodes', + 'LayoutCount', + 'RecalcStyleCount', + 'LayoutDuration', + 'RecalcStyleDuration', + 'ScriptDuration', + 'TaskDuration', + 'JSHeapUsedSize', + 'JSHeapTotalSize', +]); + +const unitToPixels = { + px: 1, + in: 96, + cm: 37.8, + mm: 3.78, +}; + +function convertPrintParameterToInches( + parameter?: string | number +): number | undefined { + if (typeof parameter === 'undefined') return undefined; + let pixels; + if (helper.isNumber(parameter)) { + // Treat numbers as pixel values to be aligned with phantom's paperSize. + pixels = /** @type {number} */ parameter; + } else if (helper.isString(parameter)) { + const text = /** @type {string} */ parameter; + let unit = text.substring(text.length - 2).toLowerCase(); + let valueText = ''; + if (unitToPixels.hasOwnProperty(unit)) { + valueText = text.substring(0, text.length - 2); + } else { + // In case of unknown unit try to parse the whole parameter as number of pixels. + // This is consistent with phantom's paperSize behavior. + unit = 'px'; + valueText = text; + } + const value = Number(valueText); + assert(!isNaN(value), 'Failed to parse parameter value: ' + text); + pixels = value * unitToPixels[unit]; + } else { + throw new Error( + 'page.pdf() Cannot handle parameter type: ' + typeof parameter + ); + } + return pixels / 96; +} diff --git a/remote/test/puppeteer/src/common/Product.ts b/remote/test/puppeteer/src/common/Product.ts new file mode 100644 index 0000000000..58a62fad3e --- /dev/null +++ b/remote/test/puppeteer/src/common/Product.ts @@ -0,0 +1,21 @@ +/** + * Copyright 2020 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. + */ + +/** + * Supported products. + * @public + */ +export type Product = 'chrome' | 'firefox'; diff --git a/remote/test/puppeteer/src/common/Puppeteer.ts b/remote/test/puppeteer/src/common/Puppeteer.ts new file mode 100644 index 0000000000..0dc40d33b5 --- /dev/null +++ b/remote/test/puppeteer/src/common/Puppeteer.ts @@ -0,0 +1,169 @@ +/** + * 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. + */ +import { puppeteerErrors, PuppeteerErrors } from './Errors.js'; +import { ConnectionTransport } from './ConnectionTransport.js'; +import { devicesMap, DevicesMap } from './DeviceDescriptors.js'; +import { Browser } from './Browser.js'; +import { + registerCustomQueryHandler, + unregisterCustomQueryHandler, + customQueryHandlerNames, + clearCustomQueryHandlers, + CustomQueryHandler, +} from './QueryHandler.js'; +import { Product } from './Product.js'; +import { connectToBrowser, BrowserOptions } from './BrowserConnector.js'; + +/** + * Settings that are common to the Puppeteer class, regardless of enviroment. + * @internal + */ +export interface CommonPuppeteerSettings { + isPuppeteerCore: boolean; +} + +export interface ConnectOptions extends BrowserOptions { + browserWSEndpoint?: string; + browserURL?: string; + transport?: ConnectionTransport; + product?: Product; +} + +/** + * The main Puppeteer class. + * + * IMPORTANT: if you are using Puppeteer in a Node environment, you will get an + * instance of {@link PuppeteerNode} when you import or require `puppeteer`. + * That class extends `Puppeteer`, so has all the methods documented below as + * well as all that are defined on {@link PuppeteerNode}. + * @public + */ +export class Puppeteer { + protected _isPuppeteerCore: boolean; + protected _changedProduct = false; + + /** + * @internal + */ + constructor(settings: CommonPuppeteerSettings) { + this._isPuppeteerCore = settings.isPuppeteerCore; + } + + /** + * This method attaches Puppeteer to an existing browser instance. + * + * @remarks + * + * @param options - Set of configurable options to set on the browser. + * @returns Promise which resolves to browser instance. + */ + connect(options: ConnectOptions): Promise<Browser> { + return connectToBrowser(options); + } + + /** + * @remarks + * A list of devices to be used with `page.emulate(options)`. Actual list of devices can be found in {@link https://github.com/puppeteer/puppeteer/blob/main/src/common/DeviceDescriptors.ts | src/common/DeviceDescriptors.ts}. + * + * @example + * + * ```js + * const puppeteer = require('puppeteer'); + * const iPhone = puppeteer.devices['iPhone 6']; + * + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * await page.emulate(iPhone); + * await page.goto('https://www.google.com'); + * // other actions... + * await browser.close(); + * })(); + * ``` + * + */ + get devices(): DevicesMap { + return devicesMap; + } + + /** + * @remarks + * + * Puppeteer methods might throw errors if they are unable to fulfill a request. + * For example, `page.waitForSelector(selector[, options])` might fail if + * the selector doesn't match any nodes during the given timeframe. + * + * For certain types of errors Puppeteer uses specific error classes. + * These classes are available via `puppeteer.errors`. + * + * @example + * An example of handling a timeout error: + * ```js + * try { + * await page.waitForSelector('.foo'); + * } catch (e) { + * if (e instanceof puppeteer.errors.TimeoutError) { + * // Do something if this is a timeout. + * } + * } + * ``` + */ + get errors(): PuppeteerErrors { + return puppeteerErrors; + } + + /** + * Registers a {@link CustomQueryHandler | custom query handler}. After + * registration, the handler can be used everywhere where a selector is + * expected by prepending the selection string with `<name>/`. The name is + * only allowed to consist of lower- and upper case latin letters. + * @example + * ``` + * puppeteer.registerCustomQueryHandler('text', { … }); + * const aHandle = await page.$('text/…'); + * ``` + * @param name - The name that the custom query handler will be registered under. + * @param queryHandler - The {@link CustomQueryHandler | custom query handler} to + * register. + */ + registerCustomQueryHandler( + name: string, + queryHandler: CustomQueryHandler + ): void { + registerCustomQueryHandler(name, queryHandler); + } + + /** + * @param name - The name of the query handler to unregistered. + */ + unregisterCustomQueryHandler(name: string): void { + unregisterCustomQueryHandler(name); + } + + /** + * @returns a list with the names of all registered custom query handlers. + */ + customQueryHandlerNames(): string[] { + return customQueryHandlerNames(); + } + + /** + * Clears all registered handlers. + */ + clearCustomQueryHandlers(): void { + clearCustomQueryHandlers(); + } +} diff --git a/remote/test/puppeteer/src/common/PuppeteerViewport.ts b/remote/test/puppeteer/src/common/PuppeteerViewport.ts new file mode 100644 index 0000000000..f626ce8d2b --- /dev/null +++ b/remote/test/puppeteer/src/common/PuppeteerViewport.ts @@ -0,0 +1,23 @@ +/** + * Copyright 2020 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. + */ +export interface Viewport { + width: number; + height: number; + deviceScaleFactor?: number; + isMobile?: boolean; + isLandscape?: boolean; + hasTouch?: boolean; +} diff --git a/remote/test/puppeteer/src/common/QueryHandler.ts b/remote/test/puppeteer/src/common/QueryHandler.ts new file mode 100644 index 0000000000..b7984067ee --- /dev/null +++ b/remote/test/puppeteer/src/common/QueryHandler.ts @@ -0,0 +1,238 @@ +/** + * Copyright 2020 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. + */ + +import { WaitForSelectorOptions, DOMWorld } from './DOMWorld.js'; +import { ElementHandle, JSHandle } from './JSHandle.js'; +import { ariaHandler } from './AriaQueryHandler.js'; + +/** + * @internal + */ +export interface InternalQueryHandler { + queryOne?: ( + element: ElementHandle, + selector: string + ) => Promise<ElementHandle | null>; + waitFor?: ( + domWorld: DOMWorld, + selector: string, + options: WaitForSelectorOptions + ) => Promise<ElementHandle | null>; + queryAll?: ( + element: ElementHandle, + selector: string + ) => Promise<ElementHandle[]>; + queryAllArray?: ( + element: ElementHandle, + selector: string + ) => Promise<JSHandle>; +} + +/** + * Contains two functions `queryOne` and `queryAll` that can + * be {@link Puppeteer.registerCustomQueryHandler | registered} + * as alternative querying strategies. The functions `queryOne` and `queryAll` + * are executed in the page context. `queryOne` should take an `Element` and a + * selector string as argument and return a single `Element` or `null` if no + * element is found. `queryAll` takes the same arguments but should instead + * return a `NodeListOf<Element>` or `Array<Element>` with all the elements + * that match the given query selector. + * @public + */ +export interface CustomQueryHandler { + queryOne?: (element: Element | Document, selector: string) => Element | null; + queryAll?: ( + element: Element | Document, + selector: string + ) => Element[] | NodeListOf<Element>; +} + +function makeQueryHandler(handler: CustomQueryHandler): InternalQueryHandler { + const internalHandler: InternalQueryHandler = {}; + + if (handler.queryOne) { + internalHandler.queryOne = async (element, selector) => { + const jsHandle = await element.evaluateHandle(handler.queryOne, selector); + const elementHandle = jsHandle.asElement(); + if (elementHandle) return elementHandle; + await jsHandle.dispose(); + return null; + }; + internalHandler.waitFor = ( + domWorld: DOMWorld, + selector: string, + options: WaitForSelectorOptions + ) => domWorld.waitForSelectorInPage(handler.queryOne, selector, options); + } + + if (handler.queryAll) { + internalHandler.queryAll = async (element, selector) => { + const jsHandle = await element.evaluateHandle(handler.queryAll, selector); + const properties = await jsHandle.getProperties(); + await jsHandle.dispose(); + const result = []; + for (const property of properties.values()) { + const elementHandle = property.asElement(); + if (elementHandle) result.push(elementHandle); + } + return result; + }; + internalHandler.queryAllArray = async (element, selector) => { + const resultHandle = await element.evaluateHandle( + handler.queryAll, + selector + ); + const arrayHandle = await resultHandle.evaluateHandle( + (res: Element[] | NodeListOf<Element>) => Array.from(res) + ); + return arrayHandle; + }; + } + + return internalHandler; +} + +const _defaultHandler = makeQueryHandler({ + queryOne: (element: Element, selector: string) => + element.querySelector(selector), + queryAll: (element: Element, selector: string) => + element.querySelectorAll(selector), +}); + +const pierceHandler = makeQueryHandler({ + queryOne: (element, selector) => { + let found: Element | null = null; + const search = (root: Element | ShadowRoot) => { + const iter = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT); + do { + const currentNode = iter.currentNode as HTMLElement; + if (currentNode.shadowRoot) { + search(currentNode.shadowRoot); + } + if (currentNode instanceof ShadowRoot) { + continue; + } + if (!found && currentNode.matches(selector)) { + found = currentNode; + } + } while (!found && iter.nextNode()); + }; + if (element instanceof Document) { + element = element.documentElement; + } + search(element); + return found; + }, + + queryAll: (element, selector) => { + const result: Element[] = []; + const collect = (root: Element | ShadowRoot) => { + const iter = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT); + do { + const currentNode = iter.currentNode as HTMLElement; + if (currentNode.shadowRoot) { + collect(currentNode.shadowRoot); + } + if (currentNode instanceof ShadowRoot) { + continue; + } + if (currentNode.matches(selector)) { + result.push(currentNode); + } + } while (iter.nextNode()); + }; + if (element instanceof Document) { + element = element.documentElement; + } + collect(element); + return result; + }, +}); + +const _builtInHandlers = new Map([ + ['aria', ariaHandler], + ['pierce', pierceHandler], +]); +const _queryHandlers = new Map(_builtInHandlers); + +/** + * @internal + */ +export function registerCustomQueryHandler( + name: string, + handler: CustomQueryHandler +): void { + if (_queryHandlers.get(name)) + throw new Error(`A custom query handler named "${name}" already exists`); + + const isValidName = /^[a-zA-Z]+$/.test(name); + if (!isValidName) + throw new Error(`Custom query handler names may only contain [a-zA-Z]`); + + const internalHandler = makeQueryHandler(handler); + + _queryHandlers.set(name, internalHandler); +} + +/** + * @internal + */ +export function unregisterCustomQueryHandler(name: string): void { + if (_queryHandlers.has(name) && !_builtInHandlers.has(name)) { + _queryHandlers.delete(name); + } +} + +/** + * @internal + */ +export function customQueryHandlerNames(): string[] { + return [..._queryHandlers.keys()].filter( + (name) => !_builtInHandlers.has(name) + ); +} + +/** + * @internal + */ +export function clearCustomQueryHandlers(): void { + customQueryHandlerNames().forEach(unregisterCustomQueryHandler); +} + +/** + * @internal + */ +export function getQueryHandlerAndSelector( + selector: string +): { updatedSelector: string; queryHandler: InternalQueryHandler } { + const hasCustomQueryHandler = /^[a-zA-Z]+\//.test(selector); + if (!hasCustomQueryHandler) + return { updatedSelector: selector, queryHandler: _defaultHandler }; + + const index = selector.indexOf('/'); + const name = selector.slice(0, index); + const updatedSelector = selector.slice(index + 1); + const queryHandler = _queryHandlers.get(name); + if (!queryHandler) + throw new Error( + `Query set to use "${name}", but no query handler of that name was found` + ); + + return { + updatedSelector, + queryHandler, + }; +} diff --git a/remote/test/puppeteer/src/common/SecurityDetails.ts b/remote/test/puppeteer/src/common/SecurityDetails.ts new file mode 100644 index 0000000000..aceba1a3d0 --- /dev/null +++ b/remote/test/puppeteer/src/common/SecurityDetails.ts @@ -0,0 +1,88 @@ +/** + * Copyright 2020 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. + */ + +import { Protocol } from 'devtools-protocol'; + +/** + * The SecurityDetails class represents the security details of a + * response that was received over a secure connection. + * + * @public + */ +export class SecurityDetails { + private _subjectName: string; + private _issuer: string; + private _validFrom: number; + private _validTo: number; + private _protocol: string; + private _sanList: string[]; + + /** + * @internal + */ + constructor(securityPayload: Protocol.Network.SecurityDetails) { + this._subjectName = securityPayload.subjectName; + this._issuer = securityPayload.issuer; + this._validFrom = securityPayload.validFrom; + this._validTo = securityPayload.validTo; + this._protocol = securityPayload.protocol; + this._sanList = securityPayload.sanList; + } + + /** + * @returns The name of the issuer of the certificate. + */ + issuer(): string { + return this._issuer; + } + + /** + * @returns {@link https://en.wikipedia.org/wiki/Unix_time | Unix timestamp} + * marking the start of the certificate's validity. + */ + validFrom(): number { + return this._validFrom; + } + + /** + * @returns {@link https://en.wikipedia.org/wiki/Unix_time | Unix timestamp} + * marking the end of the certificate's validity. + */ + validTo(): number { + return this._validTo; + } + + /** + * @returns The security protocol being used, e.g. "TLS 1.2". + */ + protocol(): string { + return this._protocol; + } + + /** + * @returns The name of the subject to which the certificate was issued. + */ + subjectName(): string { + return this._subjectName; + } + + /** + * @returns The list of {@link https://en.wikipedia.org/wiki/Subject_Alternative_Name | subject alternative names (SANs)} of the certificate. + */ + subjectAlternativeNames(): string[] { + return this._sanList; + } +} diff --git a/remote/test/puppeteer/src/common/Target.ts b/remote/test/puppeteer/src/common/Target.ts new file mode 100644 index 0000000000..0e8296a633 --- /dev/null +++ b/remote/test/puppeteer/src/common/Target.ts @@ -0,0 +1,222 @@ +/** + * Copyright 2019 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. + */ + +import { Page, PageEmittedEvents } from './Page.js'; +import { WebWorker } from './WebWorker.js'; +import { CDPSession } from './Connection.js'; +import { Browser, BrowserContext } from './Browser.js'; +import { Viewport } from './PuppeteerViewport.js'; +import { Protocol } from 'devtools-protocol'; + +/** + * @public + */ +export class Target { + private _targetInfo: Protocol.Target.TargetInfo; + private _browserContext: BrowserContext; + + private _sessionFactory: () => Promise<CDPSession>; + private _ignoreHTTPSErrors: boolean; + private _defaultViewport?: Viewport; + private _pagePromise?: Promise<Page>; + private _workerPromise?: Promise<WebWorker>; + /** + * @internal + */ + _initializedPromise: Promise<boolean>; + /** + * @internal + */ + _initializedCallback: (x: boolean) => void; + /** + * @internal + */ + _isClosedPromise: Promise<void>; + /** + * @internal + */ + _closedCallback: () => void; + /** + * @internal + */ + _isInitialized: boolean; + /** + * @internal + */ + _targetId: string; + + /** + * @internal + */ + constructor( + targetInfo: Protocol.Target.TargetInfo, + browserContext: BrowserContext, + sessionFactory: () => Promise<CDPSession>, + ignoreHTTPSErrors: boolean, + defaultViewport: Viewport | null + ) { + this._targetInfo = targetInfo; + this._browserContext = browserContext; + this._targetId = targetInfo.targetId; + this._sessionFactory = sessionFactory; + this._ignoreHTTPSErrors = ignoreHTTPSErrors; + this._defaultViewport = defaultViewport; + /** @type {?Promise<!Puppeteer.Page>} */ + this._pagePromise = null; + /** @type {?Promise<!WebWorker>} */ + this._workerPromise = null; + this._initializedPromise = new Promise<boolean>( + (fulfill) => (this._initializedCallback = fulfill) + ).then(async (success) => { + if (!success) return false; + const opener = this.opener(); + if (!opener || !opener._pagePromise || this.type() !== 'page') + return true; + const openerPage = await opener._pagePromise; + if (!openerPage.listenerCount(PageEmittedEvents.Popup)) return true; + const popupPage = await this.page(); + openerPage.emit(PageEmittedEvents.Popup, popupPage); + return true; + }); + this._isClosedPromise = new Promise<void>( + (fulfill) => (this._closedCallback = fulfill) + ); + this._isInitialized = + this._targetInfo.type !== 'page' || this._targetInfo.url !== ''; + if (this._isInitialized) this._initializedCallback(true); + } + + /** + * Creates a Chrome Devtools Protocol session attached to the target. + */ + createCDPSession(): Promise<CDPSession> { + return this._sessionFactory(); + } + + /** + * If the target is not of type `"page"` or `"background_page"`, returns `null`. + */ + async page(): Promise<Page | null> { + if ( + (this._targetInfo.type === 'page' || + this._targetInfo.type === 'background_page' || + this._targetInfo.type === 'webview') && + !this._pagePromise + ) { + this._pagePromise = this._sessionFactory().then((client) => + Page.create( + client, + this, + this._ignoreHTTPSErrors, + this._defaultViewport + ) + ); + } + return this._pagePromise; + } + + /** + * If the target is not of type `"service_worker"` or `"shared_worker"`, returns `null`. + */ + async worker(): Promise<WebWorker | null> { + if ( + this._targetInfo.type !== 'service_worker' && + this._targetInfo.type !== 'shared_worker' + ) + return null; + if (!this._workerPromise) { + // TODO(einbinder): Make workers send their console logs. + this._workerPromise = this._sessionFactory().then( + (client) => + new WebWorker( + client, + this._targetInfo.url, + () => {} /* consoleAPICalled */, + () => {} /* exceptionThrown */ + ) + ); + } + return this._workerPromise; + } + + url(): string { + return this._targetInfo.url; + } + + /** + * Identifies what kind of target this is. + * + * @remarks + * + * See {@link https://developer.chrome.com/extensions/background_pages | docs} for more info about background pages. + */ + type(): + | 'page' + | 'background_page' + | 'service_worker' + | 'shared_worker' + | 'other' + | 'browser' + | 'webview' { + const type = this._targetInfo.type; + if ( + type === 'page' || + type === 'background_page' || + type === 'service_worker' || + type === 'shared_worker' || + type === 'browser' || + type === 'webview' + ) + return type; + return 'other'; + } + + /** + * Get the browser the target belongs to. + */ + browser(): Browser { + return this._browserContext.browser(); + } + + browserContext(): BrowserContext { + return this._browserContext; + } + + /** + * Get the target that opened this target. Top-level targets return `null`. + */ + opener(): Target | null { + const { openerId } = this._targetInfo; + if (!openerId) return null; + return this.browser()._targets.get(openerId); + } + + /** + * @internal + */ + _targetInfoChanged(targetInfo: Protocol.Target.TargetInfo): void { + this._targetInfo = targetInfo; + + if ( + !this._isInitialized && + (this._targetInfo.type !== 'page' || this._targetInfo.url !== '') + ) { + this._isInitialized = true; + this._initializedCallback(true); + return; + } + } +} diff --git a/remote/test/puppeteer/src/common/TimeoutSettings.ts b/remote/test/puppeteer/src/common/TimeoutSettings.ts new file mode 100644 index 0000000000..9c441498de --- /dev/null +++ b/remote/test/puppeteer/src/common/TimeoutSettings.ts @@ -0,0 +1,50 @@ +/** + * Copyright 2019 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 DEFAULT_TIMEOUT = 30000; + +/** + * @internal + */ +export class TimeoutSettings { + _defaultTimeout: number | null; + _defaultNavigationTimeout: number | null; + + constructor() { + this._defaultTimeout = null; + this._defaultNavigationTimeout = null; + } + + setDefaultTimeout(timeout: number): void { + this._defaultTimeout = timeout; + } + + setDefaultNavigationTimeout(timeout: number): void { + this._defaultNavigationTimeout = timeout; + } + + navigationTimeout(): number { + if (this._defaultNavigationTimeout !== null) + return this._defaultNavigationTimeout; + if (this._defaultTimeout !== null) return this._defaultTimeout; + return DEFAULT_TIMEOUT; + } + + timeout(): number { + if (this._defaultTimeout !== null) return this._defaultTimeout; + return DEFAULT_TIMEOUT; + } +} diff --git a/remote/test/puppeteer/src/common/Tracing.ts b/remote/test/puppeteer/src/common/Tracing.ts new file mode 100644 index 0000000000..ad075e9bac --- /dev/null +++ b/remote/test/puppeteer/src/common/Tracing.ts @@ -0,0 +1,118 @@ +/** + * 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. + */ +import { assert } from './assert.js'; +import { helper } from './helper.js'; +import { CDPSession } from './Connection.js'; + +/** + * @public + */ +export interface TracingOptions { + path?: string; + screenshots?: boolean; + categories?: string[]; +} + +/** + * The Tracing class exposes the tracing audit interface. + * @remarks + * You can use `tracing.start` and `tracing.stop` to create a trace file + * which can be opened in Chrome DevTools or {@link https://chromedevtools.github.io/timeline-viewer/ | timeline viewer}. + * + * @example + * ```js + * await page.tracing.start({path: 'trace.json'}); + * await page.goto('https://www.google.com'); + * await page.tracing.stop(); + * ``` + * + * @public + */ +export class Tracing { + _client: CDPSession; + _recording = false; + _path = ''; + + /** + * @internal + */ + constructor(client: CDPSession) { + this._client = client; + } + + /** + * Starts a trace for the current page. + * @remarks + * Only one trace can be active at a time per browser. + * @param options - Optional `TracingOptions`. + */ + async start(options: TracingOptions = {}): Promise<void> { + assert( + !this._recording, + 'Cannot start recording trace while already recording trace.' + ); + + const defaultCategories = [ + '-*', + 'devtools.timeline', + 'v8.execute', + 'disabled-by-default-devtools.timeline', + 'disabled-by-default-devtools.timeline.frame', + 'toplevel', + 'blink.console', + 'blink.user_timing', + 'latencyInfo', + 'disabled-by-default-devtools.timeline.stack', + 'disabled-by-default-v8.cpu_profiler', + 'disabled-by-default-v8.cpu_profiler.hires', + ]; + const { + path = null, + screenshots = false, + categories = defaultCategories, + } = options; + + if (screenshots) categories.push('disabled-by-default-devtools.screenshot'); + + this._path = path; + this._recording = true; + await this._client.send('Tracing.start', { + transferMode: 'ReturnAsStream', + categories: categories.join(','), + }); + } + + /** + * Stops a trace started with the `start` method. + * @returns Promise which resolves to buffer with trace data. + */ + async stop(): Promise<Buffer> { + let fulfill: (value: Buffer) => void; + let reject: (err: Error) => void; + const contentPromise = new Promise<Buffer>((x, y) => { + fulfill = x; + reject = y; + }); + this._client.once('Tracing.tracingComplete', (event) => { + helper + .readProtocolStream(this._client, event.stream, this._path) + .then(fulfill, reject); + }); + await this._client.send('Tracing.end'); + this._recording = false; + return contentPromise; + } +} diff --git a/remote/test/puppeteer/src/common/USKeyboardLayout.ts b/remote/test/puppeteer/src/common/USKeyboardLayout.ts new file mode 100644 index 0000000000..feb3a1f7f1 --- /dev/null +++ b/remote/test/puppeteer/src/common/USKeyboardLayout.ts @@ -0,0 +1,681 @@ +/** + * 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. + */ + +/** + * @internal + */ +export interface KeyDefinition { + keyCode?: number; + shiftKeyCode?: number; + key?: string; + shiftKey?: string; + code?: string; + text?: string; + shiftText?: string; + location?: number; +} + +/** + * All the valid keys that can be passed to functions that take user input, such + * as {@link Keyboard.press | keyboard.press } + * + * @public + */ +export type KeyInput = + | '0' + | '1' + | '2' + | '3' + | '4' + | '5' + | '6' + | '7' + | '8' + | '9' + | 'Power' + | 'Eject' + | 'Abort' + | 'Help' + | 'Backspace' + | 'Tab' + | 'Numpad5' + | 'NumpadEnter' + | 'Enter' + | '\r' + | '\n' + | 'ShiftLeft' + | 'ShiftRight' + | 'ControlLeft' + | 'ControlRight' + | 'AltLeft' + | 'AltRight' + | 'Pause' + | 'CapsLock' + | 'Escape' + | 'Convert' + | 'NonConvert' + | 'Space' + | 'Numpad9' + | 'PageUp' + | 'Numpad3' + | 'PageDown' + | 'End' + | 'Numpad1' + | 'Home' + | 'Numpad7' + | 'ArrowLeft' + | 'Numpad4' + | 'Numpad8' + | 'ArrowUp' + | 'ArrowRight' + | 'Numpad6' + | 'Numpad2' + | 'ArrowDown' + | 'Select' + | 'Open' + | 'PrintScreen' + | 'Insert' + | 'Numpad0' + | 'Delete' + | 'NumpadDecimal' + | 'Digit0' + | 'Digit1' + | 'Digit2' + | 'Digit3' + | 'Digit4' + | 'Digit5' + | 'Digit6' + | 'Digit7' + | 'Digit8' + | 'Digit9' + | 'KeyA' + | 'KeyB' + | 'KeyC' + | 'KeyD' + | 'KeyE' + | 'KeyF' + | 'KeyG' + | 'KeyH' + | 'KeyI' + | 'KeyJ' + | 'KeyK' + | 'KeyL' + | 'KeyM' + | 'KeyN' + | 'KeyO' + | 'KeyP' + | 'KeyQ' + | 'KeyR' + | 'KeyS' + | 'KeyT' + | 'KeyU' + | 'KeyV' + | 'KeyW' + | 'KeyX' + | 'KeyY' + | 'KeyZ' + | 'MetaLeft' + | 'MetaRight' + | 'ContextMenu' + | 'NumpadMultiply' + | 'NumpadAdd' + | 'NumpadSubtract' + | 'NumpadDivide' + | 'F1' + | 'F2' + | 'F3' + | 'F4' + | 'F5' + | 'F6' + | 'F7' + | 'F8' + | 'F9' + | 'F10' + | 'F11' + | 'F12' + | 'F13' + | 'F14' + | 'F15' + | 'F16' + | 'F17' + | 'F18' + | 'F19' + | 'F20' + | 'F21' + | 'F22' + | 'F23' + | 'F24' + | 'NumLock' + | 'ScrollLock' + | 'AudioVolumeMute' + | 'AudioVolumeDown' + | 'AudioVolumeUp' + | 'MediaTrackNext' + | 'MediaTrackPrevious' + | 'MediaStop' + | 'MediaPlayPause' + | 'Semicolon' + | 'Equal' + | 'NumpadEqual' + | 'Comma' + | 'Minus' + | 'Period' + | 'Slash' + | 'Backquote' + | 'BracketLeft' + | 'Backslash' + | 'BracketRight' + | 'Quote' + | 'AltGraph' + | 'Props' + | 'Cancel' + | 'Clear' + | 'Shift' + | 'Control' + | 'Alt' + | 'Accept' + | 'ModeChange' + | ' ' + | 'Print' + | 'Execute' + | '\u0000' + | 'a' + | 'b' + | 'c' + | 'd' + | 'e' + | 'f' + | 'g' + | 'h' + | 'i' + | 'j' + | 'k' + | 'l' + | 'm' + | 'n' + | 'o' + | 'p' + | 'q' + | 'r' + | 's' + | 't' + | 'u' + | 'v' + | 'w' + | 'x' + | 'y' + | 'z' + | 'Meta' + | '*' + | '+' + | '-' + | '/' + | ';' + | '=' + | ',' + | '.' + | '`' + | '[' + | '\\' + | ']' + | "'" + | 'Attn' + | 'CrSel' + | 'ExSel' + | 'EraseEof' + | 'Play' + | 'ZoomOut' + | ')' + | '!' + | '@' + | '#' + | '$' + | '%' + | '^' + | '&' + | '(' + | 'A' + | 'B' + | 'C' + | 'D' + | 'E' + | 'F' + | 'G' + | 'H' + | 'I' + | 'J' + | 'K' + | 'L' + | 'M' + | 'N' + | 'O' + | 'P' + | 'Q' + | 'R' + | 'S' + | 'T' + | 'U' + | 'V' + | 'W' + | 'X' + | 'Y' + | 'Z' + | ':' + | '<' + | '_' + | '>' + | '?' + | '~' + | '{' + | '|' + | '}' + | '"' + | 'SoftLeft' + | 'SoftRight' + | 'Camera' + | 'Call' + | 'EndCall' + | 'VolumeDown' + | 'VolumeUp'; + +/** + * @internal + */ +export const keyDefinitions: Readonly<Record<KeyInput, KeyDefinition>> = { + '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' }, + SoftLeft: { key: 'SoftLeft', code: 'SoftLeft', location: 4 }, + SoftRight: { key: 'SoftRight', code: 'SoftRight', location: 4 }, + Camera: { keyCode: 44, key: 'Camera', code: 'Camera', location: 4 }, + Call: { key: 'Call', code: 'Call', location: 4 }, + EndCall: { keyCode: 95, key: 'EndCall', code: 'EndCall', location: 4 }, + VolumeDown: { + keyCode: 182, + key: 'VolumeDown', + code: 'VolumeDown', + location: 4, + }, + VolumeUp: { keyCode: 183, key: 'VolumeUp', code: 'VolumeUp', location: 4 }, +}; diff --git a/remote/test/puppeteer/src/common/WebWorker.ts b/remote/test/puppeteer/src/common/WebWorker.ts new file mode 100644 index 0000000000..a4d415315e --- /dev/null +++ b/remote/test/puppeteer/src/common/WebWorker.ts @@ -0,0 +1,172 @@ +/** + * 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. + */ +import { EventEmitter } from './EventEmitter.js'; +import { debugError } from './helper.js'; +import { ExecutionContext } from './ExecutionContext.js'; +import { JSHandle } from './JSHandle.js'; +import { CDPSession } from './Connection.js'; +import { Protocol } from 'devtools-protocol'; +import { EvaluateHandleFn, SerializableOrJSHandle } from './EvalTypes.js'; + +/** + * @internal + */ +type ConsoleAPICalledCallback = ( + eventType: string, + handles: JSHandle[], + trace: Protocol.Runtime.StackTrace +) => void; + +/** + * @internal + */ +type ExceptionThrownCallback = ( + details: Protocol.Runtime.ExceptionDetails +) => void; +type JSHandleFactory = (obj: Protocol.Runtime.RemoteObject) => JSHandle; + +/** + * The WebWorker class represents a + * {@link https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API | WebWorker}. + * + * @remarks + * The events `workercreated` and `workerdestroyed` are emitted on the page + * object to signal the worker lifecycle. + * + * @example + * ```js + * page.on('workercreated', worker => console.log('Worker created: ' + worker.url())); + * page.on('workerdestroyed', worker => console.log('Worker destroyed: ' + worker.url())); + * + * console.log('Current workers:'); + * for (const worker of page.workers()) { + * console.log(' ' + worker.url()); + * } + * ``` + * + * @public + */ +export class WebWorker extends EventEmitter { + _client: CDPSession; + _url: string; + _executionContextPromise: Promise<ExecutionContext>; + _executionContextCallback: (value: ExecutionContext) => void; + + /** + * + * @internal + */ + constructor( + client: CDPSession, + url: string, + consoleAPICalled: ConsoleAPICalledCallback, + exceptionThrown: ExceptionThrownCallback + ) { + super(); + this._client = client; + this._url = url; + this._executionContextPromise = new Promise<ExecutionContext>( + (x) => (this._executionContextCallback = x) + ); + + let jsHandleFactory: JSHandleFactory; + this._client.once('Runtime.executionContextCreated', async (event) => { + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + jsHandleFactory = (remoteObject) => + new JSHandle(executionContext, client, remoteObject); + const executionContext = new ExecutionContext( + client, + event.context, + null + ); + this._executionContextCallback(executionContext); + }); + + // This might fail if the target is closed before we recieve all execution contexts. + this._client.send('Runtime.enable').catch(debugError); + this._client.on('Runtime.consoleAPICalled', (event) => + consoleAPICalled( + event.type, + event.args.map(jsHandleFactory), + event.stackTrace + ) + ); + this._client.on('Runtime.exceptionThrown', (exception) => + exceptionThrown(exception.exceptionDetails) + ); + } + + /** + * @returns The URL of this web worker. + */ + url(): string { + return this._url; + } + + /** + * Returns the ExecutionContext the WebWorker runs in + * @returns The ExecutionContext the web worker runs in. + */ + async executionContext(): Promise<ExecutionContext> { + return this._executionContextPromise; + } + + /** + * If the function passed to the `worker.evaluate` returns a Promise, then + * `worker.evaluate` would wait for the promise to resolve and return its + * value. If the function passed to the `worker.evaluate` returns a + * non-serializable value, then `worker.evaluate` resolves to `undefined`. + * DevTools Protocol also supports transferring some additional values that + * are not serializable by `JSON`: `-0`, `NaN`, `Infinity`, `-Infinity`, and + * bigint literals. + * Shortcut for `await worker.executionContext()).evaluate(pageFunction, ...args)`. + * + * @param pageFunction - Function to be evaluated in the worker context. + * @param args - Arguments to pass to `pageFunction`. + * @returns Promise which resolves to the return value of `pageFunction`. + */ + async evaluate<ReturnType extends any>( + pageFunction: Function | string, + ...args: any[] + ): Promise<ReturnType> { + return (await this._executionContextPromise).evaluate<ReturnType>( + pageFunction, + ...args + ); + } + + /** + * The only difference between `worker.evaluate` and `worker.evaluateHandle` + * is that `worker.evaluateHandle` returns in-page object (JSHandle). If the + * function passed to the `worker.evaluateHandle` returns a [Promise], then + * `worker.evaluateHandle` would wait for the promise to resolve and return + * its value. Shortcut for + * `await worker.executionContext()).evaluateHandle(pageFunction, ...args)` + * + * @param pageFunction - Function to be evaluated in the page context. + * @param args - Arguments to pass to `pageFunction`. + * @returns Promise which resolves to the return value of `pageFunction`. + */ + async evaluateHandle<HandlerType extends JSHandle = JSHandle>( + pageFunction: EvaluateHandleFn, + ...args: SerializableOrJSHandle[] + ): Promise<JSHandle> { + return (await this._executionContextPromise).evaluateHandle<HandlerType>( + pageFunction, + ...args + ); + } +} diff --git a/remote/test/puppeteer/src/common/assert.ts b/remote/test/puppeteer/src/common/assert.ts new file mode 100644 index 0000000000..6ba090ce26 --- /dev/null +++ b/remote/test/puppeteer/src/common/assert.ts @@ -0,0 +1,24 @@ +/** + * Copyright 2020 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. + */ + +/** + * Asserts that the given value is truthy. + * @param value + * @param message - the error message to throw if the value is not truthy. + */ +export const assert = (value: unknown, message?: string): void => { + if (!value) throw new Error(message); +}; diff --git a/remote/test/puppeteer/src/common/fetch.ts b/remote/test/puppeteer/src/common/fetch.ts new file mode 100644 index 0000000000..ae4b65c45f --- /dev/null +++ b/remote/test/puppeteer/src/common/fetch.ts @@ -0,0 +1,22 @@ +/** + * Copyright 2020 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. + */ + +import { isNode } from '../environment.js'; + +/* Use the global version if we're in the browser, else load the node-fetch module. */ +export const getFetch = async (): Promise<typeof fetch> => { + return isNode ? await import('node-fetch') : globalThis.fetch; +}; diff --git a/remote/test/puppeteer/src/common/helper.ts b/remote/test/puppeteer/src/common/helper.ts new file mode 100644 index 0000000000..d8ca9b4ef4 --- /dev/null +++ b/remote/test/puppeteer/src/common/helper.ts @@ -0,0 +1,389 @@ +/** + * 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. + */ +import { TimeoutError } from './Errors.js'; +import { debug } from './Debug.js'; +import { CDPSession } from './Connection.js'; +import { Protocol } from 'devtools-protocol'; +import { CommonEventEmitter } from './EventEmitter.js'; +import { assert } from './assert.js'; +import { isNode } from '../environment.js'; + +export const debugError = debug('puppeteer:error'); + +function getExceptionMessage( + exceptionDetails: Protocol.Runtime.ExceptionDetails +): string { + if (exceptionDetails.exception) + return ( + exceptionDetails.exception.description || exceptionDetails.exception.value + ); + let message = exceptionDetails.text; + if (exceptionDetails.stackTrace) { + for (const callframe of exceptionDetails.stackTrace.callFrames) { + const location = + callframe.url + + ':' + + callframe.lineNumber + + ':' + + callframe.columnNumber; + const functionName = callframe.functionName || '<anonymous>'; + message += `\n at ${functionName} (${location})`; + } + } + return message; +} + +function valueFromRemoteObject( + remoteObject: Protocol.Runtime.RemoteObject +): any { + assert(!remoteObject.objectId, 'Cannot extract value when objectId is given'); + if (remoteObject.unserializableValue) { + if (remoteObject.type === 'bigint' && typeof BigInt !== 'undefined') + return BigInt(remoteObject.unserializableValue.replace('n', '')); + switch (remoteObject.unserializableValue) { + case '-0': + return -0; + case 'NaN': + return NaN; + case 'Infinity': + return Infinity; + case '-Infinity': + return -Infinity; + default: + throw new Error( + 'Unsupported unserializable value: ' + + remoteObject.unserializableValue + ); + } + } + return remoteObject.value; +} + +async function releaseObject( + client: CDPSession, + remoteObject: Protocol.Runtime.RemoteObject +): Promise<void> { + if (!remoteObject.objectId) return; + await client + .send('Runtime.releaseObject', { objectId: remoteObject.objectId }) + .catch((error) => { + // Exceptions might happen in case of a page been navigated or closed. + // Swallow these since they are harmless and we don't leak anything in this case. + debugError(error); + }); +} + +export interface PuppeteerEventListener { + emitter: CommonEventEmitter; + eventName: string | symbol; + handler: (...args: any[]) => void; +} + +function addEventListener( + emitter: CommonEventEmitter, + eventName: string | symbol, + handler: (...args: any[]) => void +): PuppeteerEventListener { + emitter.on(eventName, handler); + return { emitter, eventName, handler }; +} + +function removeEventListeners( + listeners: Array<{ + emitter: CommonEventEmitter; + eventName: string | symbol; + handler: (...args: any[]) => void; + }> +): void { + for (const listener of listeners) + listener.emitter.removeListener(listener.eventName, listener.handler); + listeners.length = 0; +} + +function isString(obj: unknown): obj is string { + return typeof obj === 'string' || obj instanceof String; +} + +function isNumber(obj: unknown): obj is number { + return typeof obj === 'number' || obj instanceof Number; +} + +async function waitForEvent<T extends any>( + emitter: CommonEventEmitter, + eventName: string | symbol, + predicate: (event: T) => boolean, + timeout: number, + abortPromise: Promise<Error> +): Promise<T> { + let eventTimeout, resolveCallback, rejectCallback; + const promise = new Promise<T>((resolve, reject) => { + resolveCallback = resolve; + rejectCallback = reject; + }); + const listener = addEventListener(emitter, eventName, (event) => { + if (!predicate(event)) return; + resolveCallback(event); + }); + if (timeout) { + eventTimeout = setTimeout(() => { + rejectCallback( + new TimeoutError('Timeout exceeded while waiting for event') + ); + }, timeout); + } + function cleanup(): void { + removeEventListeners([listener]); + clearTimeout(eventTimeout); + } + const result = await Promise.race([promise, abortPromise]).then( + (r) => { + cleanup(); + return r; + }, + (error) => { + cleanup(); + throw error; + } + ); + if (result instanceof Error) throw result; + + return result; +} + +function evaluationString(fun: Function | string, ...args: unknown[]): string { + if (isString(fun)) { + assert(args.length === 0, 'Cannot evaluate a string with arguments'); + return fun; + } + + function serializeArgument(arg: unknown): string { + if (Object.is(arg, undefined)) return 'undefined'; + return JSON.stringify(arg); + } + + return `(${fun})(${args.map(serializeArgument).join(',')})`; +} + +function pageBindingInitString(type: string, name: string): string { + function addPageBinding(type: string, bindingName: string): void { + /* Cast window to any here as we're about to add properties to it + * via win[bindingName] which TypeScript doesn't like. + */ + const win = window as any; + const binding = win[bindingName]; + + win[bindingName] = (...args: unknown[]): Promise<unknown> => { + const me = window[bindingName]; + let callbacks = me.callbacks; + if (!callbacks) { + callbacks = new Map(); + me.callbacks = callbacks; + } + const seq = (me.lastSeq || 0) + 1; + me.lastSeq = seq; + const promise = new Promise((resolve, reject) => + callbacks.set(seq, { resolve, reject }) + ); + binding(JSON.stringify({ type, name: bindingName, seq, args })); + return promise; + }; + } + return evaluationString(addPageBinding, type, name); +} + +function pageBindingDeliverResultString( + name: string, + seq: number, + result: unknown +): string { + function deliverResult(name: string, seq: number, result: unknown): void { + window[name].callbacks.get(seq).resolve(result); + window[name].callbacks.delete(seq); + } + return evaluationString(deliverResult, name, seq, result); +} + +function pageBindingDeliverErrorString( + name: string, + seq: number, + message: string, + stack: string +): string { + function deliverError( + name: string, + seq: number, + message: string, + stack: string + ): void { + const error = new Error(message); + error.stack = stack; + window[name].callbacks.get(seq).reject(error); + window[name].callbacks.delete(seq); + } + return evaluationString(deliverError, name, seq, message, stack); +} + +function pageBindingDeliverErrorValueString( + name: string, + seq: number, + value: unknown +): string { + function deliverErrorValue(name: string, seq: number, value: unknown): void { + window[name].callbacks.get(seq).reject(value); + window[name].callbacks.delete(seq); + } + return evaluationString(deliverErrorValue, name, seq, value); +} + +function makePredicateString( + predicate: Function, + predicateQueryHandler?: Function +): string { + function checkWaitForOptions( + node: Node, + waitForVisible: boolean, + waitForHidden: boolean + ): Node | null | boolean { + if (!node) return waitForHidden; + if (!waitForVisible && !waitForHidden) return node; + const element = + node.nodeType === Node.TEXT_NODE ? node.parentElement : (node as Element); + + const style = window.getComputedStyle(element); + const isVisible = + style && style.visibility !== 'hidden' && hasVisibleBoundingBox(); + const success = + waitForVisible === isVisible || waitForHidden === !isVisible; + return success ? node : null; + + function hasVisibleBoundingBox(): boolean { + const rect = element.getBoundingClientRect(); + return !!(rect.top || rect.bottom || rect.width || rect.height); + } + } + const predicateQueryHandlerDef = predicateQueryHandler + ? `const predicateQueryHandler = ${predicateQueryHandler};` + : ''; + return ` + (() => { + ${predicateQueryHandlerDef} + const checkWaitForOptions = ${checkWaitForOptions}; + return (${predicate})(...args) + })() `; +} + +async function waitWithTimeout<T extends any>( + promise: Promise<T>, + taskName: string, + timeout: number +): Promise<T> { + let reject; + const timeoutError = new TimeoutError( + `waiting for ${taskName} failed: timeout ${timeout}ms exceeded` + ); + const timeoutPromise = new Promise<T>((resolve, x) => (reject = x)); + let timeoutTimer = null; + if (timeout) timeoutTimer = setTimeout(() => reject(timeoutError), timeout); + try { + return await Promise.race([promise, timeoutPromise]); + } finally { + if (timeoutTimer) clearTimeout(timeoutTimer); + } +} + +async function readProtocolStream( + client: CDPSession, + handle: string, + path?: string +): Promise<Buffer> { + if (!isNode && path) { + throw new Error('Cannot write to a path outside of Node.js environment.'); + } + + const fs = isNode ? await importFSModule() : null; + + let eof = false; + let fileHandle: import('fs').promises.FileHandle; + + if (path && fs) { + fileHandle = await fs.promises.open(path, 'w'); + } + const bufs = []; + while (!eof) { + const response = await client.send('IO.read', { handle }); + eof = response.eof; + const buf = Buffer.from( + response.data, + response.base64Encoded ? 'base64' : undefined + ); + bufs.push(buf); + if (path && fs) { + await fs.promises.writeFile(fileHandle, buf); + } + } + if (path) await fileHandle.close(); + await client.send('IO.close', { handle }); + let resultBuffer = null; + try { + resultBuffer = Buffer.concat(bufs); + } finally { + return resultBuffer; + } +} + +/** + * Loads the Node fs promises API. Needed because on Node 10.17 and below, + * fs.promises is experimental, and therefore not marked as enumerable. That + * means when TypeScript compiles an `import('fs')`, its helper doesn't spot the + * promises declaration and therefore on Node <10.17 you get an error as + * fs.promises is undefined in compiled TypeScript land. + * + * See https://github.com/puppeteer/puppeteer/issues/6548 for more details. + * + * Once Node 10 is no longer supported (April 2021) we can remove this and use + * `(await import('fs')).promises`. + */ +async function importFSModule(): Promise<typeof import('fs')> { + if (!isNode) { + throw new Error('Cannot load the fs module API outside of Node.'); + } + + const fs = await import('fs'); + if (fs.promises) { + return fs; + } + return fs.default; +} + +export const helper = { + evaluationString, + pageBindingInitString, + pageBindingDeliverResultString, + pageBindingDeliverErrorString, + pageBindingDeliverErrorValueString, + makePredicateString, + readProtocolStream, + waitWithTimeout, + waitForEvent, + isString, + isNumber, + importFSModule, + addEventListener, + removeEventListeners, + valueFromRemoteObject, + getExceptionMessage, + releaseObject, +}; diff --git a/remote/test/puppeteer/src/environment.ts b/remote/test/puppeteer/src/environment.ts new file mode 100644 index 0000000000..f7d869775b --- /dev/null +++ b/remote/test/puppeteer/src/environment.ts @@ -0,0 +1,17 @@ +/** + * Copyright 2020 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. + */ + +export const isNode = !!(typeof process !== 'undefined' && process.version); diff --git a/remote/test/puppeteer/src/initialize-node.ts b/remote/test/puppeteer/src/initialize-node.ts new file mode 100644 index 0000000000..e533665403 --- /dev/null +++ b/remote/test/puppeteer/src/initialize-node.ts @@ -0,0 +1,43 @@ +/** + * Copyright 2020 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. + */ + +import { PuppeteerNode } from './node/Puppeteer.js'; +import { PUPPETEER_REVISIONS } from './revisions.js'; +import pkgDir from 'pkg-dir'; +import { Product } from './common/Product.js'; + +export const initializePuppeteerNode = (packageName: string): PuppeteerNode => { + const puppeteerRootDirectory = pkgDir.sync(__dirname); + + let preferredRevision = PUPPETEER_REVISIONS.chromium; + const isPuppeteerCore = packageName === 'puppeteer-core'; + // puppeteer-core ignores environment variables + const productName = isPuppeteerCore + ? undefined + : process.env.PUPPETEER_PRODUCT || + process.env.npm_config_puppeteer_product || + process.env.npm_package_config_puppeteer_product; + + if (!isPuppeteerCore && productName === 'firefox') + preferredRevision = PUPPETEER_REVISIONS.firefox; + + return new PuppeteerNode({ + projectRoot: puppeteerRootDirectory, + preferredRevision, + isPuppeteerCore, + productName: productName as Product, + }); +}; diff --git a/remote/test/puppeteer/src/initialize-web.ts b/remote/test/puppeteer/src/initialize-web.ts new file mode 100644 index 0000000000..4ec42ce3fe --- /dev/null +++ b/remote/test/puppeteer/src/initialize-web.ts @@ -0,0 +1,24 @@ +/** + * Copyright 2020 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. + */ + +import { Puppeteer } from './common/Puppeteer.js'; + +export const initializePuppeteerWeb = (packageName: string): Puppeteer => { + const isPuppeteerCore = packageName === 'puppeteer-core'; + return new Puppeteer({ + isPuppeteerCore, + }); +}; diff --git a/remote/test/puppeteer/src/node-puppeteer-core.ts b/remote/test/puppeteer/src/node-puppeteer-core.ts new file mode 100644 index 0000000000..976dfd5715 --- /dev/null +++ b/remote/test/puppeteer/src/node-puppeteer-core.ts @@ -0,0 +1,25 @@ +/** + * 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. + */ + +import { initializePuppeteerNode } from './initialize-node.js'; +import { isNode } from './environment.js'; + +if (!isNode) { + throw new Error('Cannot run puppeteer-core outside of Node.js'); +} + +const puppeteer = initializePuppeteerNode('puppeteer-core'); +export default puppeteer; diff --git a/remote/test/puppeteer/src/node.ts b/remote/test/puppeteer/src/node.ts new file mode 100644 index 0000000000..714f8c2b94 --- /dev/null +++ b/remote/test/puppeteer/src/node.ts @@ -0,0 +1,23 @@ +/** + * 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. + */ + +import { initializePuppeteerNode } from './initialize-node.js'; +import { isNode } from './environment.js'; + +if (!isNode) { + throw new Error('Trying to run Puppeteer-Node in a web environment.'); +} +export default initializePuppeteerNode('puppeteer'); diff --git a/remote/test/puppeteer/src/node/BrowserFetcher.ts b/remote/test/puppeteer/src/node/BrowserFetcher.ts new file mode 100644 index 0000000000..4653cd230c --- /dev/null +++ b/remote/test/puppeteer/src/node/BrowserFetcher.ts @@ -0,0 +1,602 @@ +/** + * 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. + */ + +import * as os from 'os'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as util from 'util'; +import * as childProcess from 'child_process'; +import * as https from 'https'; +import * as http from 'http'; + +import { Product } from '../common/Product.js'; +import extractZip from 'extract-zip'; +import { debug } from '../common/Debug.js'; +import { promisify } from 'util'; +import removeRecursive from 'rimraf'; +import * as URL from 'url'; +import ProxyAgent from 'https-proxy-agent'; +import { getProxyForUrl } from 'proxy-from-env'; +import { assert } from '../common/assert.js'; + +const debugFetcher = debug(`puppeteer:fetcher`); + +const downloadURLs = { + chrome: { + linux: '%s/chromium-browser-snapshots/Linux_x64/%d/%s.zip', + mac: '%s/chromium-browser-snapshots/Mac/%d/%s.zip', + win32: '%s/chromium-browser-snapshots/Win/%d/%s.zip', + win64: '%s/chromium-browser-snapshots/Win_x64/%d/%s.zip', + }, + firefox: { + linux: '%s/firefox-%s.en-US.%s-x86_64.tar.bz2', + mac: '%s/firefox-%s.en-US.%s.dmg', + win32: '%s/firefox-%s.en-US.%s.zip', + win64: '%s/firefox-%s.en-US.%s.zip', + }, +} as const; + +const browserConfig = { + chrome: { + host: 'https://storage.googleapis.com', + destination: '.local-chromium', + }, + firefox: { + host: + 'https://archive.mozilla.org/pub/firefox/nightly/latest-mozilla-central', + destination: '.local-firefox', + }, +} as const; + +/** + * Supported platforms. + * @public + */ +export type Platform = 'linux' | 'mac' | 'win32' | 'win64'; + +function archiveName( + product: Product, + platform: Platform, + revision: string +): string { + if (product === 'chrome') { + 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') { + return platform; + } +} + +/** + * @internal + */ +function downloadURL( + product: Product, + platform: Platform, + host: string, + revision: string +): string { + const url = util.format( + downloadURLs[product][platform], + host, + revision, + archiveName(product, platform, revision) + ); + return url; +} + +/** + * @internal + */ +function handleArm64(): void { + fs.stat('/usr/bin/chromium-browser', function (err, stats) { + if (stats === undefined) { + console.error(`The chromium binary is not available for arm64: `); + console.error(`If you are on Ubuntu, you can install with: `); + console.error(`\n apt-get install chromium-browser\n`); + throw new Error(); + } + }); +} +const readdirAsync = promisify(fs.readdir.bind(fs)); +const mkdirAsync = promisify(fs.mkdir.bind(fs)); +const unlinkAsync = promisify(fs.unlink.bind(fs)); +const chmodAsync = promisify(fs.chmod.bind(fs)); + +function existsAsync(filePath: string): Promise<boolean> { + return new Promise((resolve) => { + fs.access(filePath, (err) => resolve(!err)); + }); +} + +/** + * @public + */ +export interface BrowserFetcherOptions { + platform?: Platform; + product?: string; + path?: string; + host?: string; +} + +/** + * @public + */ +export interface BrowserFetcherRevisionInfo { + folderPath: string; + executablePath: string; + url: string; + local: boolean; + revision: string; + product: string; +} +/** + * BrowserFetcher can download and manage different versions of Chromium and Firefox. + * + * @remarks + * BrowserFetcher operates on revision strings that specify a precise version of Chromium, e.g. `"533271"`. Revision strings can be obtained from {@link http://omahaproxy.appspot.com/ | omahaproxy.appspot.com}. + * In the Firefox case, BrowserFetcher downloads Firefox Nightly and + * operates on version numbers such as `"75"`. + * + * @example + * An example of using BrowserFetcher to download a specific version of Chromium + * and running Puppeteer against it: + * + * ```js + * const browserFetcher = puppeteer.createBrowserFetcher(); + * const revisionInfo = await browserFetcher.download('533271'); + * const browser = await puppeteer.launch({executablePath: revisionInfo.executablePath}) + * ``` + * + * **NOTE** BrowserFetcher is not designed to work concurrently with other + * instances of BrowserFetcher that share the same downloads directory. + * + * @public + */ + +export class BrowserFetcher { + private _product: Product; + private _downloadsFolder: string; + private _downloadHost: string; + private _platform: Platform; + + /** + * @internal + */ + constructor(projectRoot: string, options: BrowserFetcherOptions = {}) { + this._product = (options.product || 'chrome').toLowerCase() as Product; + assert( + this._product === 'chrome' || this._product === 'firefox', + `Unknown product: "${options.product}"` + ); + + this._downloadsFolder = + options.path || + path.join(projectRoot, browserConfig[this._product].destination); + this._downloadHost = options.host || browserConfig[this._product].host; + this.setPlatform(options.platform); + assert( + downloadURLs[this._product][this._platform], + 'Unsupported platform: ' + this._platform + ); + } + + private setPlatform(platformFromOptions?: Platform): void { + if (platformFromOptions) { + this._platform = platformFromOptions; + return; + } + + 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'; + else assert(this._platform, 'Unsupported platform: ' + os.platform()); + } + + /** + * @returns Returns the current `Platform`. + */ + platform(): Platform { + return this._platform; + } + + /** + * @returns Returns the current `Product`. + */ + product(): Product { + return this._product; + } + + /** + * @returns The download host being used. + */ + host(): string { + return this._downloadHost; + } + + /** + * Initiates a HEAD request to check if the revision is available. + * @remarks + * This method is affected by the current `product`. + * @param revision - The revision to check availability for. + * @returns A promise that resolves to `true` if the revision could be downloaded + * from the host. + */ + canDownload(revision: string): Promise<boolean> { + const url = downloadURL( + this._product, + this._platform, + this._downloadHost, + revision + ); + return new Promise((resolve) => { + const request = httpRequest(url, 'HEAD', (response) => { + resolve(response.statusCode === 200); + }); + request.on('error', (error) => { + console.error(error); + resolve(false); + }); + }); + } + + /** + * Initiates a GET request to download the revision from the host. + * @remarks + * This method is affected by the current `product`. + * @param revision - The revision to download. + * @param progressCallback - A function that will be called with two arguments: + * How many bytes have been downloaded and the total number of bytes of the download. + * @returns A promise with revision information when the revision is downloaded + * and extracted. + */ + async download( + revision: string, + progressCallback: (x: number, y: number) => void = (): void => {} + ): Promise<BrowserFetcherRevisionInfo> { + const url = downloadURL( + this._product, + this._platform, + this._downloadHost, + revision + ); + const fileName = url.split('/').pop(); + const archivePath = path.join(this._downloadsFolder, fileName); + const outputPath = this._getFolderPath(revision); + if (await existsAsync(outputPath)) return this.revisionInfo(revision); + if (!(await existsAsync(this._downloadsFolder))) + await mkdirAsync(this._downloadsFolder); + if (os.arch() === 'arm64') { + handleArm64(); + return; + } + try { + await downloadFile(url, archivePath, progressCallback); + await install(archivePath, outputPath); + } finally { + if (await existsAsync(archivePath)) await unlinkAsync(archivePath); + } + const revisionInfo = this.revisionInfo(revision); + if (revisionInfo) await chmodAsync(revisionInfo.executablePath, 0o755); + return revisionInfo; + } + + /** + * @remarks + * This method is affected by the current `product`. + * @returns A promise with a list of all revision strings (for the current `product`) + * available locally on disk. + */ + async localRevisions(): Promise<string[]> { + if (!(await existsAsync(this._downloadsFolder))) return []; + const fileNames = await readdirAsync(this._downloadsFolder); + return fileNames + .map((fileName) => parseFolderPath(this._product, fileName)) + .filter((entry) => entry && entry.platform === this._platform) + .map((entry) => entry.revision); + } + + /** + * @remarks + * This method is affected by the current `product`. + * @param revision - A revision to remove for the current `product`. + * @returns A promise that resolves when the revision has been removes or + * throws if the revision has not been downloaded. + */ + async remove(revision: string): Promise<void> { + 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 revision - The revision to get info for. + * @returns The revision info for the given revision. + */ + revisionInfo(revision: string): BrowserFetcherRevisionInfo { + const folderPath = this._getFolderPath(revision); + let executablePath = ''; + if (this._product === 'chrome') { + 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); + } else { + throw new Error('Unsupported product: ' + this._product); + } + const url = downloadURL( + this._product, + this._platform, + this._downloadHost, + revision + ); + const local = fs.existsSync(folderPath); + debugFetcher({ + revision, + executablePath, + folderPath, + local, + url, + product: this._product, + }); + return { + revision, + executablePath, + folderPath, + local, + url, + product: this._product, + }; + } + + /** + * @internal + */ + _getFolderPath(revision: string): string { + return path.join(this._downloadsFolder, this._platform + '-' + revision); + } +} + +function parseFolderPath( + product: Product, + folderPath: string +): { product: string; platform: string; revision: string } | null { + const name = path.basename(folderPath); + const splits = name.split('-'); + if (splits.length !== 2) return null; + const [platform, revision] = splits; + if (!downloadURLs[product][platform]) return null; + return { product, platform, revision }; +} + +/** + * @internal + */ +function downloadFile( + url: string, + destinationPath: string, + progressCallback: (x: number, y: number) => void +): Promise<void> { + debugFetcher(`Downloading binary from ${url}`); + let fulfill, reject; + let downloadedBytes = 0; + let totalBytes = 0; + + const promise = new Promise<void>((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: string): void { + downloadedBytes += chunk.length; + progressCallback(downloadedBytes, totalBytes); + } +} + +function install(archivePath: string, folderPath: string): Promise<unknown> { + debugFetcher(`Installing ${archivePath} to ${folderPath}`); + if (archivePath.endsWith('.zip')) + return extractZip(archivePath, { dir: folderPath }); + else if (archivePath.endsWith('.tar.bz2')) + return extractTar(archivePath, folderPath); + else if (archivePath.endsWith('.dmg')) + return mkdirAsync(folderPath).then(() => + installDMG(archivePath, folderPath) + ); + else throw new Error(`Unsupported archive format: ${archivePath}`); +} + +/** + * @internal + */ +function extractTar(tarPath: string, folderPath: string): Promise<unknown> { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const tar = require('tar-fs'); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const bzip = require('unbzip2-stream'); + return new Promise((fulfill, reject) => { + const tarStream = tar.extract(folderPath); + tarStream.on('error', reject); + tarStream.on('finish', fulfill); + const readStream = fs.createReadStream(tarPath); + readStream.pipe(bzip()).pipe(tarStream); + }); +} + +/** + * @internal + */ +function installDMG(dmgPath: string, folderPath: string): Promise<void> { + let mountPath; + + function mountAndCopy(fulfill: () => void, reject: (Error) => void): void { + const mountCommand = `hdiutil attach -nobrowse -noautoopen "${dmgPath}"`; + childProcess.exec(mountCommand, (err, stdout) => { + if (err) return reject(err); + const volumes = stdout.match(/\/Volumes\/(.*)/m); + if (!volumes) + return reject(new Error(`Could not find volume path in ${stdout}`)); + mountPath = volumes[0]; + readdirAsync(mountPath) + .then((fileNames) => { + const appName = fileNames.filter( + (item) => typeof item === 'string' && item.endsWith('.app') + )[0]; + if (!appName) + return reject(new Error(`Cannot find app in ${mountPath}`)); + const copyPath = path.join(mountPath, appName); + debugFetcher(`Copying ${copyPath} to ${folderPath}`); + childProcess.exec(`cp -R "${copyPath}" "${folderPath}"`, (err) => { + if (err) reject(err); + else fulfill(); + }); + }) + .catch(reject); + }); + } + + function unmount(): void { + if (!mountPath) return; + const unmountCommand = `hdiutil detach "${mountPath}" -quiet`; + debugFetcher(`Unmounting ${mountPath}`); + childProcess.exec(unmountCommand, (err) => { + if (err) console.error(`Error unmounting dmg: ${err}`); + }); + } + + return new Promise<void>(mountAndCopy) + .catch((error) => { + console.error(error); + }) + .finally(unmount); +} + +function httpRequest( + url: string, + method: string, + response: (x: http.IncomingMessage) => void +): http.ClientRequest { + const urlParsed = URL.parse(url); + + type Options = Partial<URL.UrlWithStringQuery> & { + method?: string; + agent?: ProxyAgent; + rejectUnauthorized?: boolean; + }; + + let options: Options = { + ...urlParsed, + method, + }; + + const proxyURL = getProxyForUrl(url); + if (proxyURL) { + if (url.startsWith('http:')) { + const proxy = URL.parse(proxyURL); + options = { + path: options.href, + host: proxy.hostname, + port: proxy.port, + }; + } else { + const parsedProxyURL = URL.parse(proxyURL); + + const proxyOptions = { + ...parsedProxyURL, + secureProxy: parsedProxyURL.protocol === 'https:', + } as ProxyAgent.HttpsProxyAgentOptions; + + options.agent = new ProxyAgent(proxyOptions); + options.rejectUnauthorized = false; + } + } + + const requestCallback = (res: http.IncomingMessage): void => { + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) + httpRequest(res.headers.location, method, response); + else response(res); + }; + const request = + options.protocol === 'https:' + ? https.request(options, requestCallback) + : http.request(options, requestCallback); + request.end(); + return request; +} diff --git a/remote/test/puppeteer/src/node/BrowserRunner.ts b/remote/test/puppeteer/src/node/BrowserRunner.ts new file mode 100644 index 0000000000..e7e11bb143 --- /dev/null +++ b/remote/test/puppeteer/src/node/BrowserRunner.ts @@ -0,0 +1,257 @@ +/** + * Copyright 2020 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. + */ + +import { debug } from '../common/Debug.js'; + +import removeFolder from 'rimraf'; +import * as childProcess from 'child_process'; +import { assert } from '../common/assert.js'; +import { helper, debugError } from '../common/helper.js'; +import { LaunchOptions } from './LaunchOptions.js'; +import { Connection } from '../common/Connection.js'; +import { NodeWebSocketTransport as WebSocketTransport } from '../node/NodeWebSocketTransport.js'; +import { PipeTransport } from './PipeTransport.js'; +import * as readline from 'readline'; +import { TimeoutError } from '../common/Errors.js'; +import { promisify } from 'util'; + +const removeFolderAsync = promisify(removeFolder); +const debugLauncher = debug('puppeteer:launcher'); +const PROCESS_ERROR_EXPLANATION = `Puppeteer was unable to kill the process which ran the browser binary. +This means that, on future Puppeteer launches, Puppeteer might not be able to launch the browser. +Please check your open processes and ensure that the browser processes that Puppeteer launched have been killed. +If you think this is a bug, please report it on the Puppeteer issue tracker.`; + +export class BrowserRunner { + private _executablePath: string; + private _processArguments: string[]; + private _tempDirectory?: string; + + proc = null; + connection = null; + + private _closed = true; + private _listeners = []; + private _processClosing: Promise<void>; + + constructor( + executablePath: string, + processArguments: string[], + tempDirectory?: string + ) { + this._executablePath = executablePath; + this._processArguments = processArguments; + this._tempDirectory = tempDirectory; + } + + start(options: LaunchOptions): void { + const { + handleSIGINT, + handleSIGTERM, + handleSIGHUP, + dumpio, + env, + pipe, + } = options; + let stdio: Array<'ignore' | 'pipe'> = ['pipe', 'pipe', 'pipe']; + if (pipe) { + if (dumpio) stdio = ['ignore', 'pipe', 'pipe', 'pipe', 'pipe']; + else stdio = ['ignore', 'ignore', 'ignore', 'pipe', 'pipe']; + } + assert(!this.proc, 'This process has previously been started.'); + debugLauncher( + `Calling ${this._executablePath} ${this._processArguments.join(' ')}` + ); + this.proc = childProcess.spawn( + this._executablePath, + this._processArguments, + { + // On non-windows platforms, `detached: true` 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', + env, + stdio, + } + ); + if (dumpio) { + this.proc.stderr.pipe(process.stderr); + this.proc.stdout.pipe(process.stdout); + } + this._closed = false; + this._processClosing = new Promise((fulfill) => { + this.proc.once('exit', () => { + this._closed = true; + // Cleanup as processes exit. + if (this._tempDirectory) { + removeFolderAsync(this._tempDirectory) + .then(() => fulfill()) + .catch((error) => console.error(error)); + } else { + fulfill(); + } + }); + }); + this._listeners = [ + helper.addEventListener(process, 'exit', this.kill.bind(this)), + ]; + if (handleSIGINT) + this._listeners.push( + helper.addEventListener(process, 'SIGINT', () => { + this.kill(); + process.exit(130); + }) + ); + if (handleSIGTERM) + this._listeners.push( + helper.addEventListener(process, 'SIGTERM', this.close.bind(this)) + ); + if (handleSIGHUP) + this._listeners.push( + helper.addEventListener(process, 'SIGHUP', this.close.bind(this)) + ); + } + + close(): Promise<void> { + if (this._closed) return Promise.resolve(); + if (this._tempDirectory) { + this.kill(); + } else if (this.connection) { + // Attempt to close the browser gracefully + this.connection.send('Browser.close').catch((error) => { + debugError(error); + this.kill(); + }); + } + // Cleanup this listener last, as that makes sure the full callback runs. If we + // perform this earlier, then the previous function calls would not happen. + helper.removeEventListeners(this._listeners); + return this._processClosing; + } + + kill(): void { + // Attempt to remove temporary profile directory to avoid littering. + try { + removeFolder.sync(this._tempDirectory); + } catch (error) {} + + // If the process failed to launch (for example if the browser executable path + // is invalid), then the process does not get a pid assigned. A call to + // `proc.kill` would error, as the `pid` to-be-killed can not be found. + if (this.proc && this.proc.pid && !this.proc.killed) { + try { + this.proc.kill('SIGKILL'); + } catch (error) { + throw new Error( + `${PROCESS_ERROR_EXPLANATION}\nError cause: ${error.stack}` + ); + } + } + // Cleanup this listener last, as that makes sure the full callback runs. If we + // perform this earlier, then the previous function calls would not happen. + helper.removeEventListeners(this._listeners); + } + + async setupConnection(options: { + usePipe?: boolean; + timeout: number; + slowMo: number; + preferredRevision: string; + }): Promise<Connection> { + const { usePipe, timeout, slowMo, preferredRevision } = options; + if (!usePipe) { + const browserWSEndpoint = await waitForWSEndpoint( + this.proc, + timeout, + preferredRevision + ); + const transport = await WebSocketTransport.create(browserWSEndpoint); + this.connection = new Connection(browserWSEndpoint, transport, slowMo); + } else { + // stdio was assigned during start(), and the 'pipe' option there adds the + // 4th and 5th items to stdio array + const { 3: pipeWrite, 4: pipeRead } = this.proc.stdio; + const transport = new PipeTransport( + pipeWrite as NodeJS.WritableStream, + pipeRead as NodeJS.ReadableStream + ); + this.connection = new Connection('', transport, slowMo); + } + return this.connection; + } +} + +function waitForWSEndpoint( + browserProcess: childProcess.ChildProcess, + timeout: number, + preferredRevision: string +): Promise<string> { + return new Promise((resolve, reject) => { + const rl = readline.createInterface({ input: browserProcess.stderr }); + let stderr = ''; + const listeners = [ + helper.addEventListener(rl, 'line', onLine), + helper.addEventListener(rl, 'close', () => onClose()), + helper.addEventListener(browserProcess, 'exit', () => onClose()), + helper.addEventListener(browserProcess, 'error', (error) => + onClose(error) + ), + ]; + const timeoutId = timeout ? setTimeout(onTimeout, timeout) : 0; + + /** + * @param {!Error=} error + */ + function onClose(error?: Error): void { + cleanup(); + reject( + new Error( + [ + 'Failed to launch the browser process!' + + (error ? ' ' + error.message : ''), + stderr, + '', + 'TROUBLESHOOTING: https://github.com/puppeteer/puppeteer/blob/main/docs/troubleshooting.md', + '', + ].join('\n') + ) + ); + } + + function onTimeout(): void { + cleanup(); + reject( + new TimeoutError( + `Timed out after ${timeout} ms while trying to connect to the browser! Only Chrome at revision r${preferredRevision} is guaranteed to work.` + ) + ); + } + + function onLine(line: string): void { + stderr += line + '\n'; + const match = line.match(/^DevTools listening on (ws:\/\/.*)$/); + if (!match) return; + cleanup(); + resolve(match[1]); + } + + function cleanup(): void { + if (timeoutId) clearTimeout(timeoutId); + helper.removeEventListeners(listeners); + } + }); +} diff --git a/remote/test/puppeteer/src/node/LaunchOptions.ts b/remote/test/puppeteer/src/node/LaunchOptions.ts new file mode 100644 index 0000000000..0eb99b6980 --- /dev/null +++ b/remote/test/puppeteer/src/node/LaunchOptions.ts @@ -0,0 +1,42 @@ +/** + * Copyright 2020 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. + */ +/** + * Launcher options that only apply to Chrome. + * + * @public + */ +export interface ChromeArgOptions { + headless?: boolean; + args?: string[]; + userDataDir?: string; + devtools?: boolean; +} + +/** + * Generic launch options that can be passed when launching any browser. + * @public + */ +export interface LaunchOptions { + executablePath?: string; + ignoreDefaultArgs?: boolean | string[]; + handleSIGINT?: boolean; + handleSIGTERM?: boolean; + handleSIGHUP?: boolean; + timeout?: number; + dumpio?: boolean; + env?: Record<string, string | undefined>; + pipe?: boolean; +} diff --git a/remote/test/puppeteer/src/node/Launcher.ts b/remote/test/puppeteer/src/node/Launcher.ts new file mode 100644 index 0000000000..8ca62db389 --- /dev/null +++ b/remote/test/puppeteer/src/node/Launcher.ts @@ -0,0 +1,673 @@ +/** + * 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. + */ +import * as os from 'os'; +import * as path from 'path'; +import * as fs from 'fs'; + +import { BrowserFetcher } from './BrowserFetcher.js'; +import { Browser } from '../common/Browser.js'; +import { BrowserRunner } from './BrowserRunner.js'; +import { promisify } from 'util'; + +const mkdtempAsync = promisify(fs.mkdtemp); +const writeFileAsync = promisify(fs.writeFile); + +import { ChromeArgOptions, LaunchOptions } from './LaunchOptions.js'; +import { BrowserOptions } from '../common/BrowserConnector.js'; +import { Product } from '../common/Product.js'; + +/** + * Describes a launcher - a class that is able to create and launch a browser instance. + * @public + */ +export interface ProductLauncher { + launch(object); + executablePath: () => string; + defaultArgs(object); + product: Product; +} + +/** + * @internal + */ +class ChromeLauncher implements ProductLauncher { + _projectRoot: string; + _preferredRevision: string; + _isPuppeteerCore: boolean; + + constructor( + projectRoot: string, + preferredRevision: string, + isPuppeteerCore: boolean + ) { + this._projectRoot = projectRoot; + this._preferredRevision = preferredRevision; + this._isPuppeteerCore = isPuppeteerCore; + } + + async launch( + options: LaunchOptions & ChromeArgOptions & BrowserOptions = {} + ): Promise<Browser> { + const { + ignoreDefaultArgs = false, + args = [], + dumpio = false, + executablePath = null, + pipe = false, + env = process.env, + handleSIGINT = true, + handleSIGTERM = true, + handleSIGHUP = true, + ignoreHTTPSErrors = false, + defaultViewport = { width: 800, height: 600 }, + slowMo = 0, + timeout = 30000, + } = options; + + const profilePath = path.join(os.tmpdir(), 'puppeteer_dev_chrome_profile-'); + const chromeArguments = []; + if (!ignoreDefaultArgs) chromeArguments.push(...this.defaultArgs(options)); + else if (Array.isArray(ignoreDefaultArgs)) + chromeArguments.push( + ...this.defaultArgs(options).filter( + (arg) => !ignoreDefaultArgs.includes(arg) + ) + ); + else chromeArguments.push(...args); + + let temporaryUserDataDir = null; + + if ( + !chromeArguments.some((argument) => + argument.startsWith('--remote-debugging-') + ) + ) + chromeArguments.push( + pipe ? '--remote-debugging-pipe' : '--remote-debugging-port=0' + ); + if (!chromeArguments.some((arg) => arg.startsWith('--user-data-dir'))) { + temporaryUserDataDir = await mkdtempAsync(profilePath); + chromeArguments.push(`--user-data-dir=${temporaryUserDataDir}`); + } + + let chromeExecutable = executablePath; + if (os.arch() === 'arm64') { + chromeExecutable = '/usr/bin/chromium-browser'; + } else if (!executablePath) { + const { missingText, executablePath } = resolveExecutablePath(this); + if (missingText) throw new Error(missingText); + chromeExecutable = executablePath; + } + + const usePipe = chromeArguments.includes('--remote-debugging-pipe'); + const runner = new BrowserRunner( + chromeExecutable, + chromeArguments, + temporaryUserDataDir + ); + runner.start({ + handleSIGHUP, + handleSIGTERM, + handleSIGINT, + dumpio, + env, + pipe: usePipe, + }); + + try { + const connection = await runner.setupConnection({ + usePipe, + timeout, + slowMo, + preferredRevision: this._preferredRevision, + }); + const browser = await Browser.create( + connection, + [], + ignoreHTTPSErrors, + defaultViewport, + runner.proc, + runner.close.bind(runner) + ); + await browser.waitForTarget((t) => t.type() === 'page'); + return browser; + } catch (error) { + runner.kill(); + throw error; + } + } + + /** + * @param {!Launcher.ChromeArgOptions=} options + * @returns {!Array<string>} + */ + defaultArgs(options: ChromeArgOptions = {}): string[] { + const chromeArguments = [ + '--disable-background-networking', + '--enable-features=NetworkService,NetworkServiceInProcess', + '--disable-background-timer-throttling', + '--disable-backgrounding-occluded-windows', + '--disable-breakpad', + '--disable-client-side-phishing-detection', + '--disable-component-extensions-with-background-pages', + '--disable-default-apps', + '--disable-dev-shm-usage', + '--disable-extensions', + '--disable-features=TranslateUI', + '--disable-hang-monitor', + '--disable-ipc-flooding-protection', + '--disable-popup-blocking', + '--disable-prompt-on-repost', + '--disable-renderer-backgrounding', + '--disable-sync', + '--force-color-profile=srgb', + '--metrics-recording-only', + '--no-first-run', + '--enable-automation', + '--password-store=basic', + '--use-mock-keychain', + // TODO(sadym): remove '--enable-blink-features=IdleDetection' + // once IdleDetection is turned on by default. + '--enable-blink-features=IdleDetection', + ]; + const { + devtools = false, + headless = !devtools, + args = [], + userDataDir = null, + } = options; + if (userDataDir) + chromeArguments.push(`--user-data-dir=${path.resolve(userDataDir)}`); + if (devtools) chromeArguments.push('--auto-open-devtools-for-tabs'); + if (headless) { + chromeArguments.push('--headless', '--hide-scrollbars', '--mute-audio'); + } + if (args.every((arg) => arg.startsWith('-'))) + chromeArguments.push('about:blank'); + chromeArguments.push(...args); + return chromeArguments; + } + + executablePath(): string { + return resolveExecutablePath(this).executablePath; + } + + get product(): Product { + return 'chrome'; + } +} + +/** + * @internal + */ +class FirefoxLauncher implements ProductLauncher { + _projectRoot: string; + _preferredRevision: string; + _isPuppeteerCore: boolean; + + constructor( + projectRoot: string, + preferredRevision: string, + isPuppeteerCore: boolean + ) { + this._projectRoot = projectRoot; + this._preferredRevision = preferredRevision; + this._isPuppeteerCore = isPuppeteerCore; + } + + async launch( + options: LaunchOptions & + ChromeArgOptions & + BrowserOptions & { + extraPrefsFirefox?: { [x: string]: unknown }; + } = {} + ): Promise<Browser> { + const { + ignoreDefaultArgs = false, + args = [], + dumpio = false, + executablePath = null, + pipe = false, + env = process.env, + handleSIGINT = true, + handleSIGTERM = true, + handleSIGHUP = true, + ignoreHTTPSErrors = false, + defaultViewport = { width: 800, height: 600 }, + slowMo = 0, + timeout = 30000, + extraPrefsFirefox = {}, + } = options; + + const firefoxArguments = []; + if (!ignoreDefaultArgs) firefoxArguments.push(...this.defaultArgs(options)); + else if (Array.isArray(ignoreDefaultArgs)) + firefoxArguments.push( + ...this.defaultArgs(options).filter( + (arg) => !ignoreDefaultArgs.includes(arg) + ) + ); + else firefoxArguments.push(...args); + + if ( + !firefoxArguments.some((argument) => + argument.startsWith('--remote-debugging-') + ) + ) + firefoxArguments.push('--remote-debugging-port=0'); + + let temporaryUserDataDir = null; + + if ( + !firefoxArguments.includes('-profile') && + !firefoxArguments.includes('--profile') + ) { + temporaryUserDataDir = await this._createProfile(extraPrefsFirefox); + firefoxArguments.push('--profile'); + firefoxArguments.push(temporaryUserDataDir); + } + + await this._updateRevision(); + let firefoxExecutable = executablePath; + if (!executablePath) { + const { missingText, executablePath } = resolveExecutablePath(this); + if (missingText) throw new Error(missingText); + firefoxExecutable = executablePath; + } + + const runner = new BrowserRunner( + firefoxExecutable, + firefoxArguments, + temporaryUserDataDir + ); + runner.start({ + handleSIGHUP, + handleSIGTERM, + handleSIGINT, + dumpio, + env, + pipe, + }); + + try { + const connection = await runner.setupConnection({ + usePipe: pipe, + timeout, + slowMo, + preferredRevision: this._preferredRevision, + }); + const browser = await Browser.create( + connection, + [], + ignoreHTTPSErrors, + defaultViewport, + runner.proc, + runner.close.bind(runner) + ); + await browser.waitForTarget((t) => t.type() === 'page'); + return browser; + } catch (error) { + runner.kill(); + throw error; + } + } + + executablePath(): string { + return resolveExecutablePath(this).executablePath; + } + + async _updateRevision(): Promise<void> { + // replace 'latest' placeholder with actual downloaded revision + if (this._preferredRevision === 'latest') { + const browserFetcher = new BrowserFetcher(this._projectRoot, { + product: this.product, + }); + const localRevisions = await browserFetcher.localRevisions(); + if (localRevisions[0]) this._preferredRevision = localRevisions[0]; + } + } + + get product(): Product { + return 'firefox'; + } + + defaultArgs(options: ChromeArgOptions = {}): string[] { + const firefoxArguments = ['--no-remote', '--foreground']; + if (os.platform().startsWith('win')) { + firefoxArguments.push('--wait-for-browser'); + } + const { + devtools = false, + headless = !devtools, + args = [], + userDataDir = null, + } = options; + if (userDataDir) { + firefoxArguments.push('--profile'); + firefoxArguments.push(userDataDir); + } + if (headless) firefoxArguments.push('--headless'); + if (devtools) firefoxArguments.push('--devtools'); + if (args.every((arg) => arg.startsWith('-'))) + firefoxArguments.push('about:blank'); + firefoxArguments.push(...args); + return firefoxArguments; + } + + async _createProfile(extraPrefs: { [x: string]: unknown }): Promise<string> { + const profilePath = await mkdtempAsync( + path.join(os.tmpdir(), 'puppeteer_dev_firefox_profile-') + ); + const prefsJS = []; + const userJS = []; + const server = 'dummy.test'; + const defaultPreferences = { + // Make sure Shield doesn't hit the network. + 'app.normandy.api_url': '', + // Disable Firefox old build background check + 'app.update.checkInstallTime': false, + // Disable automatically upgrading Firefox + 'app.update.disabledForTesting': true, + + // Increase the APZ content response timeout to 1 minute + 'apz.content_response_timeout': 60000, + + // Prevent various error message on the console + // jest-puppeteer asserts that no error message is emitted by the console + 'browser.contentblocking.features.standard': + '-tp,tpPrivate,cookieBehavior0,-cm,-fp', + + // Enable the dump function: which sends messages to the system + // console + // https://bugzilla.mozilla.org/show_bug.cgi?id=1543115 + 'browser.dom.window.dump.enabled': true, + // Disable topstories + 'browser.newtabpage.activity-stream.feeds.system.topstories': false, + // Always display a blank page + 'browser.newtabpage.enabled': false, + // Background thumbnails in particular cause grief: and disabling + // thumbnails in general cannot hurt + 'browser.pagethumbnails.capturing_disabled': true, + + // Disable safebrowsing components. + 'browser.safebrowsing.blockedURIs.enabled': false, + 'browser.safebrowsing.downloads.enabled': false, + 'browser.safebrowsing.malware.enabled': false, + 'browser.safebrowsing.passwords.enabled': false, + 'browser.safebrowsing.phishing.enabled': false, + + // Disable updates to search engines. + 'browser.search.update': false, + // Do not restore the last open set of tabs if the browser has crashed + 'browser.sessionstore.resume_from_crash': false, + // Skip check for default browser on startup + 'browser.shell.checkDefaultBrowser': false, + + // Disable newtabpage + 'browser.startup.homepage': 'about:blank', + // Do not redirect user when a milstone upgrade of Firefox is detected + 'browser.startup.homepage_override.mstone': 'ignore', + // Start with a blank page about:blank + 'browser.startup.page': 0, + + // 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 + 'browser.tabs.disableBackgroundZombification': false, + // Do not warn when closing all other open tabs + 'browser.tabs.warnOnCloseOtherTabs': false, + // Do not warn when multiple tabs will be opened + 'browser.tabs.warnOnOpen': false, + + // Disable the UI tour. + 'browser.uitour.enabled': false, + // Turn off search suggestions in the location bar so as not to trigger + // network connections. + 'browser.urlbar.suggest.searches': false, + // Disable first run splash page on Windows 10 + 'browser.usedOnWindows10.introURL': '', + // Do not warn on quitting Firefox + 'browser.warnOnQuit': false, + + // Defensively disable data reporting systems + 'datareporting.healthreport.documentServerURI': `http://${server}/dummy/healthreport/`, + 'datareporting.healthreport.logging.consoleEnabled': false, + 'datareporting.healthreport.service.enabled': false, + 'datareporting.healthreport.service.firstRun': false, + 'datareporting.healthreport.uploadEnabled': false, + + // Do not show datareporting policy notifications which can interfere with tests + 'datareporting.policy.dataSubmissionEnabled': false, + 'datareporting.policy.dataSubmissionPolicyBypassNotification': true, + + // DevTools JSONViewer sometimes fails to load dependencies with its require.js. + // This doesn't affect Puppeteer but spams console (Bug 1424372) + 'devtools.jsonview.enabled': false, + + // Disable popup-blocker + 'dom.disable_open_during_load': false, + + // Enable the support for File object creation in the content process + // Required for |Page.setFileInputFiles| protocol method. + 'dom.file.createInChild': true, + + // Disable the ProcessHangMonitor + 'dom.ipc.reportProcessHangs': false, + + // Disable slow script dialogues + 'dom.max_chrome_script_run_time': 0, + 'dom.max_script_run_time': 0, + + // Only load extensions from the application and user profile + // AddonManager.SCOPE_PROFILE + AddonManager.SCOPE_APPLICATION + 'extensions.autoDisableScopes': 0, + 'extensions.enabledScopes': 5, + + // Disable metadata caching for installed add-ons by default + 'extensions.getAddons.cache.enabled': false, + + // Disable installing any distribution extensions or add-ons. + 'extensions.installDistroAddons': false, + + // Disabled screenshots extension + 'extensions.screenshots.disabled': true, + + // Turn off extension updates so they do not bother tests + 'extensions.update.enabled': false, + + // Turn off extension updates so they do not bother tests + 'extensions.update.notifyUser': false, + + // Make sure opening about:addons will not hit the network + 'extensions.webservice.discoverURL': `http://${server}/dummy/discoveryURL`, + + // Force disable Fission until the Remote Agent is compatible + 'fission.autostart': false, + + // Allow the application to have focus even it runs in the background + 'focusmanager.testmode': true, + // Disable useragent updates + 'general.useragent.updates.enabled': false, + // Always use network provider for geolocation tests so we bypass the + // macOS dialog raised by the corelocation provider + 'geo.provider.testing': true, + // Do not scan Wifi + 'geo.wifi.scan': false, + // No hang monitor + 'hangmonitor.timeout': 0, + // Show chrome errors and warnings in the error console + 'javascript.options.showInConsole': true, + + // Disable download and usage of OpenH264: and Widevine plugins + 'media.gmp-manager.updateEnabled': false, + // Prevent various error message on the console + // jest-puppeteer asserts that no error message is emitted by the console + 'network.cookie.cookieBehavior': 0, + + // Disable experimental feature that is only available in Nightly + 'network.cookie.sameSite.laxByDefault': false, + + // Do not prompt for temporary redirects + 'network.http.prompt-temp-redirect': false, + + // Disable speculative connections so they are not reported as leaking + // when they are hanging around + 'network.http.speculative-parallel-limit': 0, + + // Do not automatically switch between offline and online + 'network.manage-offline-status': false, + + // Make sure SNTP requests do not hit the network + 'network.sntp.pools': server, + + // Disable Flash. + 'plugin.state.flash': 0, + + 'privacy.trackingprotection.enabled': false, + + // Enable Remote Agent + // https://bugzilla.mozilla.org/show_bug.cgi?id=1544393 + 'remote.enabled': true, + + // Don't do network connections for mitm priming + 'security.certerrors.mitm.priming.enabled': false, + // Local documents have access to all other local documents, + // including directory listings + 'security.fileuri.strict_origin_policy': false, + // Do not wait for the notification button security delay + 'security.notification_enable_delay': 0, + + // Ensure blocklist updates do not hit the network + 'services.settings.server': `http://${server}/dummy/blocklist/`, + + // Do not automatically fill sign-in forms with known usernames and + // passwords + 'signon.autofillForms': false, + // Disable password capture, so that tests that include forms are not + // influenced by the presence of the persistent doorhanger notification + 'signon.rememberSignons': false, + + // Disable first-run welcome page + 'startup.homepage_welcome_url': 'about:blank', + + // Disable first-run welcome page + 'startup.homepage_welcome_url.additional': '', + + // Disable browser animations (tabs, fullscreen, sliding alerts) + 'toolkit.cosmeticAnimations.enabled': false, + + // Prevent starting into safe mode after application crashes + 'toolkit.startup.max_resumed_crashes': -1, + }; + + Object.assign(defaultPreferences, extraPrefs); + for (const [key, value] of Object.entries(defaultPreferences)) + userJS.push( + `user_pref(${JSON.stringify(key)}, ${JSON.stringify(value)});` + ); + await writeFileAsync(path.join(profilePath, 'user.js'), userJS.join('\n')); + await writeFileAsync( + path.join(profilePath, 'prefs.js'), + prefsJS.join('\n') + ); + return profilePath; + } +} + +function resolveExecutablePath( + launcher: ChromeLauncher | FirefoxLauncher +): { executablePath: string; missingText?: string } { + let downloadPath: string; + // puppeteer-core doesn't take into account PUPPETEER_* env variables. + if (!launcher._isPuppeteerCore) { + const executablePath = + process.env.PUPPETEER_EXECUTABLE_PATH || + process.env.npm_config_puppeteer_executable_path || + process.env.npm_package_config_puppeteer_executable_path; + if (executablePath) { + const missingText = !fs.existsSync(executablePath) + ? 'Tried to use PUPPETEER_EXECUTABLE_PATH env variable to launch browser but did not find any executable at: ' + + executablePath + : null; + return { executablePath, missingText }; + } + downloadPath = + process.env.PUPPETEER_DOWNLOAD_PATH || + process.env.npm_config_puppeteer_download_path || + process.env.npm_package_config_puppeteer_download_path; + } + const browserFetcher = new BrowserFetcher(launcher._projectRoot, { + product: launcher.product, + path: downloadPath, + }); + if (!launcher._isPuppeteerCore && launcher.product === 'chrome') { + const revision = process.env['PUPPETEER_CHROMIUM_REVISION']; + if (revision) { + const revisionInfo = browserFetcher.revisionInfo(revision); + const missingText = !revisionInfo.local + ? 'Tried to use PUPPETEER_CHROMIUM_REVISION env variable to launch browser but did not find executable at: ' + + revisionInfo.executablePath + : null; + return { executablePath: revisionInfo.executablePath, missingText }; + } + } + const revisionInfo = browserFetcher.revisionInfo(launcher._preferredRevision); + const missingText = !revisionInfo.local + ? `Could not find browser revision ${launcher._preferredRevision}. Run "PUPPETEER_PRODUCT=firefox npm install" or "PUPPETEER_PRODUCT=firefox yarn install" to download a supported Firefox browser binary.` + : null; + return { executablePath: revisionInfo.executablePath, missingText }; +} + +/** + * @internal + */ +export default function Launcher( + projectRoot: string, + preferredRevision: string, + isPuppeteerCore: boolean, + product?: string +): ProductLauncher { + // puppeteer-core doesn't take into account PUPPETEER_* env variables. + if (!product && !isPuppeteerCore) + product = + process.env.PUPPETEER_PRODUCT || + process.env.npm_config_puppeteer_product || + process.env.npm_package_config_puppeteer_product; + switch (product) { + case 'firefox': + return new FirefoxLauncher( + projectRoot, + preferredRevision, + isPuppeteerCore + ); + case 'chrome': + default: + if (typeof product !== 'undefined' && product !== 'chrome') { + /* The user gave us an incorrect product name + * we'll default to launching Chrome, but log to the console + * to let the user know (they've probably typoed). + */ + console.warn( + `Warning: unknown product name ${product}. Falling back to chrome.` + ); + } + return new ChromeLauncher( + projectRoot, + preferredRevision, + isPuppeteerCore + ); + } +} diff --git a/remote/test/puppeteer/src/node/NodeWebSocketTransport.ts b/remote/test/puppeteer/src/node/NodeWebSocketTransport.ts new file mode 100644 index 0000000000..d1077ae7c2 --- /dev/null +++ b/remote/test/puppeteer/src/node/NodeWebSocketTransport.ts @@ -0,0 +1,59 @@ +/** + * 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. + */ +import { ConnectionTransport } from '../common/ConnectionTransport.js'; +import NodeWebSocket from 'ws'; + +export class NodeWebSocketTransport implements ConnectionTransport { + static create(url: string): Promise<NodeWebSocketTransport> { + return new Promise((resolve, reject) => { + const ws = new NodeWebSocket(url, [], { + perMessageDeflate: false, + maxPayload: 256 * 1024 * 1024, // 256Mb + }); + + ws.addEventListener('open', () => + resolve(new NodeWebSocketTransport(ws)) + ); + ws.addEventListener('error', reject); + }); + } + + private _ws: NodeWebSocket; + onmessage?: (message: string) => void; + onclose?: () => void; + + constructor(ws: NodeWebSocket) { + this._ws = ws; + this._ws.addEventListener('message', (event) => { + if (this.onmessage) this.onmessage.call(null, event.data); + }); + this._ws.addEventListener('close', () => { + if (this.onclose) this.onclose.call(null); + }); + // Silently ignore all errors - we don't know what to do with them. + this._ws.addEventListener('error', () => {}); + this.onmessage = null; + this.onclose = null; + } + + send(message: string): void { + this._ws.send(message); + } + + close(): void { + this._ws.close(); + } +} diff --git a/remote/test/puppeteer/src/node/PipeTransport.ts b/remote/test/puppeteer/src/node/PipeTransport.ts new file mode 100644 index 0000000000..e66c2c0b47 --- /dev/null +++ b/remote/test/puppeteer/src/node/PipeTransport.ts @@ -0,0 +1,80 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + helper, + debugError, + PuppeteerEventListener, +} from '../common/helper.js'; +import { ConnectionTransport } from '../common/ConnectionTransport.js'; + +export class PipeTransport implements ConnectionTransport { + _pipeWrite: NodeJS.WritableStream; + _pendingMessage: string; + _eventListeners: PuppeteerEventListener[]; + + onclose?: () => void; + onmessage?: () => void; + + constructor( + pipeWrite: NodeJS.WritableStream, + pipeRead: NodeJS.ReadableStream + ) { + this._pipeWrite = pipeWrite; + this._pendingMessage = ''; + this._eventListeners = [ + helper.addEventListener(pipeRead, 'data', (buffer) => + this._dispatch(buffer) + ), + helper.addEventListener(pipeRead, 'close', () => { + if (this.onclose) this.onclose.call(null); + }), + helper.addEventListener(pipeRead, 'error', debugError), + helper.addEventListener(pipeWrite, 'error', debugError), + ]; + this.onmessage = null; + this.onclose = null; + } + + send(message: string): void { + this._pipeWrite.write(message); + this._pipeWrite.write('\0'); + } + + _dispatch(buffer: Buffer): void { + let end = buffer.indexOf('\0'); + if (end === -1) { + this._pendingMessage += buffer.toString(); + return; + } + const message = this._pendingMessage + buffer.toString(undefined, 0, end); + if (this.onmessage) this.onmessage.call(null, message); + + let start = end + 1; + end = buffer.indexOf('\0', start); + while (end !== -1) { + if (this.onmessage) + this.onmessage.call(null, buffer.toString(undefined, start, end)); + start = end + 1; + end = buffer.indexOf('\0', start); + } + this._pendingMessage = buffer.toString(undefined, start); + } + + close(): void { + this._pipeWrite = null; + helper.removeEventListeners(this._eventListeners); + } +} diff --git a/remote/test/puppeteer/src/node/Puppeteer.ts b/remote/test/puppeteer/src/node/Puppeteer.ts new file mode 100644 index 0000000000..924d2fa96e --- /dev/null +++ b/remote/test/puppeteer/src/node/Puppeteer.ts @@ -0,0 +1,230 @@ +/** + * Copyright 2020 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. + */ + +import { + Puppeteer, + CommonPuppeteerSettings, + ConnectOptions, +} from '../common/Puppeteer.js'; +import { BrowserFetcher, BrowserFetcherOptions } from './BrowserFetcher.js'; +import { LaunchOptions, ChromeArgOptions } from './LaunchOptions.js'; +import { BrowserOptions } from '../common/BrowserConnector.js'; +import { Browser } from '../common/Browser.js'; +import Launcher, { ProductLauncher } from './Launcher.js'; +import { PUPPETEER_REVISIONS } from '../revisions.js'; +import { Product } from '../common/Product.js'; + +/** + * Extends the main {@link Puppeteer} class with Node specific behaviour for fetching and + * downloading browsers. + * + * If you're using Puppeteer in a Node environment, this is the class you'll get + * when you run `require('puppeteer')` (or the equivalent ES `import`). + * + * @remarks + * + * The most common method to use is {@link PuppeteerNode.launch | launch}, which + * is used to launch and connect to a new browser instance. + * + * See {@link Puppeteer | the main Puppeteer class} for methods common to all + * environments, such as {@link Puppeteer.connect}. + * + * @example + * The following is a typical example of using Puppeteer to drive automation: + * ```js + * const puppeteer = require('puppeteer'); + * + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * await page.goto('https://www.google.com'); + * // other actions... + * await browser.close(); + * })(); + * ``` + * + * Once you have created a `page` you have access to a large API to interact + * with the page, navigate, or find certain elements in that page. + * The {@link Page | `page` documentation} lists all the available methods. + * + * @public + */ +export class PuppeteerNode extends Puppeteer { + private _lazyLauncher: ProductLauncher; + private _projectRoot: string; + private __productName?: Product; + /** + * @internal + */ + _preferredRevision: string; + + /** + * @internal + */ + constructor( + settings: { + projectRoot: string; + preferredRevision: string; + productName?: Product; + } & CommonPuppeteerSettings + ) { + const { + projectRoot, + preferredRevision, + productName, + ...commonSettings + } = settings; + super(commonSettings); + this._projectRoot = projectRoot; + this.__productName = productName; + this._preferredRevision = preferredRevision; + } + + /** + * This method attaches Puppeteer to an existing browser instance. + * + * @remarks + * + * @param options - Set of configurable options to set on the browser. + * @returns Promise which resolves to browser instance. + */ + connect(options: ConnectOptions): Promise<Browser> { + if (options.product) this._productName = options.product; + return super.connect(options); + } + + /** + * @internal + */ + get _productName(): Product { + return this.__productName; + } + + // don't need any TSDoc here - because the getter is internal the setter is too. + set _productName(name: Product) { + if (this.__productName !== name) this._changedProduct = true; + this.__productName = name; + } + + /** + * Launches puppeteer and launches a browser instance with given arguments + * and options when specified. + * + * @remarks + * + * @example + * You can use `ignoreDefaultArgs` to filter out `--mute-audio` from default arguments: + * ```js + * const browser = await puppeteer.launch({ + * ignoreDefaultArgs: ['--mute-audio'] + * }); + * ``` + * + * **NOTE** Puppeteer can also be used to control the Chrome browser, + * but it works best with the version of Chromium it is bundled with. + * There is no guarantee it will work with any other version. + * Use `executablePath` option with extreme caution. + * If Google Chrome (rather than Chromium) is preferred, a {@link https://www.google.com/chrome/browser/canary.html | Chrome Canary} or {@link https://www.chromium.org/getting-involved/dev-channel | Dev Channel} build is suggested. + * In `puppeteer.launch([options])`, any mention of Chromium also applies to Chrome. + * See {@link https://www.howtogeek.com/202825/what%E2%80%99s-the-difference-between-chromium-and-chrome/ | this article} for a description of the differences between Chromium and Chrome. {@link https://chromium.googlesource.com/chromium/src/+/lkgr/docs/chromium_browser_vs_google_chrome.md | This article} describes some differences for Linux users. + * + * @param options - Set of configurable options to set on the browser. + * @returns Promise which resolves to browser instance. + */ + launch( + options: LaunchOptions & + ChromeArgOptions & + BrowserOptions & { + product?: Product; + extraPrefsFirefox?: Record<string, unknown>; + } = {} + ): Promise<Browser> { + if (options.product) this._productName = options.product; + return this._launcher.launch(options); + } + + /** + * @remarks + * + * **NOTE** `puppeteer.executablePath()` is affected by the `PUPPETEER_EXECUTABLE_PATH` + * and `PUPPETEER_CHROMIUM_REVISION` environment variables. + * + * @returns A path where Puppeteer expects to find the bundled browser. + * The browser binary might not be there if the download was skipped with + * the `PUPPETEER_SKIP_DOWNLOAD` environment variable. + */ + executablePath(): string { + return this._launcher.executablePath(); + } + + /** + * @internal + */ + get _launcher(): ProductLauncher { + if ( + !this._lazyLauncher || + this._lazyLauncher.product !== this._productName || + this._changedProduct + ) { + switch (this._productName) { + case 'firefox': + this._preferredRevision = PUPPETEER_REVISIONS.firefox; + break; + case 'chrome': + default: + this._preferredRevision = PUPPETEER_REVISIONS.chromium; + } + this._changedProduct = false; + this._lazyLauncher = Launcher( + this._projectRoot, + this._preferredRevision, + this._isPuppeteerCore, + this._productName + ); + } + return this._lazyLauncher; + } + + /** + * The name of the browser that is under automation (`"chrome"` or `"firefox"`) + * + * @remarks + * The product is set by the `PUPPETEER_PRODUCT` environment variable or the `product` + * option in `puppeteer.launch([options])` and defaults to `chrome`. + * Firefox support is experimental. + */ + get product(): string { + return this._launcher.product; + } + + /** + * + * @param options - Set of configurable options to set on the browser. + * @returns The default flags that Chromium will be launched with. + */ + defaultArgs(options: ChromeArgOptions = {}): string[] { + return this._launcher.defaultArgs(options); + } + + /** + * @param options - Set of configurable options to specify the settings + * of the BrowserFetcher. + * @returns A new BrowserFetcher instance. + */ + createBrowserFetcher(options: BrowserFetcherOptions): BrowserFetcher { + return new BrowserFetcher(this._projectRoot, options); + } +} diff --git a/remote/test/puppeteer/src/node/install.ts b/remote/test/puppeteer/src/node/install.ts new file mode 100644 index 0000000000..41f2834d80 --- /dev/null +++ b/remote/test/puppeteer/src/node/install.ts @@ -0,0 +1,185 @@ +/** + * Copyright 2020 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. + */ + +import os from 'os'; +import https from 'https'; +import ProgressBar from 'progress'; +import puppeteer from '../node.js'; +import { PUPPETEER_REVISIONS } from '../revisions.js'; +import { PuppeteerNode } from './Puppeteer.js'; + +const supportedProducts = { + chrome: 'Chromium', + firefox: 'Firefox Nightly', +} as const; + +export async function downloadBrowser() { + const downloadHost = + process.env.PUPPETEER_DOWNLOAD_HOST || + process.env.npm_config_puppeteer_download_host || + process.env.npm_package_config_puppeteer_download_host; + const product = + process.env.PUPPETEER_PRODUCT || + process.env.npm_config_puppeteer_product || + process.env.npm_package_config_puppeteer_product || + 'chrome'; + const downloadPath = + process.env.PUPPETEER_DOWNLOAD_PATH || + process.env.npm_config_puppeteer_download_path || + process.env.npm_package_config_puppeteer_download_path; + const browserFetcher = (puppeteer as PuppeteerNode).createBrowserFetcher({ + product, + host: downloadHost, + path: downloadPath, + }); + const revision = await getRevision(); + await fetchBinary(revision); + + function getRevision() { + if (product === 'chrome') { + return ( + process.env.PUPPETEER_CHROMIUM_REVISION || + process.env.npm_config_puppeteer_chromium_revision || + PUPPETEER_REVISIONS.chromium + ); + } else if (product === 'firefox') { + (puppeteer as PuppeteerNode)._preferredRevision = + PUPPETEER_REVISIONS.firefox; + return getFirefoxNightlyVersion().catch((error) => { + console.error(error); + process.exit(1); + }); + } else { + throw new Error(`Unsupported product ${product}`); + } + } + + function fetchBinary(revision) { + const revisionInfo = browserFetcher.revisionInfo(revision); + + // Do nothing if the revision is already downloaded. + if (revisionInfo.local) { + logPolitely( + `${supportedProducts[product]} is already in ${revisionInfo.folderPath}; skipping download.` + ); + 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; + + function onSuccess(localRevisions: string[]): void { + if (os.arch() !== 'arm64') { + logPolitely( + `${supportedProducts[product]} (${revisionInfo.revision}) downloaded to ${revisionInfo.folderPath}` + ); + } + localRevisions = localRevisions.filter( + (revision) => revision !== revisionInfo.revision + ); + const cleanupOldVersions = localRevisions.map((revision) => + browserFetcher.remove(revision) + ); + Promise.all([...cleanupOldVersions]); + } + + function onError(error: Error) { + console.error( + `ERROR: Failed to set up ${supportedProducts[product]} r${revision}! Set "PUPPETEER_SKIP_DOWNLOAD" env variable to skip download.` + ); + console.error(error); + process.exit(1); + } + + let progressBar = null; + let lastDownloadedBytes = 0; + function onProgress(downloadedBytes, totalBytes) { + if (!progressBar) { + progressBar = new ProgressBar( + `Downloading ${ + supportedProducts[product] + } r${revision} - ${toMegabytes(totalBytes)} [:bar] :percent :etas `, + { + complete: '=', + incomplete: ' ', + width: 20, + total: totalBytes, + } + ); + } + const delta = downloadedBytes - lastDownloadedBytes; + lastDownloadedBytes = downloadedBytes; + progressBar.tick(delta); + } + + return browserFetcher + .download(revisionInfo.revision, onProgress) + .then(() => browserFetcher.localRevisions()) + .then(onSuccess) + .catch(onError); + } + + function toMegabytes(bytes) { + const mb = bytes / 1024 / 1024; + return `${Math.round(mb * 10) / 10} Mb`; + } + + function getFirefoxNightlyVersion() { + const firefoxVersions = + 'https://product-details.mozilla.org/1.0/firefox_versions.json'; + + const promise = new Promise((resolve, reject) => { + let data = ''; + logPolitely( + `Requesting latest Firefox Nightly version from ${firefoxVersions}` + ); + https + .get(firefoxVersions, (r) => { + if (r.statusCode >= 400) + return reject(new Error(`Got status code ${r.statusCode}`)); + r.on('data', (chunk) => { + data += chunk; + }); + r.on('end', () => { + try { + const versions = JSON.parse(data); + return resolve(versions.FIREFOX_NIGHTLY); + } catch { + return reject(new Error('Firefox version not found')); + } + }); + }) + .on('error', reject); + }); + return promise; + } +} + +export function logPolitely(toBeLogged) { + const logLevel = process.env.npm_config_loglevel; + const logLevelDisplay = ['silent', 'error', 'warn'].indexOf(logLevel) > -1; + + // eslint-disable-next-line no-console + if (!logLevelDisplay) console.log(toBeLogged); +} diff --git a/remote/test/puppeteer/src/revisions.ts b/remote/test/puppeteer/src/revisions.ts new file mode 100644 index 0000000000..5e9169adb0 --- /dev/null +++ b/remote/test/puppeteer/src/revisions.ts @@ -0,0 +1,25 @@ +/** + * Copyright 2020 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. + */ + +type Revisions = Readonly<{ + readonly chromium: string; + readonly firefox: string; +}>; + +export const PUPPETEER_REVISIONS: Revisions = { + chromium: '818858', + firefox: 'latest', +}; diff --git a/remote/test/puppeteer/src/tsconfig.cjs.json b/remote/test/puppeteer/src/tsconfig.cjs.json new file mode 100644 index 0000000000..c144b956bf --- /dev/null +++ b/remote/test/puppeteer/src/tsconfig.cjs.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "../lib/cjs/puppeteer", + "module": "CommonJS" + }, + "references": [ + { "path": "../vendor/tsconfig.cjs.json"} + ] +} diff --git a/remote/test/puppeteer/src/tsconfig.esm.json b/remote/test/puppeteer/src/tsconfig.esm.json new file mode 100644 index 0000000000..487533061f --- /dev/null +++ b/remote/test/puppeteer/src/tsconfig.esm.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "../lib/esm/puppeteer", + "module": "esnext" + }, + "references": [ + { "path": "../vendor/tsconfig.esm.json"} + ] +} diff --git a/remote/test/puppeteer/src/web.ts b/remote/test/puppeteer/src/web.ts new file mode 100644 index 0000000000..a48a5cf14d --- /dev/null +++ b/remote/test/puppeteer/src/web.ts @@ -0,0 +1,24 @@ +/** + * Copyright 2020 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. + */ + +import { initializePuppeteerWeb } from './initialize-web.js'; +import { isNode } from './environment.js'; + +if (isNode) { + throw new Error('Trying to run Puppeteer-Web in a Node environment'); +} + +export default initializePuppeteerWeb('puppeteer'); diff --git a/remote/test/puppeteer/test-browser/connection.spec.js b/remote/test/puppeteer/test-browser/connection.spec.js new file mode 100644 index 0000000000..eef8fe3c21 --- /dev/null +++ b/remote/test/puppeteer/test-browser/connection.spec.js @@ -0,0 +1,55 @@ +/** + * Copyright 2020 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. + */ +import { Connection } from '../lib/esm/puppeteer/common/Connection.js'; +import { BrowserWebSocketTransport } from '../lib/esm/puppeteer/common/BrowserWebSocketTransport.js'; +import puppeteer from '../lib/esm/puppeteer/web.js'; +import expect from '../node_modules/expect/build-es5/index.js'; +import { getWebSocketEndpoint } from './helper.js'; + +describe('creating a Connection', () => { + it('can create a real connection to the backend and send messages', async () => { + const wsUrl = getWebSocketEndpoint(); + const transport = await BrowserWebSocketTransport.create(wsUrl); + + const connection = new Connection(wsUrl, transport); + const result = await connection.send('Browser.getVersion'); + /* We can't expect exact results as the version of Chrome/CDP might change + * and we don't want flakey tests, so let's assert the structure, which is + * enough to confirm the result was recieved successfully. + */ + expect(result).toEqual({ + protocolVersion: expect.any(String), + jsVersion: expect.any(String), + revision: expect.any(String), + userAgent: expect.any(String), + product: expect.any(String), + }); + }); +}); + +describe('puppeteer.connect', () => { + it('can connect over websocket and make requests to the backend', async () => { + const wsUrl = getWebSocketEndpoint(); + const browser = await puppeteer.connect({ + browserWSEndpoint: wsUrl, + }); + + const version = await browser.version(); + const versionLooksCorrect = /.+Chrome\/\d{2}/.test(version); + expect(version).toEqual(expect.any(String)); + expect(versionLooksCorrect).toEqual(true); + }); +}); diff --git a/remote/test/puppeteer/test-browser/debug.spec.js b/remote/test/puppeteer/test-browser/debug.spec.js new file mode 100644 index 0000000000..971d3a5346 --- /dev/null +++ b/remote/test/puppeteer/test-browser/debug.spec.js @@ -0,0 +1,65 @@ +/** + * Copyright 2020 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. + */ + +import { debug } from '../lib/esm/puppeteer/common/Debug.js'; +import expect from '../node_modules/expect/build-es5/index.js'; + +describe('debug', () => { + let originalLog; + let logs; + beforeEach(() => { + originalLog = console.log; + logs = []; + console.log = (...args) => { + logs.push(args); + }; + }); + + afterEach(() => { + console.log = originalLog; + }); + + it('should return a function', async () => { + expect(debug('foo')).toBeInstanceOf(Function); + }); + + it('does not log to the console if __PUPPETEER_DEBUG global is not set', async () => { + const debugFn = debug('foo'); + debugFn('lorem', 'ipsum'); + + expect(logs.length).toEqual(0); + }); + + it('logs to the console if __PUPPETEER_DEBUG global is set to *', async () => { + globalThis.__PUPPETEER_DEBUG = '*'; + const debugFn = debug('foo'); + debugFn('lorem', 'ipsum'); + + expect(logs.length).toEqual(1); + expect(logs).toEqual([['foo:', 'lorem', 'ipsum']]); + }); + + it('logs only messages matching the __PUPPETEER_DEBUG prefix', async () => { + globalThis.__PUPPETEER_DEBUG = 'foo'; + const debugFoo = debug('foo'); + const debugBar = debug('bar'); + debugFoo('a'); + debugBar('b'); + + expect(logs.length).toEqual(1); + expect(logs).toEqual([['foo:', 'a']]); + }); +}); diff --git a/remote/test/puppeteer/test-browser/helper.js b/remote/test/puppeteer/test-browser/helper.js new file mode 100644 index 0000000000..6cfbe934d5 --- /dev/null +++ b/remote/test/puppeteer/test-browser/helper.js @@ -0,0 +1,27 @@ +/** + * Copyright 2020 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. + */ + +/** + * Returns the web socket endpoint for the backend of the browser the tests run + * in. Used to create connections to that browser in Puppeteer for unit tests. + * + * It's available on window.__ENV__ because setup code in + * web-test-runner.config.js puts it there. If you're changing this code (or + * that code), make sure the other is updated accordingly. + */ +export function getWebSocketEndpoint() { + return window.__ENV__.wsEndpoint; +} diff --git a/remote/test/puppeteer/test/.eslintrc.js b/remote/test/puppeteer/test/.eslintrc.js new file mode 100644 index 0000000000..9d86da20e1 --- /dev/null +++ b/remote/test/puppeteer/test/.eslintrc.js @@ -0,0 +1,13 @@ +module.exports = { + rules: { + 'no-restricted-imports': [ + 'error', + { + /** The mocha tests run on the compiled output in the /lib directory + * so we should avoid importing from src. + */ + patterns: ['*src*'], + }, + ], + }, +}; diff --git a/remote/test/puppeteer/test/CDPSession.spec.ts b/remote/test/puppeteer/test/CDPSession.spec.ts new file mode 100644 index 0000000000..2ebf10fd96 --- /dev/null +++ b/remote/test/puppeteer/test/CDPSession.spec.ts @@ -0,0 +1,106 @@ +/** + * 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. + */ + +import { waitEvent } from './utils.js'; +import expect from 'expect'; +import { + getTestState, + setupTestBrowserHooks, + setupTestPageAndContextHooks, + describeChromeOnly, +} from './mocha-utils'; // eslint-disable-line import/extensions + +describeChromeOnly('Target.createCDPSession', function () { + setupTestBrowserHooks(); + setupTestPageAndContextHooks(); + + it('should work', async () => { + const { page } = getTestState(); + + const client = await page.target().createCDPSession(); + + await Promise.all([ + client.send('Runtime.enable'), + client.send('Runtime.evaluate', { expression: 'window.foo = "bar"' }), + ]); + const foo = await page.evaluate(() => globalThis.foo); + expect(foo).toBe('bar'); + }); + it('should send events', async () => { + const { page, server } = getTestState(); + + const client = await page.target().createCDPSession(); + await client.send('Network.enable'); + const events = []; + client.on('Network.requestWillBeSent', (event) => events.push(event)); + await page.goto(server.EMPTY_PAGE); + expect(events.length).toBe(1); + }); + it('should enable and disable domains independently', async () => { + const { page } = getTestState(); + + const client = await page.target().createCDPSession(); + await client.send('Runtime.enable'); + await client.send('Debugger.enable'); + // JS coverage enables and then disables Debugger domain. + await page.coverage.startJSCoverage(); + await page.coverage.stopJSCoverage(); + // generate a script in page and wait for the event. + const [event] = await Promise.all([ + waitEvent(client, 'Debugger.scriptParsed'), + page.evaluate('//# sourceURL=foo.js'), + ]); + // expect events to be dispatched. + expect(event.url).toBe('foo.js'); + }); + it('should be able to detach session', async () => { + const { page } = getTestState(); + + const client = await page.target().createCDPSession(); + await client.send('Runtime.enable'); + const evalResponse = await client.send('Runtime.evaluate', { + expression: '1 + 2', + returnByValue: true, + }); + expect(evalResponse.result.value).toBe(3); + await client.detach(); + let error = null; + try { + await client.send('Runtime.evaluate', { + expression: '3 + 1', + returnByValue: true, + }); + } catch (error_) { + error = error_; + } + expect(error.message).toContain('Session closed.'); + }); + it('should throw nice errors', async () => { + const { page } = getTestState(); + + const client = await page.target().createCDPSession(); + const error = await theSourceOfTheProblems().catch((error) => error); + expect(error.stack).toContain('theSourceOfTheProblems'); + expect(error.message).toContain('ThisCommand.DoesNotExist'); + + async function theSourceOfTheProblems() { + // @ts-expect-error This fails in TS as it knows that command does not + // exist but we want to have this tests for our users who consume in JS + // not TS. + await client.send('ThisCommand.DoesNotExist'); + } + }); +}); diff --git a/remote/test/puppeteer/test/EventEmitter.spec.ts b/remote/test/puppeteer/test/EventEmitter.spec.ts new file mode 100644 index 0000000000..bf20e7fe8f --- /dev/null +++ b/remote/test/puppeteer/test/EventEmitter.spec.ts @@ -0,0 +1,170 @@ +/** + * Copyright 2020 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. + */ + +import { EventEmitter } from '../lib/cjs/puppeteer/common/EventEmitter.js'; +import sinon from 'sinon'; +import expect from 'expect'; + +describe('EventEmitter', () => { + let emitter; + + beforeEach(() => { + emitter = new EventEmitter(); + }); + + describe('on', () => { + const onTests = (methodName: 'on' | 'addListener'): void => { + it(`${methodName}: adds an event listener that is fired when the event is emitted`, () => { + const listener = sinon.spy(); + emitter[methodName]('foo', listener); + emitter.emit('foo'); + expect(listener.callCount).toEqual(1); + }); + + it(`${methodName} sends the event data to the handler`, () => { + const listener = sinon.spy(); + const data = {}; + emitter[methodName]('foo', listener); + emitter.emit('foo', data); + expect(listener.callCount).toEqual(1); + expect(listener.firstCall.args[0]).toBe(data); + }); + + it(`${methodName}: supports chaining`, () => { + const listener = sinon.spy(); + const returnValue = emitter[methodName]('foo', listener); + expect(returnValue).toBe(emitter); + }); + }; + onTests('on'); + // we support addListener for legacy reasons + onTests('addListener'); + }); + + describe('off', () => { + const offTests = (methodName: 'off' | 'removeListener'): void => { + it(`${methodName}: removes the listener so it is no longer called`, () => { + const listener = sinon.spy(); + emitter.on('foo', listener); + emitter.emit('foo'); + expect(listener.callCount).toEqual(1); + emitter.off('foo', listener); + emitter.emit('foo'); + expect(listener.callCount).toEqual(1); + }); + + it(`${methodName}: supports chaining`, () => { + const listener = sinon.spy(); + emitter.on('foo', listener); + const returnValue = emitter.off('foo', listener); + expect(returnValue).toBe(emitter); + }); + }; + offTests('off'); + // we support removeListener for legacy reasons + offTests('removeListener'); + }); + + describe('once', () => { + it('only calls the listener once and then removes it', () => { + const listener = sinon.spy(); + emitter.once('foo', listener); + emitter.emit('foo'); + expect(listener.callCount).toEqual(1); + emitter.emit('foo'); + expect(listener.callCount).toEqual(1); + }); + + it('supports chaining', () => { + const listener = sinon.spy(); + const returnValue = emitter.once('foo', listener); + expect(returnValue).toBe(emitter); + }); + }); + + describe('emit', () => { + it('calls all the listeners for an event', () => { + const listener1 = sinon.spy(); + const listener2 = sinon.spy(); + const listener3 = sinon.spy(); + emitter.on('foo', listener1).on('foo', listener2).on('bar', listener3); + + emitter.emit('foo'); + + expect(listener1.callCount).toEqual(1); + expect(listener2.callCount).toEqual(1); + expect(listener3.callCount).toEqual(0); + }); + + it('passes data through to the listener', () => { + const listener = sinon.spy(); + emitter.on('foo', listener); + const data = {}; + + emitter.emit('foo', data); + expect(listener.callCount).toEqual(1); + expect(listener.firstCall.args[0]).toBe(data); + }); + + it('returns true if the event has listeners', () => { + const listener = sinon.spy(); + emitter.on('foo', listener); + expect(emitter.emit('foo')).toBe(true); + }); + + it('returns false if the event has listeners', () => { + const listener = sinon.spy(); + emitter.on('foo', listener); + expect(emitter.emit('notFoo')).toBe(false); + }); + }); + + describe('listenerCount', () => { + it('returns the number of listeners for the given event', () => { + emitter.on('foo', () => {}); + emitter.on('foo', () => {}); + emitter.on('bar', () => {}); + expect(emitter.listenerCount('foo')).toEqual(2); + expect(emitter.listenerCount('bar')).toEqual(1); + expect(emitter.listenerCount('noListeners')).toEqual(0); + }); + }); + + describe('removeAllListeners', () => { + it('removes every listener from all events by default', () => { + emitter.on('foo', () => {}).on('bar', () => {}); + + emitter.removeAllListeners(); + expect(emitter.emit('foo')).toBe(false); + expect(emitter.emit('bar')).toBe(false); + }); + + it('returns the emitter for chaining', () => { + expect(emitter.removeAllListeners()).toBe(emitter); + }); + + it('can filter to remove only listeners for a given event name', () => { + emitter + .on('foo', () => {}) + .on('bar', () => {}) + .on('bar', () => {}); + + emitter.removeAllListeners('bar'); + expect(emitter.emit('foo')).toBe(true); + expect(emitter.emit('bar')).toBe(false); + }); + }); +}); diff --git a/remote/test/puppeteer/test/README.md b/remote/test/puppeteer/test/README.md new file mode 100644 index 0000000000..304e3e0b0a --- /dev/null +++ b/remote/test/puppeteer/test/README.md @@ -0,0 +1,88 @@ +# Puppeteer unit tests + +Unit tests in Puppeteer are written using [Mocha] as the test runner and [Expect] as the assertions library. + +## Test state + + +We have some common setup that runs before each test and is defined in `mocha-utils.js`. + +You can use the `getTestState` function to read state. It exposes the following that you can use in your tests. These will be reset/tidied between tests automatically for you: + +* `puppeteer`: an instance of the Puppeteer library. This is exactly what you'd get if you ran `require('puppeteer')`. +* `puppeteerPath`: the path to the root source file for Puppeteer. +* `defaultBrowserOptions`: the default options the Puppeteer browser is launched from in test mode, so tests can use them and override if required. +* `server`: a dummy test server instance (see `utils/testserver` for more). +* `httpsServer`: a dummy test server HTTPS instance (see `utils/testserver` for more). +* `isFirefox`: true if running in Firefox. +* `isChrome`: true if running Chromium. +* `isHeadless`: true if the test is in headless mode. + +If your test needs a browser instance, you can use the `setupTestBrowserHooks()` function which will automatically configure a browser that will be cleaned between each test suite run. You access this via `getTestState()`. + +If your test needs a Puppeteer page and context, you can use the `setupTestPageAndContextHooks()` function which will configure these. You can access `page` and `context` from `getTestState()` once you have done this. + +The best place to look is an existing test to see how they use the helpers. + +## Skipping tests in specific conditions + +Tests that are not expected to pass in Firefox can be skipped. You can skip an individual test by using `itFailsFirefox` rather than `it`. Similarly you can skip a describe block with `describeFailsFirefox`. + +There is also `describeChromeOnly` and `itChromeOnly` which will only execute the test if running in Chromium. Note that this is different from `describeFailsFirefox`: the goal is to get any `FailsFirefox` calls passing in Firefox, whereas `describeChromeOnly` should be used to test behaviour that will only ever apply in Chromium. + +There are also tests that assume a normal install flow, with browser binaries ending up in `.local-<browser>`, for example. Such tests are skipped with +`itOnlyRegularInstall` which checks `BINARY` and `PUPPETEER_ALT_INSTALL` environment variables. + +[Mocha]: https://mochajs.org/ +[Expect]: https://www.npmjs.com/package/expect + +## Running tests + +Despite being named 'unit', these are integration tests, making sure public API methods and events work as expected. + +- To run all tests: + +```bash +npm run unit +``` + +- __Important__: don't forget to first run TypeScript if you're testing local changes: + +```bash +npm run tsc && npm run unit +``` + +- To run a specific test, substitute the `it` with `it.only`: + +```js + ... + it.only('should work', async function() { + const {server, page} = getTestState(); + const response = await page.goto(server.EMPTY_PAGE); + expect(response.ok).toBe(true); + }); +``` + +- To disable a specific test, substitute the `it` with `xit` (mnemonic rule: '*cross it*'): + +```js + ... + // Using "xit" to skip specific test + xit('should work', async function({server, page}) { + const {server, page} = getTestState(); + const response = await page.goto(server.EMPTY_PAGE); + expect(response.ok).toBe(true); + }); +``` + +- To run tests in non-headless mode: + +```bash +HEADLESS=false npm run unit +``` + +- To run tests with custom browser executable: + +```bash +BINARY=<path-to-executable> npm run unit +``` diff --git a/remote/test/puppeteer/test/accessibility.spec.ts b/remote/test/puppeteer/test/accessibility.spec.ts new file mode 100644 index 0000000000..e941c683a4 --- /dev/null +++ b/remote/test/puppeteer/test/accessibility.spec.ts @@ -0,0 +1,520 @@ +/** + * 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. + */ + +import expect from 'expect'; +import { + getTestState, + setupTestBrowserHooks, + setupTestPageAndContextHooks, + describeFailsFirefox, +} from './mocha-utils'; // eslint-disable-line import/extensions + +describeFailsFirefox('Accessibility', function () { + setupTestBrowserHooks(); + setupTestPageAndContextHooks(); + + it('should work', async () => { + const { page, isFirefox } = getTestState(); + + await page.setContent(` + <head> + <title>Accessibility Test</title> + </head> + <body> + <div>Hello World</div> + <h1>Inputs</h1> + <input placeholder="Empty input" autofocus /> + <input placeholder="readonly input" readonly /> + <input placeholder="disabled input" disabled /> + <input aria-label="Input with whitespace" value=" " /> + <input value="value only" /> + <input aria-placeholder="placeholder" value="and a value" /> + <div aria-hidden="true" id="desc">This is a description!</div> + <input aria-placeholder="placeholder" value="and a value" aria-describedby="desc" /> + <select> + <option>First Option</option> + <option>Second Option</option> + </select> + </body>`); + + await page.focus('[placeholder="Empty input"]'); + const golden = isFirefox + ? { + role: 'document', + name: 'Accessibility Test', + children: [ + { role: 'text leaf', name: 'Hello World' }, + { role: 'heading', name: 'Inputs', level: 1 }, + { role: 'entry', name: 'Empty input', focused: true }, + { role: 'entry', name: 'readonly input', readonly: true }, + { role: 'entry', name: 'disabled input', disabled: true }, + { role: 'entry', name: 'Input with whitespace', value: ' ' }, + { role: 'entry', name: '', value: 'value only' }, + { role: 'entry', name: '', value: 'and a value' }, // firefox doesn't use aria-placeholder for the name + { + role: 'entry', + name: '', + value: 'and a value', + description: 'This is a description!', + }, // and here + { + role: 'combobox', + name: '', + value: 'First Option', + haspopup: true, + children: [ + { + role: 'combobox option', + name: 'First Option', + selected: true, + }, + { role: 'combobox option', name: 'Second Option' }, + ], + }, + ], + } + : { + role: 'WebArea', + name: 'Accessibility Test', + children: [ + { role: 'text', name: 'Hello World' }, + { role: 'heading', name: 'Inputs', level: 1 }, + { role: 'textbox', name: 'Empty input', focused: true }, + { role: 'textbox', name: 'readonly input', readonly: true }, + { role: 'textbox', name: 'disabled input', disabled: true }, + { role: 'textbox', name: 'Input with whitespace', value: ' ' }, + { role: 'textbox', name: '', value: 'value only' }, + { role: 'textbox', name: 'placeholder', value: 'and a value' }, + { + role: 'textbox', + name: 'placeholder', + value: 'and a value', + description: 'This is a description!', + }, + { + role: 'combobox', + name: '', + value: 'First Option', + children: [ + { role: 'menuitem', name: 'First Option', selected: true }, + { role: 'menuitem', name: 'Second Option' }, + ], + }, + ], + }; + expect(await page.accessibility.snapshot()).toEqual(golden); + }); + it('should report uninteresting nodes', async () => { + const { page, isFirefox } = getTestState(); + + await page.setContent(`<textarea>hi</textarea>`); + await page.focus('textarea'); + const golden = isFirefox + ? { + role: 'entry', + name: '', + value: 'hi', + focused: true, + multiline: true, + children: [ + { + role: 'text leaf', + name: 'hi', + }, + ], + } + : { + role: 'textbox', + name: '', + value: 'hi', + focused: true, + multiline: true, + children: [ + { + role: 'generic', + name: '', + children: [ + { + role: 'text', + name: 'hi', + }, + ], + }, + ], + }; + expect( + findFocusedNode( + await page.accessibility.snapshot({ interestingOnly: false }) + ) + ).toEqual(golden); + }); + it('roledescription', async () => { + const { page } = getTestState(); + + await page.setContent( + '<div tabIndex=-1 aria-roledescription="foo">Hi</div>' + ); + const snapshot = await page.accessibility.snapshot(); + expect(snapshot.children[0].roledescription).toEqual('foo'); + }); + it('orientation', async () => { + const { page } = getTestState(); + + await page.setContent( + '<a href="" role="slider" aria-orientation="vertical">11</a>' + ); + const snapshot = await page.accessibility.snapshot(); + expect(snapshot.children[0].orientation).toEqual('vertical'); + }); + it('autocomplete', async () => { + const { page } = getTestState(); + + await page.setContent('<input type="number" aria-autocomplete="list" />'); + const snapshot = await page.accessibility.snapshot(); + expect(snapshot.children[0].autocomplete).toEqual('list'); + }); + it('multiselectable', async () => { + const { page } = getTestState(); + + await page.setContent( + '<div role="grid" tabIndex=-1 aria-multiselectable=true>hey</div>' + ); + const snapshot = await page.accessibility.snapshot(); + expect(snapshot.children[0].multiselectable).toEqual(true); + }); + it('keyshortcuts', async () => { + const { page } = getTestState(); + + await page.setContent( + '<div role="grid" tabIndex=-1 aria-keyshortcuts="foo">hey</div>' + ); + const snapshot = await page.accessibility.snapshot(); + expect(snapshot.children[0].keyshortcuts).toEqual('foo'); + }); + describe('filtering children of leaf nodes', function () { + it('should not report text nodes inside controls', async () => { + const { page, isFirefox } = getTestState(); + + await page.setContent(` + <div role="tablist"> + <div role="tab" aria-selected="true"><b>Tab1</b></div> + <div role="tab">Tab2</div> + </div>`); + const golden = isFirefox + ? { + role: 'document', + name: '', + children: [ + { + role: 'pagetab', + name: 'Tab1', + selected: true, + }, + { + role: 'pagetab', + name: 'Tab2', + }, + ], + } + : { + role: 'WebArea', + name: '', + children: [ + { + role: 'tab', + name: 'Tab1', + selected: true, + }, + { + role: 'tab', + name: 'Tab2', + }, + ], + }; + expect(await page.accessibility.snapshot()).toEqual(golden); + }); + it('rich text editable fields should have children', async () => { + const { page, isFirefox } = getTestState(); + + await page.setContent(` + <div contenteditable="true"> + Edit this image: <img src="fakeimage.png" alt="my fake image"> + </div>`); + const golden = isFirefox + ? { + role: 'section', + name: '', + children: [ + { + role: 'text leaf', + name: 'Edit this image: ', + }, + { + role: 'text', + name: 'my fake image', + }, + ], + } + : { + role: 'generic', + name: '', + value: 'Edit this image: ', + children: [ + { + role: 'text', + name: 'Edit this image:', + }, + { + role: 'img', + name: 'my fake image', + }, + ], + }; + const snapshot = await page.accessibility.snapshot(); + expect(snapshot.children[0]).toEqual(golden); + }); + it('rich text editable fields with role should have children', async () => { + const { page, isFirefox } = getTestState(); + + await page.setContent(` + <div contenteditable="true" role='textbox'> + Edit this image: <img src="fakeimage.png" alt="my fake image"> + </div>`); + const golden = isFirefox + ? { + role: 'entry', + name: '', + value: 'Edit this image: my fake image', + children: [ + { + role: 'text', + name: 'my fake image', + }, + ], + } + : { + role: 'textbox', + name: '', + value: 'Edit this image: ', + children: [ + { + role: 'text', + name: 'Edit this image:', + }, + { + role: 'img', + name: 'my fake image', + }, + ], + }; + const snapshot = await page.accessibility.snapshot(); + expect(snapshot.children[0]).toEqual(golden); + }); + + // Firefox does not support contenteditable="plaintext-only". + describeFailsFirefox('plaintext contenteditable', function () { + it('plain text field with role should not have children', async () => { + const { page } = getTestState(); + + await page.setContent(` + <div contenteditable="plaintext-only" role='textbox'>Edit this image:<img src="fakeimage.png" alt="my fake image"></div>`); + const snapshot = await page.accessibility.snapshot(); + expect(snapshot.children[0]).toEqual({ + role: 'textbox', + name: '', + value: 'Edit this image:', + }); + }); + it('plain text field without role should not have content', async () => { + const { page } = getTestState(); + + await page.setContent(` + <div contenteditable="plaintext-only">Edit this image:<img src="fakeimage.png" alt="my fake image"></div>`); + const snapshot = await page.accessibility.snapshot(); + expect(snapshot.children[0]).toEqual({ + role: 'generic', + name: '', + }); + }); + it('plain text field with tabindex and without role should not have content', async () => { + const { page } = getTestState(); + + await page.setContent(` + <div contenteditable="plaintext-only" tabIndex=0>Edit this image:<img src="fakeimage.png" alt="my fake image"></div>`); + const snapshot = await page.accessibility.snapshot(); + expect(snapshot.children[0]).toEqual({ + role: 'generic', + name: '', + }); + }); + }); + it('non editable textbox with role and tabIndex and label should not have children', async () => { + const { page, isFirefox } = getTestState(); + + await page.setContent(` + <div role="textbox" tabIndex=0 aria-checked="true" aria-label="my favorite textbox"> + this is the inner content + <img alt="yo" src="fakeimg.png"> + </div>`); + const golden = isFirefox + ? { + role: 'entry', + name: 'my favorite textbox', + value: 'this is the inner content yo', + } + : { + role: 'textbox', + name: 'my favorite textbox', + value: 'this is the inner content ', + }; + const snapshot = await page.accessibility.snapshot(); + expect(snapshot.children[0]).toEqual(golden); + }); + it('checkbox with and tabIndex and label should not have children', async () => { + const { page, isFirefox } = getTestState(); + + await page.setContent(` + <div role="checkbox" tabIndex=0 aria-checked="true" aria-label="my favorite checkbox"> + this is the inner content + <img alt="yo" src="fakeimg.png"> + </div>`); + const golden = isFirefox + ? { + role: 'checkbutton', + name: 'my favorite checkbox', + checked: true, + } + : { + role: 'checkbox', + name: 'my favorite checkbox', + checked: true, + }; + const snapshot = await page.accessibility.snapshot(); + expect(snapshot.children[0]).toEqual(golden); + }); + it('checkbox without label should not have children', async () => { + const { page, isFirefox } = getTestState(); + + await page.setContent(` + <div role="checkbox" aria-checked="true"> + this is the inner content + <img alt="yo" src="fakeimg.png"> + </div>`); + const golden = isFirefox + ? { + role: 'checkbutton', + name: 'this is the inner content yo', + checked: true, + } + : { + role: 'checkbox', + name: 'this is the inner content yo', + checked: true, + }; + const snapshot = await page.accessibility.snapshot(); + expect(snapshot.children[0]).toEqual(golden); + }); + + describe('root option', function () { + it('should work a button', async () => { + const { page } = getTestState(); + + await page.setContent(`<button>My Button</button>`); + + const button = await page.$('button'); + expect(await page.accessibility.snapshot({ root: button })).toEqual({ + role: 'button', + name: 'My Button', + }); + }); + it('should work an input', async () => { + const { page } = getTestState(); + + await page.setContent(`<input title="My Input" value="My Value">`); + + const input = await page.$('input'); + expect(await page.accessibility.snapshot({ root: input })).toEqual({ + role: 'textbox', + name: 'My Input', + value: 'My Value', + }); + }); + it('should work a menu', async () => { + const { page } = getTestState(); + + await page.setContent(` + <div role="menu" title="My Menu"> + <div role="menuitem">First Item</div> + <div role="menuitem">Second Item</div> + <div role="menuitem">Third Item</div> + </div> + `); + + const menu = await page.$('div[role="menu"]'); + expect(await page.accessibility.snapshot({ root: menu })).toEqual({ + role: 'menu', + name: 'My Menu', + children: [ + { role: 'menuitem', name: 'First Item' }, + { role: 'menuitem', name: 'Second Item' }, + { role: 'menuitem', name: 'Third Item' }, + ], + }); + }); + it('should return null when the element is no longer in DOM', async () => { + const { page } = getTestState(); + + await page.setContent(`<button>My Button</button>`); + const button = await page.$('button'); + await page.$eval('button', (button) => button.remove()); + expect(await page.accessibility.snapshot({ root: button })).toEqual( + null + ); + }); + it('should support the interestingOnly option', async () => { + const { page } = getTestState(); + + await page.setContent(`<div><button>My Button</button></div>`); + const div = await page.$('div'); + expect(await page.accessibility.snapshot({ root: div })).toEqual(null); + expect( + await page.accessibility.snapshot({ + root: div, + interestingOnly: false, + }) + ).toEqual({ + role: 'generic', + name: '', + children: [ + { + role: 'button', + name: 'My Button', + children: [{ role: 'text', name: 'My Button' }], + }, + ], + }); + }); + }); + }); + function findFocusedNode(node) { + if (node.focused) return node; + for (const child of node.children || []) { + const focusedChild = findFocusedNode(child); + if (focusedChild) return focusedChild; + } + return null; + } +}); diff --git a/remote/test/puppeteer/test/ariaqueryhandler.spec.ts b/remote/test/puppeteer/test/ariaqueryhandler.spec.ts new file mode 100644 index 0000000000..83f78fd611 --- /dev/null +++ b/remote/test/puppeteer/test/ariaqueryhandler.spec.ts @@ -0,0 +1,565 @@ +/** + * Copyright 2020 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. + */ + +import expect from 'expect'; +import { + getTestState, + setupTestBrowserHooks, + setupTestPageAndContextHooks, + describeChromeOnly, +} from './mocha-utils'; // eslint-disable-line import/extensions + +import { ElementHandle } from '../lib/cjs/puppeteer/common/JSHandle.js'; +import utils from './utils.js'; + +describeChromeOnly('AriaQueryHandler', () => { + setupTestBrowserHooks(); + setupTestPageAndContextHooks(); + + describe('parseAriaSelector', () => { + beforeEach(async () => { + const { page } = getTestState(); + await page.setContent( + '<button id="btn" role="button"> Submit button and some spaces </button>' + ); + }); + it('should find button', async () => { + const { page } = getTestState(); + const expectFound = async (button: ElementHandle) => { + const id = await button.evaluate((button: Element) => button.id); + expect(id).toBe('btn'); + }; + let button = await page.$( + 'aria/Submit button and some spaces[role="button"]' + ); + await expectFound(button); + button = await page.$( + 'aria/ Submit button and some spaces[role="button"]' + ); + await expectFound(button); + button = await page.$( + 'aria/Submit button and some spaces [role="button"]' + ); + await expectFound(button); + button = await page.$( + 'aria/Submit button and some spaces [ role = "button" ] ' + ); + await expectFound(button); + button = await page.$( + 'aria/[role="button"]Submit button and some spaces' + ); + await expectFound(button); + button = await page.$( + 'aria/Submit button [role="button"]and some spaces' + ); + await expectFound(button); + button = await page.$( + 'aria/[name=" Submit button and some spaces"][role="button"]' + ); + await expectFound(button); + button = await page.$( + 'aria/ignored[name="Submit button and some spaces"][role="button"]' + ); + await expectFound(button); + }); + }); + + describe('queryOne', () => { + it('should find button by role', async () => { + const { page } = getTestState(); + await page.setContent( + '<div id="div"><button id="btn" role="button">Submit</button></div>' + ); + const button = await page.$('aria/[role="button"]'); + const id = await button.evaluate((button: Element) => button.id); + expect(id).toBe('btn'); + }); + + it('should find button by name and role', async () => { + const { page } = getTestState(); + await page.setContent( + '<div id="div"><button id="btn" role="button">Submit</button></div>' + ); + const button = await page.$('aria/Submit[role="button"]'); + const id = await button.evaluate((button: Element) => button.id); + expect(id).toBe('btn'); + }); + + it('should find first matching element', async () => { + const { page } = getTestState(); + await page.setContent( + ` + <div role="menu" id="mnu1" aria-label="menu div"></div> + <div role="menu" id="mnu2" aria-label="menu div"></div> + ` + ); + const div = await page.$('aria/menu div'); + const id = await div.evaluate((div: Element) => div.id); + expect(id).toBe('mnu1'); + }); + + it('should find by name', async () => { + const { page } = getTestState(); + await page.setContent( + ` + <div role="menu" id="mnu1" aria-label="menu-label1">menu div</div> + <div role="menu" id="mnu2" aria-label="menu-label2">menu div</div> + ` + ); + const menu = await page.$('aria/menu-label1'); + const id = await menu.evaluate((div: Element) => div.id); + expect(id).toBe('mnu1'); + }); + + it('should find by name', async () => { + const { page } = getTestState(); + await page.setContent( + ` + <div role="menu" id="mnu1" aria-label="menu-label1">menu div</div> + <div role="menu" id="mnu2" aria-label="menu-label2">menu div</div> + ` + ); + const menu = await page.$('aria/menu-label2'); + const id = await menu.evaluate((div: Element) => div.id); + expect(id).toBe('mnu2'); + }); + }); + + describe('queryAll', () => { + it('should find menu by name', async () => { + const { page } = getTestState(); + await page.setContent( + ` + <div role="menu" id="mnu1" aria-label="menu div"></div> + <div role="menu" id="mnu2" aria-label="menu div"></div> + ` + ); + const divs = await page.$$('aria/menu div'); + const ids = await Promise.all( + divs.map((n) => n.evaluate((div: Element) => div.id)) + ); + expect(ids.join(', ')).toBe('mnu1, mnu2'); + }); + }); + describe('queryAllArray', () => { + it('$$eval should handle many elements', async () => { + const { page } = getTestState(); + await page.setContent(''); + await page.evaluate( + ` + for (var i = 0; i <= 10000; i++) { + const button = document.createElement('button'); + button.textContent = i; + document.body.appendChild(button); + } + ` + ); + const sum = await page.$$eval('aria/[role="button"]', (buttons) => + buttons.reduce((acc, button) => acc + Number(button.textContent), 0) + ); + expect(sum).toBe(50005000); + }); + }); + + describe('waitForSelector (aria)', function () { + const addElement = (tag) => + document.body.appendChild(document.createElement(tag)); + + it('should immediately resolve promise if node exists', async () => { + const { page, server } = getTestState(); + await page.goto(server.EMPTY_PAGE); + await page.evaluate(addElement, 'button'); + await page.waitForSelector('aria/[role="button"]'); + }); + + it('should work independently of `exposeFunction`', async () => { + const { page, server } = getTestState(); + await page.goto(server.EMPTY_PAGE); + await page.exposeFunction('ariaQuerySelector', (a, b) => a + b); + await page.evaluate(addElement, 'button'); + await page.waitForSelector('aria/[role="button"]'); + const result = await page.evaluate('globalThis.ariaQuerySelector(2,8)'); + expect(result).toBe(10); + }); + + it('should work with removed MutationObserver', async () => { + const { page } = getTestState(); + + await page.evaluate(() => delete window.MutationObserver); + const [handle] = await Promise.all([ + page.waitForSelector('aria/anything'), + page.setContent(`<h1>anything</h1>`), + ]); + expect( + await page.evaluate((x: HTMLElement) => x.textContent, handle) + ).toBe('anything'); + }); + + it('should resolve promise when node is added', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + const frame = page.mainFrame(); + const watchdog = frame.waitForSelector('aria/[role="heading"]'); + await frame.evaluate(addElement, 'br'); + await frame.evaluate(addElement, 'h1'); + const elementHandle = await watchdog; + const tagName = await elementHandle + .getProperty('tagName') + .then((element) => element.jsonValue()); + expect(tagName).toBe('H1'); + }); + + it('should work when node is added through innerHTML', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + const watchdog = page.waitForSelector('aria/name'); + await page.evaluate(addElement, 'span'); + await page.evaluate( + () => + (document.querySelector('span').innerHTML = + '<h3><div aria-label="name"></div></h3>') + ); + await watchdog; + }); + + it('Page.waitForSelector is shortcut for main frame', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + await utils.attachFrame(page, 'frame1', server.EMPTY_PAGE); + const otherFrame = page.frames()[1]; + const watchdog = page.waitForSelector('aria/[role="button"]'); + await otherFrame.evaluate(addElement, 'button'); + await page.evaluate(addElement, 'button'); + const elementHandle = await watchdog; + expect(elementHandle.executionContext().frame()).toBe(page.mainFrame()); + }); + + it('should run in specified frame', async () => { + const { page, server } = getTestState(); + + await utils.attachFrame(page, 'frame1', server.EMPTY_PAGE); + await utils.attachFrame(page, 'frame2', server.EMPTY_PAGE); + const frame1 = page.frames()[1]; + const frame2 = page.frames()[2]; + const waitForSelectorPromise = frame2.waitForSelector( + 'aria/[role="button"]' + ); + await frame1.evaluate(addElement, 'button'); + await frame2.evaluate(addElement, 'button'); + const elementHandle = await waitForSelectorPromise; + expect(elementHandle.executionContext().frame()).toBe(frame2); + }); + + it('should throw when frame is detached', async () => { + const { page, server } = getTestState(); + + await utils.attachFrame(page, 'frame1', server.EMPTY_PAGE); + const frame = page.frames()[1]; + let waitError = null; + const waitPromise = frame + .waitForSelector('aria/does-not-exist') + .catch((error) => (waitError = error)); + await utils.detachFrame(page, 'frame1'); + await waitPromise; + expect(waitError).toBeTruthy(); + expect(waitError.message).toContain( + 'waitForFunction failed: frame got detached.' + ); + }); + + it('should survive cross-process navigation', async () => { + const { page, server } = getTestState(); + + let imgFound = false; + const waitForSelector = page + .waitForSelector('aria/[role="img"]') + .then(() => (imgFound = true)); + await page.goto(server.EMPTY_PAGE); + expect(imgFound).toBe(false); + await page.reload(); + expect(imgFound).toBe(false); + await page.goto(server.CROSS_PROCESS_PREFIX + '/grid.html'); + await waitForSelector; + expect(imgFound).toBe(true); + }); + + it('should wait for visible', async () => { + const { page } = getTestState(); + + let divFound = false; + const waitForSelector = page + .waitForSelector('aria/name', { visible: true }) + .then(() => (divFound = true)); + await page.setContent( + `<div aria-label='name' style='display: none; visibility: hidden;'>1</div>` + ); + expect(divFound).toBe(false); + await page.evaluate(() => + document.querySelector('div').style.removeProperty('display') + ); + expect(divFound).toBe(false); + await page.evaluate(() => + document.querySelector('div').style.removeProperty('visibility') + ); + expect(await waitForSelector).toBe(true); + expect(divFound).toBe(true); + }); + + it('should wait for visible recursively', async () => { + const { page } = getTestState(); + + let divVisible = false; + const waitForSelector = page + .waitForSelector('aria/inner', { visible: true }) + .then(() => (divVisible = true)); + await page.setContent( + `<div style='display: none; visibility: hidden;'><div aria-label="inner">hi</div></div>` + ); + expect(divVisible).toBe(false); + await page.evaluate(() => + document.querySelector('div').style.removeProperty('display') + ); + expect(divVisible).toBe(false); + await page.evaluate(() => + document.querySelector('div').style.removeProperty('visibility') + ); + expect(await waitForSelector).toBe(true); + expect(divVisible).toBe(true); + }); + + it('hidden should wait for visibility: hidden', async () => { + const { page } = getTestState(); + + let divHidden = false; + await page.setContent( + `<div role='button' style='display: block;'></div>` + ); + const waitForSelector = page + .waitForSelector('aria/[role="button"]', { hidden: true }) + .then(() => (divHidden = true)); + await page.waitForSelector('aria/[role="button"]'); // do a round trip + expect(divHidden).toBe(false); + await page.evaluate(() => + document.querySelector('div').style.setProperty('visibility', 'hidden') + ); + expect(await waitForSelector).toBe(true); + expect(divHidden).toBe(true); + }); + + it('hidden should wait for display: none', async () => { + const { page } = getTestState(); + + let divHidden = false; + await page.setContent(`<div role='main' style='display: block;'></div>`); + const waitForSelector = page + .waitForSelector('aria/[role="main"]', { hidden: true }) + .then(() => (divHidden = true)); + await page.waitForSelector('aria/[role="main"]'); // do a round trip + expect(divHidden).toBe(false); + await page.evaluate(() => + document.querySelector('div').style.setProperty('display', 'none') + ); + expect(await waitForSelector).toBe(true); + expect(divHidden).toBe(true); + }); + + it('hidden should wait for removal', async () => { + const { page } = getTestState(); + + await page.setContent(`<div role='main'></div>`); + let divRemoved = false; + const waitForSelector = page + .waitForSelector('aria/[role="main"]', { hidden: true }) + .then(() => (divRemoved = true)); + await page.waitForSelector('aria/[role="main"]'); // do a round trip + expect(divRemoved).toBe(false); + await page.evaluate(() => document.querySelector('div').remove()); + expect(await waitForSelector).toBe(true); + expect(divRemoved).toBe(true); + }); + + it('should return null if waiting to hide non-existing element', async () => { + const { page } = getTestState(); + + const handle = await page.waitForSelector('aria/non-existing', { + hidden: true, + }); + expect(handle).toBe(null); + }); + + it('should respect timeout', async () => { + const { page, puppeteer } = getTestState(); + + let error = null; + await page + .waitForSelector('aria/[role="button"]', { timeout: 10 }) + .catch((error_) => (error = error_)); + expect(error).toBeTruthy(); + expect(error.message).toContain( + 'waiting for selector `[role="button"]` failed: timeout' + ); + expect(error).toBeInstanceOf(puppeteer.errors.TimeoutError); + }); + + it('should have an error message specifically for awaiting an element to be hidden', async () => { + const { page } = getTestState(); + + await page.setContent(`<div role='main'></div>`); + let error = null; + await page + .waitForSelector('aria/[role="main"]', { hidden: true, timeout: 10 }) + .catch((error_) => (error = error_)); + expect(error).toBeTruthy(); + expect(error.message).toContain( + 'waiting for selector `[role="main"]` to be hidden failed: timeout' + ); + }); + + it('should respond to node attribute mutation', async () => { + const { page } = getTestState(); + + let divFound = false; + const waitForSelector = page + .waitForSelector('aria/zombo') + .then(() => (divFound = true)); + await page.setContent(`<div aria-label='notZombo'></div>`); + expect(divFound).toBe(false); + await page.evaluate(() => + document.querySelector('div').setAttribute('aria-label', 'zombo') + ); + expect(await waitForSelector).toBe(true); + }); + + it('should return the element handle', async () => { + const { page } = getTestState(); + + const waitForSelector = page.waitForSelector('aria/zombo'); + await page.setContent(`<div aria-label='zombo'>anything</div>`); + expect( + await page.evaluate( + (x: HTMLElement) => x.textContent, + await waitForSelector + ) + ).toBe('anything'); + }); + + it('should have correct stack trace for timeout', async () => { + const { page } = getTestState(); + + let error; + await page + .waitForSelector('aria/zombo', { timeout: 10 }) + .catch((error_) => (error = error_)); + expect(error.stack).toContain('waiting for selector `zombo` failed'); + }); + }); + + describe('queryOne (Chromium web test)', async () => { + beforeEach(async () => { + const { page } = getTestState(); + await page.setContent( + ` + <h2 id="shown">title</h2> + <h2 id="hidden" aria-hidden="true">title</h2> + <div id="node1" aria-labeledby="node2"></div> + <div id="node2" aria-label="bar"></div> + <div id="node3" aria-label="foo"></div> + <div id="node4" class="container"> + <div id="node5" role="button" aria-label="foo"></div> + <div id="node6" role="button" aria-label="foo"></div> + <!-- Accessible name not available when element is hidden --> + <div id="node7" hidden role="button" aria-label="foo"></div> + <div id="node8" role="button" aria-label="bar"></div> + </div> + <button id="node10">text content</button> + <h1 id="node11">text content</h1> + <!-- Accessible name not available when role is "presentation" --> + <h1 id="node12" role="presentation">text content</h1> + <!-- Elements inside shadow dom should be found --> + <script> + const div = document.createElement('div'); + const shadowRoot = div.attachShadow({mode: 'open'}); + const h1 = document.createElement('h1'); + h1.textContent = 'text content'; + h1.id = 'node13'; + shadowRoot.appendChild(h1); + document.documentElement.appendChild(div); + </script> + <img id="node20" src="" alt="Accessible Name"> + <input id="node21" type="submit" value="Accessible Name"> + <label id="node22" for="node23">Accessible Name</label> + <!-- Accessible name for the <input> is "Accessible Name" --> + <input id="node23"> + <div id="node24" title="Accessible Name"></div> + <div role="treeitem" id="node30"> + <div role="treeitem" id="node31"> + <div role="treeitem" id="node32">item1</div> + <div role="treeitem" id="node33">item2</div> + </div> + <div role="treeitem" id="node34">item3</div> + </div> + <!-- Accessible name for the <div> is "item1 item2 item3" --> + <div aria-describedby="node30"></div> + ` + ); + }); + const getIds = async (elements: ElementHandle[]) => + Promise.all( + elements.map((element) => + element.evaluate((element: Element) => element.id) + ) + ); + it('should find by name "foo"', async () => { + const { page } = getTestState(); + const found = await page.$$('aria/foo'); + const ids = await getIds(found); + expect(ids).toEqual(['node3', 'node5', 'node6']); + }); + it('should find by name "bar"', async () => { + const { page } = getTestState(); + const found = await page.$$('aria/bar'); + const ids = await getIds(found); + expect(ids).toEqual(['node1', 'node2', 'node8']); + }); + it('should find treeitem by name', async () => { + const { page } = getTestState(); + const found = await page.$$('aria/item1 item2 item3'); + const ids = await getIds(found); + expect(ids).toEqual(['node30']); + }); + it('should find by role "button"', async () => { + const { page } = getTestState(); + const found = await page.$$('aria/[role="button"]'); + const ids = await getIds(found); + expect(ids).toEqual(['node5', 'node6', 'node8', 'node10', 'node21']); + }); + it('should find by role "heading"', async () => { + const { page } = getTestState(); + const found = await page.$$('aria/[role="heading"]'); + const ids = await getIds(found); + expect(ids).toEqual(['shown', 'hidden', 'node11', 'node13']); + }); + it('should find both ignored and unignored', async () => { + const { page } = getTestState(); + const found = await page.$$('aria/title'); + const ids = await getIds(found); + expect(ids).toEqual(['shown', 'hidden']); + }); + }); +}); diff --git a/remote/test/puppeteer/test/assert-coverage-test.js b/remote/test/puppeteer/test/assert-coverage-test.js new file mode 100644 index 0000000000..6e26a1121a --- /dev/null +++ b/remote/test/puppeteer/test/assert-coverage-test.js @@ -0,0 +1,25 @@ +const { describe, it } = require('mocha'); +const { getCoverageResults } = require('./coverage-utils'); +const expect = require('expect'); + +describe('API coverage test', () => { + it('calls every method', () => { + if (!process.env.COVERAGE) return; + + const coverageMap = getCoverageResults(); + const missingMethods = []; + for (const method of coverageMap.keys()) { + if (!coverageMap.get(method)) missingMethods.push(method); + } + if (missingMethods.length) { + console.error( + '\nCoverage check failed: not all API methods called. See above output for list of missing methods.' + ); + console.error(missingMethods.join('\n')); + } + + // We know this will fail because we checked above + // but we need the actual test to fail. + expect(missingMethods.length).toEqual(0); + }); +}); diff --git a/remote/test/puppeteer/test/assets/beforeunload.html b/remote/test/puppeteer/test/assets/beforeunload.html new file mode 100644 index 0000000000..3cef6763f3 --- /dev/null +++ b/remote/test/puppeteer/test/assets/beforeunload.html @@ -0,0 +1,10 @@ +<div>beforeunload demo.</div> +<script> +window.addEventListener('beforeunload', event => { + // Chrome way. + event.returnValue = 'Leave?'; + // Firefox way. + event.preventDefault(); +}); +</script> + diff --git a/remote/test/puppeteer/test/assets/cached/one-style.css b/remote/test/puppeteer/test/assets/cached/one-style.css new file mode 100644 index 0000000000..04e7110b41 --- /dev/null +++ b/remote/test/puppeteer/test/assets/cached/one-style.css @@ -0,0 +1,3 @@ +body { + background-color: pink; +} diff --git a/remote/test/puppeteer/test/assets/cached/one-style.html b/remote/test/puppeteer/test/assets/cached/one-style.html new file mode 100644 index 0000000000..4760f2b9f7 --- /dev/null +++ b/remote/test/puppeteer/test/assets/cached/one-style.html @@ -0,0 +1,2 @@ +<link rel='stylesheet' href='./one-style.css'> +<div>hello, world!</div> diff --git a/remote/test/puppeteer/test/assets/chromium-linux.zip b/remote/test/puppeteer/test/assets/chromium-linux.zip Binary files differnew file mode 100644 index 0000000000..9c00ec080d --- /dev/null +++ b/remote/test/puppeteer/test/assets/chromium-linux.zip diff --git a/remote/test/puppeteer/test/assets/consolelog.html b/remote/test/puppeteer/test/assets/consolelog.html new file mode 100644 index 0000000000..4a27803aa9 --- /dev/null +++ b/remote/test/puppeteer/test/assets/consolelog.html @@ -0,0 +1,17 @@ +<!DOCTYPE html> +<html> + <head> + <title>console.log test</title> + </head> + <body> + <script> + function foo() { + console.log('yellow') + } + function bar() { + foo(); + } + bar(); + </script> + </body> +</html> diff --git a/remote/test/puppeteer/test/assets/csp.html b/remote/test/puppeteer/test/assets/csp.html new file mode 100644 index 0000000000..34fc1fc1a5 --- /dev/null +++ b/remote/test/puppeteer/test/assets/csp.html @@ -0,0 +1 @@ +<meta http-equiv="Content-Security-Policy" content="default-src 'self'"> diff --git a/remote/test/puppeteer/test/assets/csscoverage/Dosis-Regular.ttf b/remote/test/puppeteer/test/assets/csscoverage/Dosis-Regular.ttf Binary files differnew file mode 100644 index 0000000000..4b208624e8 --- /dev/null +++ b/remote/test/puppeteer/test/assets/csscoverage/Dosis-Regular.ttf diff --git a/remote/test/puppeteer/test/assets/csscoverage/OFL.txt b/remote/test/puppeteer/test/assets/csscoverage/OFL.txt new file mode 100644 index 0000000000..a9b3c8b34e --- /dev/null +++ b/remote/test/puppeteer/test/assets/csscoverage/OFL.txt @@ -0,0 +1,95 @@ +Copyright (c) 2011, Edgar Tolentino and Pablo Impallari (www.impallari.com|impallari@gmail.com), +Copyright (c) 2011, Igino Marini. (www.ikern.com|mail@iginomarini.com), +with Reserved Font Names "Dosis". + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/remote/test/puppeteer/test/assets/csscoverage/involved.html b/remote/test/puppeteer/test/assets/csscoverage/involved.html new file mode 100644 index 0000000000..bcd9845b93 --- /dev/null +++ b/remote/test/puppeteer/test/assets/csscoverage/involved.html @@ -0,0 +1,26 @@ +<style> +@charset "utf-8"; +@namespace svg url(http://www.w3.org/2000/svg); +@font-face { + font-family: "Example Font"; + src: url("./Dosis-Regular.ttf"); +} + +#fluffy { + border: 1px solid black; + z-index: 1; + /* -webkit-disabled-property: rgb(1, 2, 3) */ + -lol-cats: "dogs" /* non-existing property */ +} + +@media (min-width: 1px) { + span { + -webkit-border-radius: 10px; + font-family: "Example Font"; + animation: 1s identifier; + } +} +</style> +<div id="fluffy">woof!</div> +<span>fancy text</span> + diff --git a/remote/test/puppeteer/test/assets/csscoverage/media.html b/remote/test/puppeteer/test/assets/csscoverage/media.html new file mode 100644 index 0000000000..bfb89f8f75 --- /dev/null +++ b/remote/test/puppeteer/test/assets/csscoverage/media.html @@ -0,0 +1,4 @@ +<style> +@media screen { div { color: green; } } </style> +<div>hello, world</div> + diff --git a/remote/test/puppeteer/test/assets/csscoverage/multiple.html b/remote/test/puppeteer/test/assets/csscoverage/multiple.html new file mode 100644 index 0000000000..0fd97e962a --- /dev/null +++ b/remote/test/puppeteer/test/assets/csscoverage/multiple.html @@ -0,0 +1,8 @@ +<link rel="stylesheet" href="stylesheet1.css"> +<link rel="stylesheet" href="stylesheet2.css"> +<script> +window.addEventListener('DOMContentLoaded', () => { + // Force stylesheets to load. + console.log(window.getComputedStyle(document.body).color); +}, false); +</script> diff --git a/remote/test/puppeteer/test/assets/csscoverage/simple.html b/remote/test/puppeteer/test/assets/csscoverage/simple.html new file mode 100644 index 0000000000..3beae21829 --- /dev/null +++ b/remote/test/puppeteer/test/assets/csscoverage/simple.html @@ -0,0 +1,6 @@ +<style> +div { color: green; } +a { color: blue; } +</style> +<div>hello, world</div> + diff --git a/remote/test/puppeteer/test/assets/csscoverage/sourceurl.html b/remote/test/puppeteer/test/assets/csscoverage/sourceurl.html new file mode 100644 index 0000000000..df4e9c276c --- /dev/null +++ b/remote/test/puppeteer/test/assets/csscoverage/sourceurl.html @@ -0,0 +1,7 @@ +<style> +body { + padding: 10px; +} +/*# sourceURL=nicename.css */ +</style> + diff --git a/remote/test/puppeteer/test/assets/csscoverage/stylesheet1.css b/remote/test/puppeteer/test/assets/csscoverage/stylesheet1.css new file mode 100644 index 0000000000..60f1eab971 --- /dev/null +++ b/remote/test/puppeteer/test/assets/csscoverage/stylesheet1.css @@ -0,0 +1,3 @@ +body { + color: red; +} diff --git a/remote/test/puppeteer/test/assets/csscoverage/stylesheet2.css b/remote/test/puppeteer/test/assets/csscoverage/stylesheet2.css new file mode 100644 index 0000000000..a87defb098 --- /dev/null +++ b/remote/test/puppeteer/test/assets/csscoverage/stylesheet2.css @@ -0,0 +1,4 @@ +html { + margin: 0; + padding: 0; +} diff --git a/remote/test/puppeteer/test/assets/csscoverage/unused.html b/remote/test/puppeteer/test/assets/csscoverage/unused.html new file mode 100644 index 0000000000..5b8186a3bf --- /dev/null +++ b/remote/test/puppeteer/test/assets/csscoverage/unused.html @@ -0,0 +1,7 @@ +<style> +@media screen { + a { color: green; } +} +/*# sourceURL=unused.css */ +</style> + diff --git a/remote/test/puppeteer/test/assets/detect-touch.html b/remote/test/puppeteer/test/assets/detect-touch.html new file mode 100644 index 0000000000..80a4123fbd --- /dev/null +++ b/remote/test/puppeteer/test/assets/detect-touch.html @@ -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> diff --git a/remote/test/puppeteer/test/assets/digits/0.png b/remote/test/puppeteer/test/assets/digits/0.png Binary files differnew file mode 100644 index 0000000000..ac3c4768ed --- /dev/null +++ b/remote/test/puppeteer/test/assets/digits/0.png diff --git a/remote/test/puppeteer/test/assets/digits/1.png b/remote/test/puppeteer/test/assets/digits/1.png Binary files differnew file mode 100644 index 0000000000..6768222729 --- /dev/null +++ b/remote/test/puppeteer/test/assets/digits/1.png diff --git a/remote/test/puppeteer/test/assets/digits/2.png b/remote/test/puppeteer/test/assets/digits/2.png Binary files differnew file mode 100644 index 0000000000..b1daa4735d --- /dev/null +++ b/remote/test/puppeteer/test/assets/digits/2.png diff --git a/remote/test/puppeteer/test/assets/digits/3.png b/remote/test/puppeteer/test/assets/digits/3.png Binary files differnew file mode 100644 index 0000000000..6eca99b21b --- /dev/null +++ b/remote/test/puppeteer/test/assets/digits/3.png diff --git a/remote/test/puppeteer/test/assets/digits/4.png b/remote/test/puppeteer/test/assets/digits/4.png Binary files differnew file mode 100644 index 0000000000..a721071e2c --- /dev/null +++ b/remote/test/puppeteer/test/assets/digits/4.png diff --git a/remote/test/puppeteer/test/assets/digits/5.png b/remote/test/puppeteer/test/assets/digits/5.png Binary files differnew file mode 100644 index 0000000000..15cb19932a --- /dev/null +++ b/remote/test/puppeteer/test/assets/digits/5.png diff --git a/remote/test/puppeteer/test/assets/digits/6.png b/remote/test/puppeteer/test/assets/digits/6.png Binary files differnew file mode 100644 index 0000000000..639f38439d --- /dev/null +++ b/remote/test/puppeteer/test/assets/digits/6.png diff --git a/remote/test/puppeteer/test/assets/digits/7.png b/remote/test/puppeteer/test/assets/digits/7.png Binary files differnew file mode 100644 index 0000000000..5c1150b005 --- /dev/null +++ b/remote/test/puppeteer/test/assets/digits/7.png diff --git a/remote/test/puppeteer/test/assets/digits/8.png b/remote/test/puppeteer/test/assets/digits/8.png Binary files differnew file mode 100644 index 0000000000..abb8b48b0b --- /dev/null +++ b/remote/test/puppeteer/test/assets/digits/8.png diff --git a/remote/test/puppeteer/test/assets/digits/9.png b/remote/test/puppeteer/test/assets/digits/9.png Binary files differnew file mode 100644 index 0000000000..6a40a21c6f --- /dev/null +++ b/remote/test/puppeteer/test/assets/digits/9.png diff --git a/remote/test/puppeteer/test/assets/dynamic-oopif.html b/remote/test/puppeteer/test/assets/dynamic-oopif.html new file mode 100644 index 0000000000..f00c741dfb --- /dev/null +++ b/remote/test/puppeteer/test/assets/dynamic-oopif.html @@ -0,0 +1,10 @@ +<script> +window.addEventListener('DOMContentLoaded', () => { + const iframe = document.createElement('iframe'); + const url = new URL(location.href); + url.hostname = url.hostname === 'localhost' ? '127.0.0.1' : 'localhost'; + url.pathname = '/grid.html'; + iframe.src = url.toString(); + document.body.appendChild(iframe); +}, false); +</script> diff --git a/remote/test/puppeteer/test/assets/empty.html b/remote/test/puppeteer/test/assets/empty.html new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/remote/test/puppeteer/test/assets/empty.html diff --git a/remote/test/puppeteer/test/assets/error.html b/remote/test/puppeteer/test/assets/error.html new file mode 100644 index 0000000000..130400c006 --- /dev/null +++ b/remote/test/puppeteer/test/assets/error.html @@ -0,0 +1,15 @@ +<script> +a(); + +function a() { + b(); +} + +function b() { + c(); +} + +function c() { + throw new Error('Fancy error!'); +} +</script> diff --git a/remote/test/puppeteer/test/assets/es6/.eslintrc b/remote/test/puppeteer/test/assets/es6/.eslintrc new file mode 100644 index 0000000000..1903e176f5 --- /dev/null +++ b/remote/test/puppeteer/test/assets/es6/.eslintrc @@ -0,0 +1,5 @@ +{ + "parserOptions": { + "sourceType": "module" + } +}
\ No newline at end of file diff --git a/remote/test/puppeteer/test/assets/es6/es6import.js b/remote/test/puppeteer/test/assets/es6/es6import.js new file mode 100644 index 0000000000..9aac2d4d64 --- /dev/null +++ b/remote/test/puppeteer/test/assets/es6/es6import.js @@ -0,0 +1,2 @@ +import num from './es6module.js'; +window.__es6injected = num; diff --git a/remote/test/puppeteer/test/assets/es6/es6module.js b/remote/test/puppeteer/test/assets/es6/es6module.js new file mode 100644 index 0000000000..7a4e8a723a --- /dev/null +++ b/remote/test/puppeteer/test/assets/es6/es6module.js @@ -0,0 +1 @@ +export default 42; diff --git a/remote/test/puppeteer/test/assets/es6/es6pathimport.js b/remote/test/puppeteer/test/assets/es6/es6pathimport.js new file mode 100644 index 0000000000..eb17a9a3d1 --- /dev/null +++ b/remote/test/puppeteer/test/assets/es6/es6pathimport.js @@ -0,0 +1,2 @@ +import num from './es6/es6module.js'; +window.__es6injected = num; diff --git a/remote/test/puppeteer/test/assets/file-to-upload.txt b/remote/test/puppeteer/test/assets/file-to-upload.txt new file mode 100644 index 0000000000..b4ad118489 --- /dev/null +++ b/remote/test/puppeteer/test/assets/file-to-upload.txt @@ -0,0 +1 @@ +contents of the file
\ No newline at end of file diff --git a/remote/test/puppeteer/test/assets/firefox-75.0a1.en-US.linux-x86_64.tar.bz2 b/remote/test/puppeteer/test/assets/firefox-75.0a1.en-US.linux-x86_64.tar.bz2 Binary files differnew file mode 100644 index 0000000000..be6d188027 --- /dev/null +++ b/remote/test/puppeteer/test/assets/firefox-75.0a1.en-US.linux-x86_64.tar.bz2 diff --git a/remote/test/puppeteer/test/assets/frames/frame.html b/remote/test/puppeteer/test/assets/frames/frame.html new file mode 100644 index 0000000000..8f20d2da9f --- /dev/null +++ b/remote/test/puppeteer/test/assets/frames/frame.html @@ -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> diff --git a/remote/test/puppeteer/test/assets/frames/frameset.html b/remote/test/puppeteer/test/assets/frames/frameset.html new file mode 100644 index 0000000000..4d56f88839 --- /dev/null +++ b/remote/test/puppeteer/test/assets/frames/frameset.html @@ -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> diff --git a/remote/test/puppeteer/test/assets/frames/nested-frames.html b/remote/test/puppeteer/test/assets/frames/nested-frames.html new file mode 100644 index 0000000000..de1987586f --- /dev/null +++ b/remote/test/puppeteer/test/assets/frames/nested-frames.html @@ -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> diff --git a/remote/test/puppeteer/test/assets/frames/one-frame-url-fragment.html b/remote/test/puppeteer/test/assets/frames/one-frame-url-fragment.html new file mode 100644 index 0000000000..d1462641ff --- /dev/null +++ b/remote/test/puppeteer/test/assets/frames/one-frame-url-fragment.html @@ -0,0 +1 @@ +<iframe src='./frame.html?param=value#fragment'></iframe> diff --git a/remote/test/puppeteer/test/assets/frames/one-frame.html b/remote/test/puppeteer/test/assets/frames/one-frame.html new file mode 100644 index 0000000000..e941d795a2 --- /dev/null +++ b/remote/test/puppeteer/test/assets/frames/one-frame.html @@ -0,0 +1 @@ +<iframe src='./frame.html'></iframe> diff --git a/remote/test/puppeteer/test/assets/frames/script.js b/remote/test/puppeteer/test/assets/frames/script.js new file mode 100644 index 0000000000..be22256d16 --- /dev/null +++ b/remote/test/puppeteer/test/assets/frames/script.js @@ -0,0 +1 @@ +console.log('Cheers!'); diff --git a/remote/test/puppeteer/test/assets/frames/style.css b/remote/test/puppeteer/test/assets/frames/style.css new file mode 100644 index 0000000000..5b5436e874 --- /dev/null +++ b/remote/test/puppeteer/test/assets/frames/style.css @@ -0,0 +1,3 @@ +div { + color: blue; +} diff --git a/remote/test/puppeteer/test/assets/frames/two-frames.html b/remote/test/puppeteer/test/assets/frames/two-frames.html new file mode 100644 index 0000000000..b2ee853eda --- /dev/null +++ b/remote/test/puppeteer/test/assets/frames/two-frames.html @@ -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> diff --git a/remote/test/puppeteer/test/assets/global-var.html b/remote/test/puppeteer/test/assets/global-var.html new file mode 100644 index 0000000000..b6be975038 --- /dev/null +++ b/remote/test/puppeteer/test/assets/global-var.html @@ -0,0 +1,3 @@ +<script> +var globalVar = 123; +</script>
\ No newline at end of file diff --git a/remote/test/puppeteer/test/assets/grid.html b/remote/test/puppeteer/test/assets/grid.html new file mode 100644 index 0000000000..0bdbb1220e --- /dev/null +++ b/remote/test/puppeteer/test/assets/grid.html @@ -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> diff --git a/remote/test/puppeteer/test/assets/historyapi.html b/remote/test/puppeteer/test/assets/historyapi.html new file mode 100644 index 0000000000..bacaf9e9a0 --- /dev/null +++ b/remote/test/puppeteer/test/assets/historyapi.html @@ -0,0 +1,5 @@ +<script> +window.addEventListener('DOMContentLoaded', () => { + history.pushState({}, '', '#1'); +}); +</script> diff --git a/remote/test/puppeteer/test/assets/idle-detector.html b/remote/test/puppeteer/test/assets/idle-detector.html new file mode 100644 index 0000000000..83b496c03d --- /dev/null +++ b/remote/test/puppeteer/test/assets/idle-detector.html @@ -0,0 +1,23 @@ +<!DOCTYPE html> +<div id="state"></div> +<script> + const elState = document.querySelector('#state'); + function setState(msg) { + elState.textContent = msg; + } + async function main() { + const controller = new AbortController(); + const signal = controller.signal; + const idleDetector = new IdleDetector({ + threshold: 60000, + signal, + }); + idleDetector.addEventListener('change', () => { + const userState = idleDetector.userState; + const screenState = idleDetector.screenState; + setState(`Idle state: ${userState}, ${screenState}.`); + }); + idleDetector.start(); + } + main(); +</script> diff --git a/remote/test/puppeteer/test/assets/injectedfile.js b/remote/test/puppeteer/test/assets/injectedfile.js new file mode 100644 index 0000000000..c211b62c16 --- /dev/null +++ b/remote/test/puppeteer/test/assets/injectedfile.js @@ -0,0 +1,2 @@ +window.__injected = 42; +window.__injectedError = new Error('hi'); diff --git a/remote/test/puppeteer/test/assets/injectedstyle.css b/remote/test/puppeteer/test/assets/injectedstyle.css new file mode 100644 index 0000000000..aa1634c255 --- /dev/null +++ b/remote/test/puppeteer/test/assets/injectedstyle.css @@ -0,0 +1,3 @@ +body { + background-color: red; +} diff --git a/remote/test/puppeteer/test/assets/input/button.html b/remote/test/puppeteer/test/assets/input/button.html new file mode 100644 index 0000000000..d4c6e13fd2 --- /dev/null +++ b/remote/test/puppeteer/test/assets/input/button.html @@ -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>
\ No newline at end of file diff --git a/remote/test/puppeteer/test/assets/input/checkbox.html b/remote/test/puppeteer/test/assets/input/checkbox.html new file mode 100644 index 0000000000..ca56762e2b --- /dev/null +++ b/remote/test/puppeteer/test/assets/input/checkbox.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> diff --git a/remote/test/puppeteer/test/assets/input/fileupload.html b/remote/test/puppeteer/test/assets/input/fileupload.html new file mode 100644 index 0000000000..55fd7c5006 --- /dev/null +++ b/remote/test/puppeteer/test/assets/input/fileupload.html @@ -0,0 +1,9 @@ +<!DOCTYPE html> +<html> + <head> + <title>File upload test</title> + </head> + <body> + <input type="file"> + </body> +</html>
\ No newline at end of file diff --git a/remote/test/puppeteer/test/assets/input/keyboard.html b/remote/test/puppeteer/test/assets/input/keyboard.html new file mode 100644 index 0000000000..fd962c7518 --- /dev/null +++ b/remote/test/puppeteer/test/assets/input/keyboard.html @@ -0,0 +1,42 @@ +<!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.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>
\ No newline at end of file diff --git a/remote/test/puppeteer/test/assets/input/mouse-helper.js b/remote/test/puppeteer/test/assets/input/mouse-helper.js new file mode 100644 index 0000000000..4f2824dceb --- /dev/null +++ b/remote/test/puppeteer/test/assets/input/mouse-helper.js @@ -0,0 +1,74 @@ +// This injects a box into the page that moves with the mouse; +// Useful for debugging +(function () { + const box = document.createElement('div'); + box.classList.add('mouse-helper'); + const styleElement = document.createElement('style'); + styleElement.innerHTML = ` + .mouse-helper { + pointer-events: none; + position: absolute; + top: 0; + left: 0; + width: 20px; + height: 20px; + background: rgba(0,0,0,.4); + border: 1px solid white; + border-radius: 10px; + margin-left: -10px; + margin-top: -10px; + transition: background .2s, border-radius .2s, border-color .2s; + } + .mouse-helper.button-1 { + transition: none; + background: rgba(0,0,0,0.9); + } + .mouse-helper.button-2 { + transition: none; + border-color: rgba(0,0,255,0.9); + } + .mouse-helper.button-3 { + transition: none; + border-radius: 4px; + } + .mouse-helper.button-4 { + transition: none; + border-color: rgba(255,0,0,0.9); + } + .mouse-helper.button-5 { + transition: none; + border-color: rgba(0,255,0,0.9); + } + `; + document.head.appendChild(styleElement); + document.body.appendChild(box); + document.addEventListener( + 'mousemove', + (event) => { + box.style.left = event.pageX + 'px'; + box.style.top = event.pageY + 'px'; + updateButtons(event.buttons); + }, + true + ); + document.addEventListener( + 'mousedown', + (event) => { + updateButtons(event.buttons); + box.classList.add('button-' + event.which); + }, + true + ); + document.addEventListener( + 'mouseup', + (event) => { + updateButtons(event.buttons); + box.classList.remove('button-' + event.which); + }, + true + ); + function updateButtons(buttons) { + for (let i = 0; i < 5; i++) + box.classList.toggle('button-' + i, buttons & (1 << i)); + } +})(); diff --git a/remote/test/puppeteer/test/assets/input/rotatedButton.html b/remote/test/puppeteer/test/assets/input/rotatedButton.html new file mode 100644 index 0000000000..1bce66cf5e --- /dev/null +++ b/remote/test/puppeteer/test/assets/input/rotatedButton.html @@ -0,0 +1,21 @@ +<!DOCTYPE html> +<html> + <head> + <title>Rotated button test</title> + </head> + <body> + <script src="mouse-helper.js"></script> + <button onclick="clicked();">Click target</button> + <style> + button { + transform: rotateY(180deg); + } + </style> + <script> + window.result = 'Was not clicked'; + function clicked() { + result = 'Clicked'; + } + </script> + </body> +</html> diff --git a/remote/test/puppeteer/test/assets/input/scrollable.html b/remote/test/puppeteer/test/assets/input/scrollable.html new file mode 100644 index 0000000000..885d3739d5 --- /dev/null +++ b/remote/test/puppeteer/test/assets/input/scrollable.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>
\ No newline at end of file diff --git a/remote/test/puppeteer/test/assets/input/select.html b/remote/test/puppeteer/test/assets/input/select.html new file mode 100644 index 0000000000..879a537a76 --- /dev/null +++ b/remote/test/puppeteer/test/assets/input/select.html @@ -0,0 +1,69 @@ +<!DOCTYPE html> +<html> + <head> + <title>Selection Test</title> + </head> + <body> + <select> + <option value="black">Black</option> + <option value="blue">Blue</option> + <option value="brown">Brown</option> + <option value="cyan">Cyan</option> + <option value="gray">Gray</option> + <option value="green">Green</option> + <option value="indigo">Indigo</option> + <option value="magenta">Magenta</option> + <option value="orange">Orange</option> + <option value="pink">Pink</option> + <option value="purple">Purple</option> + <option value="red">Red</option> + <option value="violet">Violet</option> + <option value="white">White</option> + <option value="yellow">Yellow</option> + </select> + <script> + window.result = { + onInput: null, + onChange: null, + onBubblingChange: null, + onBubblingInput: null, + }; + + let select = document.querySelector('select'); + + function makeEmpty() { + for (let i = select.options.length - 1; i >= 0; --i) { + select.remove(i); + } + } + + function makeMultiple() { + select.setAttribute('multiple', true); + } + + select.addEventListener('input', () => { + result.onInput = Array.from(select.querySelectorAll('option:checked')).map((option) => { + return option.value; + }); + }, false); + + select.addEventListener('change', () => { + result.onChange = Array.from(select.querySelectorAll('option:checked')).map((option) => { + return option.value; + }); + }, false); + + document.body.addEventListener('input', () => { + result.onBubblingInput = Array.from(select.querySelectorAll('option:checked')).map((option) => { + return option.value; + }); + }, false); + + document.body.addEventListener('change', () => { + result.onBubblingChange = Array.from(select.querySelectorAll('option:checked')).map((option) => { + return option.value; + }); + }, false); + </script> + </body> +</html> diff --git a/remote/test/puppeteer/test/assets/input/textarea.html b/remote/test/puppeteer/test/assets/input/textarea.html new file mode 100644 index 0000000000..6d77f3106d --- /dev/null +++ b/remote/test/puppeteer/test/assets/input/textarea.html @@ -0,0 +1,15 @@ +<!DOCTYPE html> +<html> + <head> + <title>Textarea test</title> + </head> + <body> + <textarea></textarea> + <script src='mouse-helper.js'></script> + <script> + globalThis.result = ''; + globalThis.textarea = document.querySelector('textarea'); + textarea.addEventListener('input', () => result = textarea.value, false); + </script> + </body> +</html> diff --git a/remote/test/puppeteer/test/assets/input/touches.html b/remote/test/puppeteer/test/assets/input/touches.html new file mode 100644 index 0000000000..4392cfacbd --- /dev/null +++ b/remote/test/puppeteer/test/assets/input/touches.html @@ -0,0 +1,35 @@ +<!DOCTYPE html> +<html> + <head> + <title>Touch test</title> + </head> + <body> + <script src="mouse-helper.js"></script> + <button onclick="clicked();">Click target</button> + <script> + window.result = []; + const button = document.querySelector('button'); + button.style.height = '200px'; + button.style.width = '200px'; + button.focus(); + button.addEventListener('touchstart', event => { + log('Touchstart:', ...Array.from(event.changedTouches).map(touch => touch.identifier)); + }); + button.addEventListener('touchend', event => { + log('Touchend:', ...Array.from(event.changedTouches).map(touch => touch.identifier)); + }); + button.addEventListener('touchmove', event => { + log('Touchmove:', ...Array.from(event.changedTouches).map(touch => touch.identifier)); + }); + function log(...args) { + console.log.apply(console, args); + result.push(args.join(' ')); + } + function getResult() { + let temp = result; + result = []; + return temp; + } + </script> + </body> +</html>
\ No newline at end of file diff --git a/remote/test/puppeteer/test/assets/input/wheel.html b/remote/test/puppeteer/test/assets/input/wheel.html new file mode 100644 index 0000000000..3d093a993e --- /dev/null +++ b/remote/test/puppeteer/test/assets/input/wheel.html @@ -0,0 +1,43 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8"> + <style> + body { + min-height: 100vh; + margin: 0; + display: flex; + align-items: center; + justify-content: center; + } + + div { + width: 105px; + height: 105px; + background: #cdf; + padding: 5px; + } + </style> + <title>Element: wheel event - Scaling_an_element_via_the_wheel - code sample</title> + </head> + <body> + <div>Scale me with your mouse wheel.</div> + <script> + function zoom(event) { + event.preventDefault(); + + scale += event.deltaY * -0.01; + + // Restrict scale + scale = Math.min(Math.max(.125, scale), 4); + + // Apply scale transform + el.style.transform = `scale(${scale})`; + } + + let scale = 1; + const el = document.querySelector('div'); + el.onwheel = zoom; + </script> + </body> +</html> diff --git a/remote/test/puppeteer/test/assets/jscoverage/eval.html b/remote/test/puppeteer/test/assets/jscoverage/eval.html new file mode 100644 index 0000000000..838ae28763 --- /dev/null +++ b/remote/test/puppeteer/test/assets/jscoverage/eval.html @@ -0,0 +1 @@ +<script>eval('console.log("foo")')</script> diff --git a/remote/test/puppeteer/test/assets/jscoverage/involved.html b/remote/test/puppeteer/test/assets/jscoverage/involved.html new file mode 100644 index 0000000000..889c86bed5 --- /dev/null +++ b/remote/test/puppeteer/test/assets/jscoverage/involved.html @@ -0,0 +1,15 @@ +<script> +function foo() { + if (1 > 2) + console.log(1); + if (1 < 2) + console.log(2); + let x = 1 > 2 ? 'foo' : 'bar'; + let y = 1 < 2 ? 'foo' : 'bar'; + let z = () => {}; + let q = () => {}; + q(); +} + +foo(); +</script> diff --git a/remote/test/puppeteer/test/assets/jscoverage/multiple.html b/remote/test/puppeteer/test/assets/jscoverage/multiple.html new file mode 100644 index 0000000000..bdef59885b --- /dev/null +++ b/remote/test/puppeteer/test/assets/jscoverage/multiple.html @@ -0,0 +1,2 @@ +<script src='script1.js'></script> +<script src='script2.js'></script> diff --git a/remote/test/puppeteer/test/assets/jscoverage/ranges.html b/remote/test/puppeteer/test/assets/jscoverage/ranges.html new file mode 100644 index 0000000000..a537a7da6a --- /dev/null +++ b/remote/test/puppeteer/test/assets/jscoverage/ranges.html @@ -0,0 +1,2 @@ +<script> +function unused(){}console.log('used!');</script> diff --git a/remote/test/puppeteer/test/assets/jscoverage/script1.js b/remote/test/puppeteer/test/assets/jscoverage/script1.js new file mode 100644 index 0000000000..3bd241b50e --- /dev/null +++ b/remote/test/puppeteer/test/assets/jscoverage/script1.js @@ -0,0 +1 @@ +console.log(3); diff --git a/remote/test/puppeteer/test/assets/jscoverage/script2.js b/remote/test/puppeteer/test/assets/jscoverage/script2.js new file mode 100644 index 0000000000..3bd241b50e --- /dev/null +++ b/remote/test/puppeteer/test/assets/jscoverage/script2.js @@ -0,0 +1 @@ +console.log(3); diff --git a/remote/test/puppeteer/test/assets/jscoverage/simple.html b/remote/test/puppeteer/test/assets/jscoverage/simple.html new file mode 100644 index 0000000000..49eeeea6ae --- /dev/null +++ b/remote/test/puppeteer/test/assets/jscoverage/simple.html @@ -0,0 +1,2 @@ +<script> +function foo() {function bar() { } console.log(1); } foo(); </script> diff --git a/remote/test/puppeteer/test/assets/jscoverage/sourceurl.html b/remote/test/puppeteer/test/assets/jscoverage/sourceurl.html new file mode 100644 index 0000000000..e477750320 --- /dev/null +++ b/remote/test/puppeteer/test/assets/jscoverage/sourceurl.html @@ -0,0 +1,4 @@ +<script> +console.log(1); +//# sourceURL=nicename.js +</script> diff --git a/remote/test/puppeteer/test/assets/jscoverage/unused.html b/remote/test/puppeteer/test/assets/jscoverage/unused.html new file mode 100644 index 0000000000..59c4a5a70b --- /dev/null +++ b/remote/test/puppeteer/test/assets/jscoverage/unused.html @@ -0,0 +1 @@ +<script>function foo() { }</script> diff --git a/remote/test/puppeteer/test/assets/mobile.html b/remote/test/puppeteer/test/assets/mobile.html new file mode 100644 index 0000000000..8e94b2fe29 --- /dev/null +++ b/remote/test/puppeteer/test/assets/mobile.html @@ -0,0 +1 @@ +<meta name = "viewport" content = "initial-scale = 1, user-scalable = no"> diff --git a/remote/test/puppeteer/test/assets/modernizr.js b/remote/test/puppeteer/test/assets/modernizr.js new file mode 100644 index 0000000000..7991a4ec40 --- /dev/null +++ b/remote/test/puppeteer/test/assets/modernizr.js @@ -0,0 +1,3 @@ +/*! modernizr 3.5.0 (Custom Build) | MIT * +* https://modernizr.com/download/?-touchevents-setclasses !*/ +!function(e,n,t){function o(e,n){return typeof e===n}function s(){var e,n,t,s,a,i,r;for(var l in c)if(c.hasOwnProperty(l)){if(e=[],n=c[l],n.name&&(e.push(n.name.toLowerCase()),n.options&&n.options.aliases&&n.options.aliases.length))for(t=0;t<n.options.aliases.length;t++)e.push(n.options.aliases[t].toLowerCase());for(s=o(n.fn,"function")?n.fn():n.fn,a=0;a<e.length;a++)i=e[a],r=i.split("."),1===r.length?Modernizr[r[0]]=s:(!Modernizr[r[0]]||Modernizr[r[0]]instanceof Boolean||(Modernizr[r[0]]=new Boolean(Modernizr[r[0]])),Modernizr[r[0]][r[1]]=s),f.push((s?"":"no-")+r.join("-"))}}function a(e){var n=u.className,t=Modernizr._config.classPrefix||"";if(p&&(n=n.baseVal),Modernizr._config.enableJSClass){var o=new RegExp("(^|\\s)"+t+"no-js(\\s|$)");n=n.replace(o,"$1"+t+"js$2")}Modernizr._config.enableClasses&&(n+=" "+t+e.join(" "+t),p?u.className.baseVal=n:u.className=n)}function i(){return"function"!=typeof n.createElement?n.createElement(arguments[0]):p?n.createElementNS.call(n,"http://www.w3.org/2000/svg",arguments[0]):n.createElement.apply(n,arguments)}function r(){var e=n.body;return e||(e=i(p?"svg":"body"),e.fake=!0),e}function l(e,t,o,s){var a,l,f,c,d="modernizr",p=i("div"),h=r();if(parseInt(o,10))for(;o--;)f=i("div"),f.id=s?s[o]:d+(o+1),p.appendChild(f);return a=i("style"),a.type="text/css",a.id="s"+d,(h.fake?h:p).appendChild(a),h.appendChild(p),a.styleSheet?a.styleSheet.cssText=e:a.appendChild(n.createTextNode(e)),p.id=d,h.fake&&(h.style.background="",h.style.overflow="hidden",c=u.style.overflow,u.style.overflow="hidden",u.appendChild(h)),l=t(p,e),h.fake?(h.parentNode.removeChild(h),u.style.overflow=c,u.offsetHeight):p.parentNode.removeChild(p),!!l}var f=[],c=[],d={_version:"3.5.0",_config:{classPrefix:"",enableClasses:!0,enableJSClass:!0,usePrefixes:!0},_q:[],on:function(e,n){var t=this;setTimeout(function(){n(t[e])},0)},addTest:function(e,n,t){c.push({name:e,fn:n,options:t})},addAsyncTest:function(e){c.push({name:null,fn:e})}},Modernizr=function(){};Modernizr.prototype=d,Modernizr=new Modernizr;var u=n.documentElement,p="svg"===u.nodeName.toLowerCase(),h=d._config.usePrefixes?" -webkit- -moz- -o- -ms- ".split(" "):["",""];d._prefixes=h;var m=d.testStyles=l;Modernizr.addTest("touchevents",function(){var t;if("ontouchstart"in e||e.DocumentTouch&&n instanceof DocumentTouch)t=!0;else{var o=["@media (",h.join("touch-enabled),("),"heartz",")","{#modernizr{top:9px;position:absolute}}"].join("");m(o,function(e){t=9===e.offsetTop})}return t}),s(),a(f),delete d.addTest,delete d.addAsyncTest;for(var v=0;v<Modernizr._q.length;v++)Modernizr._q[v]();e.Modernizr=Modernizr}(window,document); diff --git a/remote/test/puppeteer/test/assets/networkidle.html b/remote/test/puppeteer/test/assets/networkidle.html new file mode 100644 index 0000000000..910ae1736d --- /dev/null +++ b/remote/test/puppeteer/test/assets/networkidle.html @@ -0,0 +1,19 @@ +<script> + async function sleep(delay) { + return new Promise(resolve => setTimeout(resolve, delay)); + } + + async function main() { + const roundOne = Promise.all([ + fetch('fetch-request-a.js'), + fetch('fetch-request-b.js'), + fetch('fetch-request-c.js'), + ]); + + await roundOne; + await sleep(50); + await fetch('fetch-request-d.js'); + } + + main(); +</script> diff --git a/remote/test/puppeteer/test/assets/offscreenbuttons.html b/remote/test/puppeteer/test/assets/offscreenbuttons.html new file mode 100644 index 0000000000..d45e2a4129 --- /dev/null +++ b/remote/test/puppeteer/test/assets/offscreenbuttons.html @@ -0,0 +1,36 @@ +<style> + button { + position: absolute; + width: 100px; + height: 20px; + } + + #btn0 { right: 0px; top: 0; } + #btn1 { right: -10px; top: 25px; } + #btn2 { right: -20px; top: 50px; } + #btn3 { right: -30px; top: 75px; } + #btn4 { right: -40px; top: 100px; } + #btn5 { right: -50px; top: 125px; } + #btn6 { right: -60px; top: 150px; } + #btn7 { right: -70px; top: 175px; } + #btn8 { right: -80px; top: 200px; } + #btn9 { right: -90px; top: 225px; } + #btn10 { right: -100px; top: 250px; } +</style> +<button id=btn0>0</button> +<button id=btn1>1</button> +<button id=btn2>2</button> +<button id=btn3>3</button> +<button id=btn4>4</button> +<button id=btn5>5</button> +<button id=btn6>6</button> +<button id=btn7>7</button> +<button id=btn8>8</button> +<button id=btn9>9</button> +<button id=btn10>10</button> +<script> +window.addEventListener('DOMContentLoaded', () => { + for (const button of Array.from(document.querySelectorAll('button'))) + button.addEventListener('click', () => console.log('button #' + button.textContent + ' clicked'), false); +}, false); +</script> diff --git a/remote/test/puppeteer/test/assets/one-style.css b/remote/test/puppeteer/test/assets/one-style.css new file mode 100644 index 0000000000..7b26410d8a --- /dev/null +++ b/remote/test/puppeteer/test/assets/one-style.css @@ -0,0 +1,3 @@ +body { + background-color: pink; +} diff --git a/remote/test/puppeteer/test/assets/one-style.html b/remote/test/puppeteer/test/assets/one-style.html new file mode 100644 index 0000000000..4760f2b9f7 --- /dev/null +++ b/remote/test/puppeteer/test/assets/one-style.html @@ -0,0 +1,2 @@ +<link rel='stylesheet' href='./one-style.css'> +<div>hello, world!</div> diff --git a/remote/test/puppeteer/test/assets/playground.html b/remote/test/puppeteer/test/assets/playground.html new file mode 100644 index 0000000000..828cfb1c70 --- /dev/null +++ b/remote/test/puppeteer/test/assets/playground.html @@ -0,0 +1,15 @@ +<!DOCTYPE html> +<html> + <head> + <title>Playground</title> + </head> + <body> + <button>A button</button> + <textarea>A text area</textarea> + <div id="first">First div</div> + <div id="second"> + Second div + <span class="inner">Inner span</span> + </div> + </body> +</html>
\ No newline at end of file diff --git a/remote/test/puppeteer/test/assets/popup/popup.html b/remote/test/puppeteer/test/assets/popup/popup.html new file mode 100644 index 0000000000..b855162c25 --- /dev/null +++ b/remote/test/puppeteer/test/assets/popup/popup.html @@ -0,0 +1,9 @@ +<!DOCTYPE html> +<html> + <head> + <title>Popup</title> + </head> + <body> + I am a popup + </body> +</html> diff --git a/remote/test/puppeteer/test/assets/popup/window-open.html b/remote/test/puppeteer/test/assets/popup/window-open.html new file mode 100644 index 0000000000..d138be1d22 --- /dev/null +++ b/remote/test/puppeteer/test/assets/popup/window-open.html @@ -0,0 +1,11 @@ +<!DOCTYPE html> +<html> + <head> + <title>Popup test</title> + </head> + <body> + <script> + window.open('./popup.html'); + </script> + </body> +</html> diff --git a/remote/test/puppeteer/test/assets/pptr.png b/remote/test/puppeteer/test/assets/pptr.png Binary files differnew file mode 100644 index 0000000000..65d87c68e6 --- /dev/null +++ b/remote/test/puppeteer/test/assets/pptr.png diff --git a/remote/test/puppeteer/test/assets/resetcss.html b/remote/test/puppeteer/test/assets/resetcss.html new file mode 100644 index 0000000000..e4e04b1f8a --- /dev/null +++ b/remote/test/puppeteer/test/assets/resetcss.html @@ -0,0 +1,50 @@ +<style> +/* http://meyerweb.com/eric/tools/css/reset/ + v2.0 | 20110126 + License: none (public domain) +*/ + +html, body, div, span, applet, object, iframe, +h1, h2, h3, h4, h5, h6, p, blockquote, pre, +a, abbr, acronym, address, big, cite, code, +del, dfn, em, img, ins, kbd, q, s, samp, +small, strike, strong, sub, sup, tt, var, +b, u, i, center, +dl, dt, dd, ol, ul, li, +fieldset, form, label, legend, +table, caption, tbody, tfoot, thead, tr, th, td, +article, aside, canvas, details, embed, +figure, figcaption, footer, header, hgroup, +menu, nav, output, ruby, section, summary, +time, mark, audio, video { + margin: 0; + padding: 0; + border: 0; + font-size: 100%; + font: inherit; + vertical-align: baseline; +} +/* HTML5 display-role reset for older browsers */ +article, aside, details, figcaption, figure, +footer, header, hgroup, menu, nav, section { + display: block; +} +body { + line-height: 1; +} +ol, ul { + list-style: none; +} +blockquote, q { + quotes: none; +} +blockquote:before, blockquote:after, +q:before, q:after { + content: ''; + content: none; +} +table { + border-collapse: collapse; + border-spacing: 0; +} +</style> diff --git a/remote/test/puppeteer/test/assets/self-request.html b/remote/test/puppeteer/test/assets/self-request.html new file mode 100644 index 0000000000..88aff620ff --- /dev/null +++ b/remote/test/puppeteer/test/assets/self-request.html @@ -0,0 +1,5 @@ +<script> +var req = new XMLHttpRequest(); +req.open('GET', '/self-request.html'); +req.send(null); +</script> diff --git a/remote/test/puppeteer/test/assets/serviceworkers/empty/sw.html b/remote/test/puppeteer/test/assets/serviceworkers/empty/sw.html new file mode 100644 index 0000000000..bef85d985b --- /dev/null +++ b/remote/test/puppeteer/test/assets/serviceworkers/empty/sw.html @@ -0,0 +1,3 @@ +<script> + window.registrationPromise = navigator.serviceWorker.register('sw.js'); +</script> diff --git a/remote/test/puppeteer/test/assets/serviceworkers/empty/sw.js b/remote/test/puppeteer/test/assets/serviceworkers/empty/sw.js new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/remote/test/puppeteer/test/assets/serviceworkers/empty/sw.js diff --git a/remote/test/puppeteer/test/assets/serviceworkers/fetch/style.css b/remote/test/puppeteer/test/assets/serviceworkers/fetch/style.css new file mode 100644 index 0000000000..7b26410d8a --- /dev/null +++ b/remote/test/puppeteer/test/assets/serviceworkers/fetch/style.css @@ -0,0 +1,3 @@ +body { + background-color: pink; +} diff --git a/remote/test/puppeteer/test/assets/serviceworkers/fetch/sw.html b/remote/test/puppeteer/test/assets/serviceworkers/fetch/sw.html new file mode 100644 index 0000000000..a9d28acb09 --- /dev/null +++ b/remote/test/puppeteer/test/assets/serviceworkers/fetch/sw.html @@ -0,0 +1,5 @@ +<link rel="stylesheet" href="./style.css"> +<script> + window.registrationPromise = navigator.serviceWorker.register('sw.js'); + window.activationPromise = new Promise(resolve => navigator.serviceWorker.oncontrollerchange = resolve); +</script> diff --git a/remote/test/puppeteer/test/assets/serviceworkers/fetch/sw.js b/remote/test/puppeteer/test/assets/serviceworkers/fetch/sw.js new file mode 100644 index 0000000000..21381484b6 --- /dev/null +++ b/remote/test/puppeteer/test/assets/serviceworkers/fetch/sw.js @@ -0,0 +1,7 @@ +self.addEventListener('fetch', (event) => { + event.respondWith(fetch(event.request)); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil(clients.claim()); +}); diff --git a/remote/test/puppeteer/test/assets/shadow.html b/remote/test/puppeteer/test/assets/shadow.html new file mode 100644 index 0000000000..3796ca768c --- /dev/null +++ b/remote/test/puppeteer/test/assets/shadow.html @@ -0,0 +1,17 @@ +<script> + +let h1 = null; +window.button = null; +window.clicked = false; + +window.addEventListener('DOMContentLoaded', () => { + const shadowRoot = document.body.attachShadow({mode: 'open'}); + h1 = document.createElement('h1'); + h1.textContent = 'Hellow Shadow DOM v1'; + button = document.createElement('button'); + button.textContent = 'Click'; + button.addEventListener('click', () => clicked = true); + shadowRoot.appendChild(h1); + shadowRoot.appendChild(button); +}); +</script> diff --git a/remote/test/puppeteer/test/assets/simple-extension/content-script.js b/remote/test/puppeteer/test/assets/simple-extension/content-script.js new file mode 100644 index 0000000000..0fd83b90f1 --- /dev/null +++ b/remote/test/puppeteer/test/assets/simple-extension/content-script.js @@ -0,0 +1,2 @@ +console.log('hey from the content-script'); +self.thisIsTheContentScript = true; diff --git a/remote/test/puppeteer/test/assets/simple-extension/index.js b/remote/test/puppeteer/test/assets/simple-extension/index.js new file mode 100644 index 0000000000..a0bb3f4eae --- /dev/null +++ b/remote/test/puppeteer/test/assets/simple-extension/index.js @@ -0,0 +1,2 @@ +// Mock script for background extension +window.MAGIC = 42; diff --git a/remote/test/puppeteer/test/assets/simple-extension/manifest.json b/remote/test/puppeteer/test/assets/simple-extension/manifest.json new file mode 100644 index 0000000000..da2cd082ed --- /dev/null +++ b/remote/test/puppeteer/test/assets/simple-extension/manifest.json @@ -0,0 +1,14 @@ +{ + "name": "Simple extension", + "version": "0.1", + "background": { + "scripts": ["index.js"] + }, + "content_scripts": [{ + "matches": ["<all_urls>"], + "css": [], + "js": ["content-script.js"] + }], + "permissions": ["background", "activeTab"], + "manifest_version": 2 +} diff --git a/remote/test/puppeteer/test/assets/simple.json b/remote/test/puppeteer/test/assets/simple.json new file mode 100644 index 0000000000..6d95903051 --- /dev/null +++ b/remote/test/puppeteer/test/assets/simple.json @@ -0,0 +1 @@ +{"foo": "bar"} diff --git a/remote/test/puppeteer/test/assets/tamperable.html b/remote/test/puppeteer/test/assets/tamperable.html new file mode 100644 index 0000000000..d027e97038 --- /dev/null +++ b/remote/test/puppeteer/test/assets/tamperable.html @@ -0,0 +1,3 @@ +<script> + window.result = window.injected; +</script>
\ No newline at end of file diff --git a/remote/test/puppeteer/test/assets/title.html b/remote/test/puppeteer/test/assets/title.html new file mode 100644 index 0000000000..88a86ce412 --- /dev/null +++ b/remote/test/puppeteer/test/assets/title.html @@ -0,0 +1 @@ +<title>Woof-Woof</title> diff --git a/remote/test/puppeteer/test/assets/worker/worker.html b/remote/test/puppeteer/test/assets/worker/worker.html new file mode 100644 index 0000000000..7de2d9fd9e --- /dev/null +++ b/remote/test/puppeteer/test/assets/worker/worker.html @@ -0,0 +1,14 @@ +<!DOCTYPE html> +<html> + <head> + <title>Worker test</title> + </head> + <body> + <script> + var worker = new Worker('worker.js'); + worker.onmessage = function(message) { + console.log(message.data); + }; + </script> + </body> +</html>
\ No newline at end of file diff --git a/remote/test/puppeteer/test/assets/worker/worker.js b/remote/test/puppeteer/test/assets/worker/worker.js new file mode 100644 index 0000000000..0626f13e58 --- /dev/null +++ b/remote/test/puppeteer/test/assets/worker/worker.js @@ -0,0 +1,16 @@ +console.log('hello from the worker'); + +function workerFunction() { + return 'worker function result'; +} + +self.addEventListener('message', (event) => { + console.log('got this data: ' + event.data); +}); + +(async function () { + while (true) { + self.postMessage(workerFunction.toString()); + await new Promise((x) => setTimeout(x, 100)); + } +})(); diff --git a/remote/test/puppeteer/test/assets/wrappedlink.html b/remote/test/puppeteer/test/assets/wrappedlink.html new file mode 100644 index 0000000000..429b6e9156 --- /dev/null +++ b/remote/test/puppeteer/test/assets/wrappedlink.html @@ -0,0 +1,32 @@ +<style> +:root { + font-family: monospace; +} + +body { + display: flex; + align-items: center; + justify-content: center; +} + +div { + width: 10ch; + word-wrap: break-word; + border: 1px solid blue; + transform: rotate(33deg); + line-height: 8ch; + padding: 2ch; +} + +a { + margin-left: 7ch; +} +</style> +<div> + <a href='#clicked'>123321</a> +</div> +<script> + document.querySelector('a').addEventListener('click', () => { + window.__clicked = true; + }); +</script> diff --git a/remote/test/puppeteer/test/browser.spec.ts b/remote/test/puppeteer/test/browser.spec.ts new file mode 100644 index 0000000000..0d06e7f60e --- /dev/null +++ b/remote/test/puppeteer/test/browser.spec.ts @@ -0,0 +1,81 @@ +/** + * 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. + */ + +import expect from 'expect'; +import { getTestState, setupTestBrowserHooks } from './mocha-utils'; // eslint-disable-line import/extensions + +describe('Browser specs', function () { + setupTestBrowserHooks(); + + describe('Browser.version', function () { + it('should return whether we are in headless', async () => { + const { browser, isHeadless } = getTestState(); + + const version = await browser.version(); + expect(version.length).toBeGreaterThan(0); + expect(version.startsWith('Headless')).toBe(isHeadless); + }); + }); + + describe('Browser.userAgent', function () { + it('should include WebKit', async () => { + const { browser, isChrome } = getTestState(); + + const userAgent = await browser.userAgent(); + expect(userAgent.length).toBeGreaterThan(0); + if (isChrome) expect(userAgent).toContain('WebKit'); + else expect(userAgent).toContain('Gecko'); + }); + }); + + describe('Browser.target', function () { + it('should return browser target', async () => { + const { browser } = getTestState(); + + const target = browser.target(); + expect(target.type()).toBe('browser'); + }); + }); + + describe('Browser.process', function () { + it('should return child_process instance', async () => { + const { browser } = getTestState(); + + const process = await browser.process(); + expect(process.pid).toBeGreaterThan(0); + }); + it('should not return child_process for remote browser', async () => { + const { browser, puppeteer } = getTestState(); + + const browserWSEndpoint = browser.wsEndpoint(); + const remoteBrowser = await puppeteer.connect({ browserWSEndpoint }); + expect(remoteBrowser.process()).toBe(null); + remoteBrowser.disconnect(); + }); + }); + + describe('Browser.isConnected', () => { + it('should set the browser connected state', async () => { + const { browser, puppeteer } = getTestState(); + + const browserWSEndpoint = browser.wsEndpoint(); + const newBrowser = await puppeteer.connect({ browserWSEndpoint }); + expect(newBrowser.isConnected()).toBe(true); + newBrowser.disconnect(); + expect(newBrowser.isConnected()).toBe(false); + }); + }); +}); diff --git a/remote/test/puppeteer/test/browsercontext.spec.ts b/remote/test/puppeteer/test/browsercontext.spec.ts new file mode 100644 index 0000000000..dd2be4b673 --- /dev/null +++ b/remote/test/puppeteer/test/browsercontext.spec.ts @@ -0,0 +1,206 @@ +/** + * 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. + */ + +import expect from 'expect'; +import { + getTestState, + setupTestBrowserHooks, +} from './mocha-utils'; // eslint-disable-line import/extensions +import utils from './utils.js'; + +describe('BrowserContext', function () { + setupTestBrowserHooks(); + it('should have default context', async () => { + const { browser } = getTestState(); + expect(browser.browserContexts().length).toEqual(1); + const defaultContext = browser.browserContexts()[0]; + expect(defaultContext.isIncognito()).toBe(false); + let error = null; + await defaultContext.close().catch((error_) => (error = error_)); + expect(browser.defaultBrowserContext()).toBe(defaultContext); + expect(error.message).toContain('cannot be closed'); + }); + it('should create new incognito context', async () => { + const { browser } = getTestState(); + + expect(browser.browserContexts().length).toBe(1); + const context = await browser.createIncognitoBrowserContext(); + expect(context.isIncognito()).toBe(true); + expect(browser.browserContexts().length).toBe(2); + expect(browser.browserContexts().indexOf(context) !== -1).toBe(true); + await context.close(); + expect(browser.browserContexts().length).toBe(1); + }); + it('should close all belonging targets once closing context', async () => { + const { browser } = getTestState(); + + expect((await browser.pages()).length).toBe(1); + + const context = await browser.createIncognitoBrowserContext(); + await context.newPage(); + expect((await browser.pages()).length).toBe(2); + expect((await context.pages()).length).toBe(1); + + await context.close(); + expect((await browser.pages()).length).toBe(1); + }); + it('window.open should use parent tab context', async () => { + const { browser, server } = getTestState(); + + const context = await browser.createIncognitoBrowserContext(); + const page = await context.newPage(); + await page.goto(server.EMPTY_PAGE); + const [popupTarget] = await Promise.all([ + utils.waitEvent(browser, 'targetcreated'), + page.evaluate<(url: string) => void>( + (url) => window.open(url), + server.EMPTY_PAGE + ), + ]); + expect(popupTarget.browserContext()).toBe(context); + await context.close(); + }); + it('should fire target events', async () => { + const { browser, server } = getTestState(); + + const context = await browser.createIncognitoBrowserContext(); + const events = []; + context.on('targetcreated', (target) => + events.push('CREATED: ' + target.url()) + ); + context.on('targetchanged', (target) => + events.push('CHANGED: ' + target.url()) + ); + context.on('targetdestroyed', (target) => + events.push('DESTROYED: ' + target.url()) + ); + const page = await context.newPage(); + await page.goto(server.EMPTY_PAGE); + await page.close(); + expect(events).toEqual([ + 'CREATED: about:blank', + `CHANGED: ${server.EMPTY_PAGE}`, + `DESTROYED: ${server.EMPTY_PAGE}`, + ]); + await context.close(); + }); + it('should wait for a target', async () => { + const { browser, puppeteer, server } = getTestState(); + + const context = await browser.createIncognitoBrowserContext(); + let resolved = false; + + const targetPromise = context.waitForTarget( + (target) => target.url() === server.EMPTY_PAGE + ); + targetPromise + .then(() => (resolved = true)) + .catch((error) => { + resolved = true; + if (error instanceof puppeteer.errors.TimeoutError) { + console.error(error); + } else throw error; + }); + const page = await context.newPage(); + expect(resolved).toBe(false); + await page.goto(server.EMPTY_PAGE); + try { + const target = await targetPromise; + expect(await target.page()).toBe(page); + } catch (error) { + if (error instanceof puppeteer.errors.TimeoutError) { + console.error(error); + } else throw error; + } + await context.close(); + }); + + it('should timeout waiting for a non-existent target', async () => { + const { browser, puppeteer, server } = getTestState(); + + const context = await browser.createIncognitoBrowserContext(); + const error = await context + .waitForTarget((target) => target.url() === server.EMPTY_PAGE, { + timeout: 1, + }) + .catch((error_) => error_); + expect(error).toBeInstanceOf(puppeteer.errors.TimeoutError); + await context.close(); + }); + + it('should isolate localStorage and cookies', async () => { + const { browser, server } = getTestState(); + + // Create two incognito contexts. + const context1 = await browser.createIncognitoBrowserContext(); + const context2 = await browser.createIncognitoBrowserContext(); + expect(context1.targets().length).toBe(0); + expect(context2.targets().length).toBe(0); + + // Create a page in first incognito context. + const page1 = await context1.newPage(); + await page1.goto(server.EMPTY_PAGE); + await page1.evaluate(() => { + localStorage.setItem('name', 'page1'); + document.cookie = 'name=page1'; + }); + + expect(context1.targets().length).toBe(1); + expect(context2.targets().length).toBe(0); + + // Create a page in second incognito context. + const page2 = await context2.newPage(); + await page2.goto(server.EMPTY_PAGE); + await page2.evaluate(() => { + localStorage.setItem('name', 'page2'); + document.cookie = 'name=page2'; + }); + + expect(context1.targets().length).toBe(1); + expect(context1.targets()[0]).toBe(page1.target()); + expect(context2.targets().length).toBe(1); + expect(context2.targets()[0]).toBe(page2.target()); + + // Make sure pages don't share localstorage or cookies. + expect(await page1.evaluate(() => localStorage.getItem('name'))).toBe( + 'page1' + ); + expect(await page1.evaluate(() => document.cookie)).toBe('name=page1'); + expect(await page2.evaluate(() => localStorage.getItem('name'))).toBe( + 'page2' + ); + expect(await page2.evaluate(() => document.cookie)).toBe('name=page2'); + + // Cleanup contexts. + await Promise.all([context1.close(), context2.close()]); + expect(browser.browserContexts().length).toBe(1); + }); + + it('should work across sessions', async () => { + const { browser, puppeteer } = getTestState(); + + expect(browser.browserContexts().length).toBe(1); + const context = await browser.createIncognitoBrowserContext(); + expect(browser.browserContexts().length).toBe(2); + const remoteBrowser = await puppeteer.connect({ + browserWSEndpoint: browser.wsEndpoint(), + }); + const contexts = remoteBrowser.browserContexts(); + expect(contexts.length).toBe(2); + remoteBrowser.disconnect(); + await context.close(); + }); +}); diff --git a/remote/test/puppeteer/test/chromiumonly.spec.ts b/remote/test/puppeteer/test/chromiumonly.spec.ts new file mode 100644 index 0000000000..7b64a70fd3 --- /dev/null +++ b/remote/test/puppeteer/test/chromiumonly.spec.ts @@ -0,0 +1,159 @@ +/** + * Copyright 2019 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. + */ +import expect from 'expect'; +import { + getTestState, + setupTestBrowserHooks, + setupTestPageAndContextHooks, + describeChromeOnly, +} from './mocha-utils'; // eslint-disable-line import/extensions + +describeChromeOnly('Chromium-Specific Launcher tests', function () { + describe('Puppeteer.launch |browserURL| option', function () { + it('should be able to connect using browserUrl, with and without trailing slash', async () => { + const { defaultBrowserOptions, puppeteer } = getTestState(); + + const originalBrowser = await puppeteer.launch( + Object.assign({}, defaultBrowserOptions, { + args: ['--remote-debugging-port=21222'], + }) + ); + const browserURL = 'http://127.0.0.1:21222'; + + const browser1 = await puppeteer.connect({ browserURL }); + const page1 = await browser1.newPage(); + expect(await page1.evaluate(() => 7 * 8)).toBe(56); + browser1.disconnect(); + + const browser2 = await puppeteer.connect({ + browserURL: browserURL + '/', + }); + const page2 = await browser2.newPage(); + expect(await page2.evaluate(() => 8 * 7)).toBe(56); + browser2.disconnect(); + originalBrowser.close(); + }); + it('should throw when using both browserWSEndpoint and browserURL', async () => { + const { defaultBrowserOptions, puppeteer } = getTestState(); + + const originalBrowser = await puppeteer.launch( + Object.assign({}, defaultBrowserOptions, { + args: ['--remote-debugging-port=21222'], + }) + ); + const browserURL = 'http://127.0.0.1:21222'; + + let error = null; + await puppeteer + .connect({ + browserURL, + browserWSEndpoint: originalBrowser.wsEndpoint(), + }) + .catch((error_) => (error = error_)); + expect(error.message).toContain( + 'Exactly one of browserWSEndpoint, browserURL or transport' + ); + + originalBrowser.close(); + }); + it('should throw when trying to connect to non-existing browser', async () => { + const { defaultBrowserOptions, puppeteer } = getTestState(); + + const originalBrowser = await puppeteer.launch( + Object.assign({}, defaultBrowserOptions, { + args: ['--remote-debugging-port=21222'], + }) + ); + const browserURL = 'http://127.0.0.1:32333'; + + let error = null; + await puppeteer + .connect({ browserURL }) + .catch((error_) => (error = error_)); + expect(error.message).toContain( + 'Failed to fetch browser webSocket URL from' + ); + originalBrowser.close(); + }); + }); + + describe('Puppeteer.launch |pipe| option', function () { + it('should support the pipe option', async () => { + const { defaultBrowserOptions, puppeteer } = getTestState(); + const options = Object.assign({ pipe: true }, defaultBrowserOptions); + const browser = await puppeteer.launch(options); + expect((await browser.pages()).length).toBe(1); + expect(browser.wsEndpoint()).toBe(''); + const page = await browser.newPage(); + expect(await page.evaluate('11 * 11')).toBe(121); + await page.close(); + await browser.close(); + }); + it('should support the pipe argument', async () => { + const { defaultBrowserOptions, puppeteer } = getTestState(); + const options = Object.assign({}, defaultBrowserOptions); + options.args = ['--remote-debugging-pipe'].concat(options.args || []); + const browser = await puppeteer.launch(options); + expect(browser.wsEndpoint()).toBe(''); + const page = await browser.newPage(); + expect(await page.evaluate('11 * 11')).toBe(121); + await page.close(); + await browser.close(); + }); + it('should fire "disconnected" when closing with pipe', async () => { + const { defaultBrowserOptions, puppeteer } = getTestState(); + const options = Object.assign({ pipe: true }, defaultBrowserOptions); + const browser = await puppeteer.launch(options); + const disconnectedEventPromise = new Promise((resolve) => + browser.once('disconnected', resolve) + ); + // Emulate user exiting browser. + browser.process().kill(); + await disconnectedEventPromise; + }); + }); +}); + +describeChromeOnly('Chromium-Specific Page Tests', function () { + setupTestBrowserHooks(); + setupTestPageAndContextHooks(); + it('Page.setRequestInterception should work with intervention headers', async () => { + const { server, page } = getTestState(); + + server.setRoute('/intervention', (req, res) => + res.end(` + <script> + document.write('<script src="${server.CROSS_PROCESS_PREFIX}/intervention.js">' + '</scr' + 'ipt>'); + </script> + `) + ); + server.setRedirect('/intervention.js', '/redirect.js'); + let serverRequest = null; + server.setRoute('/redirect.js', (req, res) => { + serverRequest = req; + res.end('console.log(1);'); + }); + + await page.setRequestInterception(true); + page.on('request', (request) => request.continue()); + await page.goto(server.PREFIX + '/intervention'); + // Check for feature URL substring rather than https://www.chromestatus.com to + // make it work with Edgium. + expect(serverRequest.headers.intervention).toContain( + 'feature/5718547946799104' + ); + }); +}); diff --git a/remote/test/puppeteer/test/click.spec.ts b/remote/test/puppeteer/test/click.spec.ts new file mode 100644 index 0000000000..48d4b64409 --- /dev/null +++ b/remote/test/puppeteer/test/click.spec.ts @@ -0,0 +1,352 @@ +/** + * 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. + */ + +import expect from 'expect'; +import { + getTestState, + setupTestPageAndContextHooks, + setupTestBrowserHooks, + itFailsFirefox, +} from './mocha-utils'; // eslint-disable-line import/extensions +import utils from './utils.js'; + +describe('Page.click', function () { + setupTestBrowserHooks(); + setupTestPageAndContextHooks(); + it('should click the button', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/input/button.html'); + await page.click('button'); + expect(await page.evaluate(() => globalThis.result)).toBe('Clicked'); + }); + it('should click svg', async () => { + const { page } = getTestState(); + + await page.setContent(` + <svg height="100" width="100"> + <circle onclick="javascript:window.__CLICKED=42" cx="50" cy="50" r="40" stroke="black" stroke-width="3" fill="red" /> + </svg> + `); + await page.click('circle'); + expect(await page.evaluate(() => globalThis.__CLICKED)).toBe(42); + }); + it( + 'should click the button if window.Node is removed', + async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/input/button.html'); + await page.evaluate(() => delete window.Node); + await page.click('button'); + expect(await page.evaluate(() => globalThis.result)).toBe('Clicked'); + } + ); + // @see https://github.com/puppeteer/puppeteer/issues/4281 + it('should click on a span with an inline element inside', async () => { + const { page } = getTestState(); + + await page.setContent(` + <style> + span::before { + content: 'q'; + } + </style> + <span onclick='javascript:window.CLICKED=42'></span> + `); + await page.click('span'); + expect(await page.evaluate(() => globalThis.CLICKED)).toBe(42); + }); + it('should not throw UnhandledPromiseRejection when page closes', async () => { + const { page } = getTestState(); + + const newPage = await page.browser().newPage(); + await Promise.all([ + newPage.close(), + newPage.mouse.click(1, 2), + ]).catch(() => {}); + }); + it('should click the button after navigation ', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/input/button.html'); + await page.click('button'); + await page.goto(server.PREFIX + '/input/button.html'); + await page.click('button'); + expect(await page.evaluate(() => globalThis.result)).toBe('Clicked'); + }); + it('should click with disabled javascript', async () => { + const { page, server } = getTestState(); + + await page.setJavaScriptEnabled(false); + await page.goto(server.PREFIX + '/wrappedlink.html'); + await Promise.all([page.click('a'), page.waitForNavigation()]); + expect(page.url()).toBe(server.PREFIX + '/wrappedlink.html#clicked'); + }); + it('should click when one of inline box children is outside of viewport', async () => { + const { page } = getTestState(); + + await page.setContent(` + <style> + i { + position: absolute; + top: -1000px; + } + </style> + <span onclick='javascript:window.CLICKED = 42;'><i>woof</i><b>doggo</b></span> + `); + await page.click('span'); + expect(await page.evaluate(() => globalThis.CLICKED)).toBe(42); + }); + it('should select the text by triple clicking', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/input/textarea.html'); + await page.focus('textarea'); + const text = + "This is the text that we are going to try to select. Let's see how it goes."; + await page.keyboard.type(text); + await page.click('textarea'); + await page.click('textarea', { clickCount: 2 }); + await page.click('textarea', { clickCount: 3 }); + expect( + await page.evaluate(() => { + const textarea = document.querySelector('textarea'); + return textarea.value.substring( + textarea.selectionStart, + textarea.selectionEnd + ); + }) + ).toBe(text); + }); + it('should click offscreen buttons', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/offscreenbuttons.html'); + const messages = []; + page.on('console', (msg) => messages.push(msg.text())); + for (let i = 0; i < 11; ++i) { + // We might've scrolled to click a button - reset to (0, 0). + await page.evaluate(() => window.scrollTo(0, 0)); + await page.click(`#btn${i}`); + } + expect(messages).toEqual([ + 'button #0 clicked', + 'button #1 clicked', + 'button #2 clicked', + 'button #3 clicked', + 'button #4 clicked', + 'button #5 clicked', + 'button #6 clicked', + 'button #7 clicked', + 'button #8 clicked', + 'button #9 clicked', + 'button #10 clicked', + ]); + }); + + it('should click wrapped links', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/wrappedlink.html'); + await page.click('a'); + expect(await page.evaluate(() => globalThis.__clicked)).toBe(true); + }); + + it('should click on checkbox input and toggle', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/input/checkbox.html'); + expect(await page.evaluate(() => globalThis.result.check)).toBe(null); + await page.click('input#agree'); + expect(await page.evaluate(() => globalThis.result.check)).toBe(true); + expect(await page.evaluate(() => globalThis.result.events)).toEqual([ + 'mouseover', + 'mouseenter', + 'mousemove', + 'mousedown', + 'mouseup', + 'click', + 'input', + 'change', + ]); + await page.click('input#agree'); + expect(await page.evaluate(() => globalThis.result.check)).toBe(false); + }); + + it('should click on checkbox label and toggle', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/input/checkbox.html'); + expect(await page.evaluate(() => globalThis.result.check)).toBe(null); + await page.click('label[for="agree"]'); + expect(await page.evaluate(() => globalThis.result.check)).toBe(true); + expect(await page.evaluate(() => globalThis.result.events)).toEqual([ + 'click', + 'input', + 'change', + ]); + await page.click('label[for="agree"]'); + expect(await page.evaluate(() => globalThis.result.check)).toBe(false); + }); + + it('should fail to click a missing button', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/input/button.html'); + let error = null; + await page + .click('button.does-not-exist') + .catch((error_) => (error = error_)); + expect(error.message).toBe( + 'No node found for selector: button.does-not-exist' + ); + }); + // @see https://github.com/puppeteer/puppeteer/issues/161 + it('should not hang with touch-enabled viewports', async () => { + const { page, puppeteer } = getTestState(); + + await page.setViewport(puppeteer.devices['iPhone 6'].viewport); + await page.mouse.down(); + await page.mouse.move(100, 10); + await page.mouse.up(); + }); + it('should scroll and click the button', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/input/scrollable.html'); + await page.click('#button-5'); + expect( + await page.evaluate(() => document.querySelector('#button-5').textContent) + ).toBe('clicked'); + await page.click('#button-80'); + expect( + await page.evaluate( + () => document.querySelector('#button-80').textContent + ) + ).toBe('clicked'); + }); + it('should double click the button', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/input/button.html'); + await page.evaluate(() => { + globalThis.double = false; + const button = document.querySelector('button'); + button.addEventListener('dblclick', () => { + globalThis.double = true; + }); + }); + const button = await page.$('button'); + await button.click({ clickCount: 2 }); + expect(await page.evaluate('double')).toBe(true); + expect(await page.evaluate('result')).toBe('Clicked'); + }); + it('should click a partially obscured button', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/input/button.html'); + await page.evaluate(() => { + const button = document.querySelector('button'); + button.textContent = 'Some really long text that will go offscreen'; + button.style.position = 'absolute'; + button.style.left = '368px'; + }); + await page.click('button'); + expect(await page.evaluate(() => globalThis.result)).toBe('Clicked'); + }); + it('should click a rotated button', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/input/rotatedButton.html'); + await page.click('button'); + expect(await page.evaluate(() => globalThis.result)).toBe('Clicked'); + }); + it('should fire contextmenu event on right click', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/input/scrollable.html'); + await page.click('#button-8', { button: 'right' }); + expect( + await page.evaluate(() => document.querySelector('#button-8').textContent) + ).toBe('context menu'); + }); + // @see https://github.com/puppeteer/puppeteer/issues/206 + it('should click links which cause navigation', async () => { + const { page, server } = getTestState(); + + await page.setContent(`<a href="${server.EMPTY_PAGE}">empty.html</a>`); + // This await should not hang. + await page.click('a'); + }); + it('should click the button inside an iframe', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.setContent('<div style="width:100px;height:100px">spacer</div>'); + await utils.attachFrame( + page, + 'button-test', + server.PREFIX + '/input/button.html' + ); + const frame = page.frames()[1]; + const button = await frame.$('button'); + await button.click(); + expect(await frame.evaluate(() => globalThis.result)).toBe('Clicked'); + }); + // @see https://github.com/puppeteer/puppeteer/issues/4110 + xit('should click the button with fixed position inside an iframe', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.setViewport({ width: 500, height: 500 }); + await page.setContent( + '<div style="width:100px;height:2000px">spacer</div>' + ); + await utils.attachFrame( + page, + 'button-test', + server.CROSS_PROCESS_PREFIX + '/input/button.html' + ); + const frame = page.frames()[1]; + await frame.$eval('button', (button: HTMLElement) => + button.style.setProperty('position', 'fixed') + ); + await frame.click('button'); + expect(await frame.evaluate(() => globalThis.result)).toBe('Clicked'); + }); + it( + 'should click the button with deviceScaleFactor set', + async () => { + const { page, server } = getTestState(); + + await page.setViewport({ width: 400, height: 400, deviceScaleFactor: 5 }); + expect(await page.evaluate(() => window.devicePixelRatio)).toBe(5); + await page.setContent( + '<div style="width:100px;height:100px">spacer</div>' + ); + await utils.attachFrame( + page, + 'button-test', + server.PREFIX + '/input/button.html' + ); + const frame = page.frames()[1]; + const button = await frame.$('button'); + await button.click(); + expect(await frame.evaluate(() => globalThis.result)).toBe('Clicked'); + } + ); +}); diff --git a/remote/test/puppeteer/test/cookies.spec.ts b/remote/test/puppeteer/test/cookies.spec.ts new file mode 100644 index 0000000000..6d3d9a8cab --- /dev/null +++ b/remote/test/puppeteer/test/cookies.spec.ts @@ -0,0 +1,526 @@ +/** + * 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. + */ +import expect from 'expect'; +import { + getTestState, + setupTestBrowserHooks, + setupTestPageAndContextHooks, + itFailsFirefox, +} from './mocha-utils'; // eslint-disable-line import/extensions + +describe('Cookie specs', () => { + setupTestBrowserHooks(); + setupTestPageAndContextHooks(); + + describe('Page.cookies', function () { + it('should return no cookies in pristine browser context', async () => { + const { page, server } = getTestState(); + await page.goto(server.EMPTY_PAGE); + expect(await page.cookies()).toEqual([]); + }); + it('should get a cookie', async () => { + const { page, server } = getTestState(); + await page.goto(server.EMPTY_PAGE); + await page.evaluate(() => { + document.cookie = 'username=John Doe'; + }); + expect(await page.cookies()).toEqual([ + { + name: 'username', + value: 'John Doe', + domain: 'localhost', + path: '/', + expires: -1, + size: 16, + httpOnly: false, + secure: false, + session: true, + }, + ]); + }); + it('should properly report httpOnly cookie', async () => { + const { page, server } = getTestState(); + server.setRoute('/empty.html', (req, res) => { + res.setHeader('Set-Cookie', 'a=b; HttpOnly; Path=/'); + res.end(); + }); + await page.goto(server.EMPTY_PAGE); + const cookies = await page.cookies(); + expect(cookies.length).toBe(1); + expect(cookies[0].httpOnly).toBe(true); + }); + it('should properly report "Strict" sameSite cookie', async () => { + const { page, server } = getTestState(); + server.setRoute('/empty.html', (req, res) => { + res.setHeader('Set-Cookie', 'a=b; SameSite=Strict'); + res.end(); + }); + await page.goto(server.EMPTY_PAGE); + const cookies = await page.cookies(); + expect(cookies.length).toBe(1); + expect(cookies[0].sameSite).toBe('Strict'); + }); + it('should properly report "Lax" sameSite cookie', async () => { + const { page, server } = getTestState(); + server.setRoute('/empty.html', (req, res) => { + res.setHeader('Set-Cookie', 'a=b; SameSite=Lax'); + res.end(); + }); + await page.goto(server.EMPTY_PAGE); + const cookies = await page.cookies(); + expect(cookies.length).toBe(1); + expect(cookies[0].sameSite).toBe('Lax'); + }); + it('should get multiple cookies', async () => { + const { page, server } = getTestState(); + await page.goto(server.EMPTY_PAGE); + await page.evaluate(() => { + document.cookie = 'username=John Doe'; + document.cookie = 'password=1234'; + }); + const cookies = await page.cookies(); + cookies.sort((a, b) => a.name.localeCompare(b.name)); + expect(cookies).toEqual([ + { + name: 'password', + value: '1234', + domain: 'localhost', + path: '/', + expires: -1, + size: 12, + httpOnly: false, + secure: false, + session: true, + }, + { + name: 'username', + value: 'John Doe', + domain: 'localhost', + path: '/', + expires: -1, + size: 16, + httpOnly: false, + secure: false, + session: true, + }, + ]); + }); + it('should get cookies from multiple urls', async () => { + const { page } = getTestState(); + await page.setCookie( + { + url: 'https://foo.com', + name: 'doggo', + value: 'woofs', + }, + { + url: 'https://bar.com', + name: 'catto', + value: 'purrs', + }, + { + url: 'https://baz.com', + name: 'birdo', + value: 'tweets', + } + ); + const cookies = await page.cookies('https://foo.com', 'https://baz.com'); + cookies.sort((a, b) => a.name.localeCompare(b.name)); + expect(cookies).toEqual([ + { + name: 'birdo', + value: 'tweets', + domain: 'baz.com', + path: '/', + expires: -1, + size: 11, + httpOnly: false, + secure: true, + session: true, + }, + { + name: 'doggo', + value: 'woofs', + domain: 'foo.com', + path: '/', + expires: -1, + size: 10, + httpOnly: false, + secure: true, + session: true, + }, + ]); + }); + }); + describe('Page.setCookie', function () { + it('should work', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.setCookie({ + name: 'password', + value: '123456', + }); + expect(await page.evaluate(() => document.cookie)).toEqual( + 'password=123456' + ); + }); + it('should isolate cookies in browser contexts', async () => { + const { page, server, browser } = getTestState(); + + const anotherContext = await browser.createIncognitoBrowserContext(); + const anotherPage = await anotherContext.newPage(); + + await page.goto(server.EMPTY_PAGE); + await anotherPage.goto(server.EMPTY_PAGE); + + await page.setCookie({ name: 'page1cookie', value: 'page1value' }); + await anotherPage.setCookie({ name: 'page2cookie', value: 'page2value' }); + + const cookies1 = await page.cookies(); + const cookies2 = await anotherPage.cookies(); + expect(cookies1.length).toBe(1); + expect(cookies2.length).toBe(1); + expect(cookies1[0].name).toBe('page1cookie'); + expect(cookies1[0].value).toBe('page1value'); + expect(cookies2[0].name).toBe('page2cookie'); + expect(cookies2[0].value).toBe('page2value'); + await anotherContext.close(); + }); + it('should set multiple cookies', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.setCookie( + { + name: 'password', + value: '123456', + }, + { + name: 'foo', + value: 'bar', + } + ); + expect( + await page.evaluate(() => { + const cookies = document.cookie.split(';'); + return cookies.map((cookie) => cookie.trim()).sort(); + }) + ).toEqual(['foo=bar', 'password=123456']); + }); + it('should have |expires| set to |-1| for session cookies', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.setCookie({ + name: 'password', + value: '123456', + }); + const cookies = await page.cookies(); + expect(cookies[0].session).toBe(true); + expect(cookies[0].expires).toBe(-1); + }); + it('should set cookie with reasonable defaults', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.setCookie({ + name: 'password', + value: '123456', + }); + const cookies = await page.cookies(); + expect(cookies.sort((a, b) => a.name.localeCompare(b.name))).toEqual([ + { + name: 'password', + value: '123456', + domain: 'localhost', + path: '/', + expires: -1, + size: 14, + httpOnly: false, + secure: false, + session: true, + }, + ]); + }); + it('should set a cookie with a path', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/grid.html'); + await page.setCookie({ + name: 'gridcookie', + value: 'GRID', + path: '/grid.html', + }); + expect(await page.cookies()).toEqual([ + { + name: 'gridcookie', + value: 'GRID', + domain: 'localhost', + path: '/grid.html', + expires: -1, + size: 14, + httpOnly: false, + secure: false, + session: true, + }, + ]); + expect(await page.evaluate('document.cookie')).toBe('gridcookie=GRID'); + await page.goto(server.EMPTY_PAGE); + expect(await page.cookies()).toEqual([]); + expect(await page.evaluate('document.cookie')).toBe(''); + await page.goto(server.PREFIX + '/grid.html'); + expect(await page.evaluate('document.cookie')).toBe('gridcookie=GRID'); + }); + it('should not set a cookie on a blank page', async () => { + const { page } = getTestState(); + + await page.goto('about:blank'); + let error = null; + try { + await page.setCookie({ name: 'example-cookie', value: 'best' }); + } catch (error_) { + error = error_; + } + expect(error.message).toContain( + 'At least one of the url and domain needs to be specified' + ); + }); + it('should not set a cookie with blank page URL', async () => { + const { page, server } = getTestState(); + + let error = null; + await page.goto(server.EMPTY_PAGE); + try { + await page.setCookie( + { name: 'example-cookie', value: 'best' }, + { url: 'about:blank', name: 'example-cookie-blank', value: 'best' } + ); + } catch (error_) { + error = error_; + } + expect(error.message).toEqual( + `Blank page can not have cookie "example-cookie-blank"` + ); + }); + it('should not set a cookie on a data URL page', async () => { + const { page } = getTestState(); + + let error = null; + await page.goto('data:,Hello%2C%20World!'); + try { + await page.setCookie({ name: 'example-cookie', value: 'best' }); + } catch (error_) { + error = error_; + } + expect(error.message).toContain( + 'At least one of the url and domain needs to be specified' + ); + }); + it( + 'should default to setting secure cookie for HTTPS websites', + async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + const SECURE_URL = 'https://example.com'; + await page.setCookie({ + url: SECURE_URL, + name: 'foo', + value: 'bar', + }); + const [cookie] = await page.cookies(SECURE_URL); + expect(cookie.secure).toBe(true); + } + ); + it('should be able to set unsecure cookie for HTTP website', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + const HTTP_URL = 'http://example.com'; + await page.setCookie({ + url: HTTP_URL, + name: 'foo', + value: 'bar', + }); + const [cookie] = await page.cookies(HTTP_URL); + expect(cookie.secure).toBe(false); + }); + it('should set a cookie on a different domain', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.setCookie({ + url: 'https://www.example.com', + name: 'example-cookie', + value: 'best', + }); + expect(await page.evaluate('document.cookie')).toBe(''); + expect(await page.cookies()).toEqual([]); + expect(await page.cookies('https://www.example.com')).toEqual([ + { + name: 'example-cookie', + value: 'best', + domain: 'www.example.com', + path: '/', + expires: -1, + size: 18, + httpOnly: false, + secure: true, + session: true, + }, + ]); + }); + it('should set cookies from a frame', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/grid.html'); + await page.setCookie({ name: 'localhost-cookie', value: 'best' }); + await page.evaluate<(src: string) => Promise<void>>((src) => { + let fulfill; + const promise = new Promise<void>((x) => (fulfill = x)); + const iframe = document.createElement('iframe'); + document.body.appendChild(iframe); + iframe.onload = fulfill; + iframe.src = src; + return promise; + }, server.CROSS_PROCESS_PREFIX); + await page.setCookie({ + name: '127-cookie', + value: 'worst', + url: server.CROSS_PROCESS_PREFIX, + }); + expect(await page.evaluate('document.cookie')).toBe( + 'localhost-cookie=best' + ); + expect(await page.frames()[1].evaluate('document.cookie')).toBe(''); + + expect(await page.cookies()).toEqual([ + { + name: 'localhost-cookie', + value: 'best', + domain: 'localhost', + path: '/', + expires: -1, + size: 20, + httpOnly: false, + secure: false, + session: true, + }, + ]); + + expect(await page.cookies(server.CROSS_PROCESS_PREFIX)).toEqual([ + { + name: '127-cookie', + value: 'worst', + domain: '127.0.0.1', + path: '/', + expires: -1, + size: 15, + httpOnly: false, + secure: false, + session: true, + }, + ]); + }); + it( + 'should set secure same-site cookies from a frame', + async () => { + const { + httpsServer, + puppeteer, + defaultBrowserOptions, + } = getTestState(); + + const browser = await puppeteer.launch({ + ...defaultBrowserOptions, + ignoreHTTPSErrors: true, + }); + + const page = await browser.newPage(); + + try { + await page.goto(httpsServer.PREFIX + '/grid.html'); + await page.evaluate<(src: string) => Promise<void>>((src) => { + let fulfill; + const promise = new Promise<void>((x) => (fulfill = x)); + const iframe = document.createElement('iframe'); + document.body.appendChild(iframe); + iframe.onload = fulfill; + iframe.src = src; + return promise; + }, httpsServer.CROSS_PROCESS_PREFIX); + await page.setCookie({ + name: '127-same-site-cookie', + value: 'best', + url: httpsServer.CROSS_PROCESS_PREFIX, + sameSite: 'None', + }); + + expect(await page.frames()[1].evaluate('document.cookie')).toBe( + '127-same-site-cookie=best' + ); + expect(await page.cookies(httpsServer.CROSS_PROCESS_PREFIX)).toEqual([ + { + name: '127-same-site-cookie', + value: 'best', + domain: '127.0.0.1', + path: '/', + expires: -1, + size: 24, + httpOnly: false, + sameSite: 'None', + secure: true, + session: true, + }, + ]); + } finally { + await page.close(); + await browser.close(); + } + } + ); + }); + + describe('Page.deleteCookie', function () { + it('should work', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.setCookie( + { + name: 'cookie1', + value: '1', + }, + { + name: 'cookie2', + value: '2', + }, + { + name: 'cookie3', + value: '3', + } + ); + expect(await page.evaluate('document.cookie')).toBe( + 'cookie1=1; cookie2=2; cookie3=3' + ); + await page.deleteCookie({ name: 'cookie2' }); + expect(await page.evaluate('document.cookie')).toBe( + 'cookie1=1; cookie3=3' + ); + }); + }); +}); diff --git a/remote/test/puppeteer/test/coverage-utils.js b/remote/test/puppeteer/test/coverage-utils.js new file mode 100644 index 0000000000..c23e507f9d --- /dev/null +++ b/remote/test/puppeteer/test/coverage-utils.js @@ -0,0 +1,164 @@ +/** + * Copyright 2020 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. + */ + +// TODO (@jackfranklin): convert this to TypeScript and enable type-checking +// @ts-nocheck + +/* We want to ensure that all of Puppeteer's public API is tested via our unit + * tests but we can't use a tool like Istanbul because the way it instruments + * code unfortunately breaks in Puppeteer where some of that code is then being + * executed in a browser context. + * + * So instead we maintain this coverage code which does the following: + * * takes every public method that we expect to be tested + * * replaces it with a method that calls the original but also updates a Map of calls + * * in an after() test callback it asserts that every public method was called. + * + * We run this when COVERAGE=1. + */ + +const path = require('path'); +const fs = require('fs'); + +/** + * This object is also used by DocLint to know which classes to check are + * documented. It's a pretty hacky solution but DocLint is going away soon as + * part of the TSDoc migration. + */ +const MODULES_TO_CHECK_FOR_COVERAGE = { + Accessibility: '../lib/cjs/puppeteer/common/Accessibility', + Browser: '../lib/cjs/puppeteer/common/Browser', + BrowserContext: '../lib/cjs/puppeteer/common/Browser', + BrowserFetcher: '../lib/cjs/puppeteer/node/BrowserFetcher', + CDPSession: '../lib/cjs/puppeteer/common/Connection', + ConsoleMessage: '../lib/cjs/puppeteer/common/ConsoleMessage', + Coverage: '../lib/cjs/puppeteer/common/Coverage', + Dialog: '../lib/cjs/puppeteer/common/Dialog', + ElementHandle: '../lib/cjs/puppeteer/common/JSHandle', + ExecutionContext: '../lib/cjs/puppeteer/common/ExecutionContext', + EventEmitter: '../lib/cjs/puppeteer/common/EventEmitter', + FileChooser: '../lib/cjs/puppeteer/common/FileChooser', + Frame: '../lib/cjs/puppeteer/common/FrameManager', + JSHandle: '../lib/cjs/puppeteer/common/JSHandle', + Keyboard: '../lib/cjs/puppeteer/common/Input', + Mouse: '../lib/cjs/puppeteer/common/Input', + Page: '../lib/cjs/puppeteer/common/Page', + Puppeteer: '../lib/cjs/puppeteer/common/Puppeteer', + PuppeteerNode: '../lib/cjs/puppeteer/node/Puppeteer', + HTTPRequest: '../lib/cjs/puppeteer/common/HTTPRequest', + HTTPResponse: '../lib/cjs/puppeteer/common/HTTPResponse', + SecurityDetails: '../lib/cjs/puppeteer/common/SecurityDetails', + Target: '../lib/cjs/puppeteer/common/Target', + TimeoutError: '../lib/cjs/puppeteer/common/Errors', + Touchscreen: '../lib/cjs/puppeteer/common/Input', + Tracing: '../lib/cjs/puppeteer/common/Tracing', + WebWorker: '../lib/cjs/puppeteer/common/WebWorker', +}; + +function traceAPICoverage(apiCoverage, className, modulePath) { + const loadedModule = require(modulePath); + const classType = loadedModule[className]; + + if (!classType || !classType.prototype) { + console.error( + `Coverage error: could not find class for ${className}. Is src/api.ts up to date?` + ); + process.exit(1); + } + for (const methodName of Reflect.ownKeys(classType.prototype)) { + const method = Reflect.get(classType.prototype, methodName); + if ( + methodName === 'constructor' || + typeof methodName !== 'string' || + methodName.startsWith('_') || + typeof method !== 'function' + ) + continue; + apiCoverage.set(`${className}.${methodName}`, false); + Reflect.set(classType.prototype, methodName, function (...args) { + apiCoverage.set(`${className}.${methodName}`, true); + return method.call(this, ...args); + }); + } + + /** + * If classes emit events, those events are exposed via an object in the same + * module named XEmittedEvents, where X is the name of the class. For example, + * the Page module exposes PageEmittedEvents. + */ + const eventsName = `${className}EmittedEvents`; + if (loadedModule[eventsName]) { + for (const event of Object.values(loadedModule[eventsName])) { + if (typeof event !== 'symbol') + apiCoverage.set(`${className}.emit(${JSON.stringify(event)})`, false); + } + const method = Reflect.get(classType.prototype, 'emit'); + Reflect.set(classType.prototype, 'emit', function (event, ...args) { + if (typeof event !== 'symbol' && this.listenerCount(event)) + apiCoverage.set(`${className}.emit(${JSON.stringify(event)})`, true); + return method.call(this, event, ...args); + }); + } +} + +const coverageLocation = path.join(__dirname, 'coverage.json'); + +const clearOldCoverage = () => { + try { + fs.unlinkSync(coverageLocation); + } catch (error) { + // do nothing, the file didn't exist + } +}; +const writeCoverage = (coverage) => { + fs.writeFileSync(coverageLocation, JSON.stringify([...coverage.entries()])); +}; + +const getCoverageResults = () => { + let contents; + try { + contents = fs.readFileSync(coverageLocation, { encoding: 'utf8' }); + } catch (error) { + console.error('Warning: coverage file does not exist or is not readable.'); + } + + const coverageMap = new Map(JSON.parse(contents)); + return coverageMap; +}; + +const trackCoverage = () => { + clearOldCoverage(); + const coverageMap = new Map(); + + return { + beforeAll: () => { + for (const [className, moduleFilePath] of Object.entries( + MODULES_TO_CHECK_FOR_COVERAGE + )) { + traceAPICoverage(coverageMap, className, moduleFilePath); + } + }, + afterAll: () => { + writeCoverage(coverageMap); + }, + }; +}; + +module.exports = { + trackCoverage, + getCoverageResults, + MODULES_TO_CHECK_FOR_COVERAGE, +}; diff --git a/remote/test/puppeteer/test/coverage.spec.ts b/remote/test/puppeteer/test/coverage.spec.ts new file mode 100644 index 0000000000..d3aa3bf225 --- /dev/null +++ b/remote/test/puppeteer/test/coverage.spec.ts @@ -0,0 +1,280 @@ +/** + * 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. + */ + +import expect from 'expect'; +import { + getTestState, + setupTestPageAndContextHooks, + setupTestBrowserHooks, + describeChromeOnly, +} from './mocha-utils'; // eslint-disable-line import/extensions + +describe('Coverage specs', function () { + describeChromeOnly('JSCoverage', function () { + setupTestBrowserHooks(); + setupTestPageAndContextHooks(); + + it('should work', async () => { + const { page, server } = getTestState(); + await page.coverage.startJSCoverage(); + await page.goto(server.PREFIX + '/jscoverage/simple.html', { + waitUntil: 'networkidle0', + }); + const coverage = await page.coverage.stopJSCoverage(); + expect(coverage.length).toBe(1); + expect(coverage[0].url).toContain('/jscoverage/simple.html'); + expect(coverage[0].ranges).toEqual([ + { start: 0, end: 17 }, + { start: 35, end: 61 }, + ]); + }); + it('should report sourceURLs', async () => { + const { page, server } = getTestState(); + + await page.coverage.startJSCoverage(); + await page.goto(server.PREFIX + '/jscoverage/sourceurl.html'); + const coverage = await page.coverage.stopJSCoverage(); + expect(coverage.length).toBe(1); + expect(coverage[0].url).toBe('nicename.js'); + }); + it('should ignore eval() scripts by default', async () => { + const { page, server } = getTestState(); + + await page.coverage.startJSCoverage(); + await page.goto(server.PREFIX + '/jscoverage/eval.html'); + const coverage = await page.coverage.stopJSCoverage(); + expect(coverage.length).toBe(1); + }); + it("shouldn't ignore eval() scripts if reportAnonymousScripts is true", async () => { + const { page, server } = getTestState(); + + await page.coverage.startJSCoverage({ reportAnonymousScripts: true }); + await page.goto(server.PREFIX + '/jscoverage/eval.html'); + const coverage = await page.coverage.stopJSCoverage(); + expect( + coverage.find((entry) => entry.url.startsWith('debugger://')) + ).not.toBe(null); + expect(coverage.length).toBe(2); + }); + it('should ignore pptr internal scripts if reportAnonymousScripts is true', async () => { + const { page, server } = getTestState(); + + await page.coverage.startJSCoverage({ reportAnonymousScripts: true }); + await page.goto(server.EMPTY_PAGE); + await page.evaluate('console.log("foo")'); + await page.evaluate(() => console.log('bar')); + const coverage = await page.coverage.stopJSCoverage(); + expect(coverage.length).toBe(0); + }); + it('should report multiple scripts', async () => { + const { page, server } = getTestState(); + + await page.coverage.startJSCoverage(); + await page.goto(server.PREFIX + '/jscoverage/multiple.html'); + const coverage = await page.coverage.stopJSCoverage(); + expect(coverage.length).toBe(2); + coverage.sort((a, b) => a.url.localeCompare(b.url)); + expect(coverage[0].url).toContain('/jscoverage/script1.js'); + expect(coverage[1].url).toContain('/jscoverage/script2.js'); + }); + it('should report right ranges', async () => { + const { page, server } = getTestState(); + + await page.coverage.startJSCoverage(); + await page.goto(server.PREFIX + '/jscoverage/ranges.html'); + const coverage = await page.coverage.stopJSCoverage(); + expect(coverage.length).toBe(1); + const entry = coverage[0]; + expect(entry.ranges.length).toBe(1); + const range = entry.ranges[0]; + expect(entry.text.substring(range.start, range.end)).toBe( + `console.log('used!');` + ); + }); + it('should report scripts that have no coverage', async () => { + const { page, server } = getTestState(); + + await page.coverage.startJSCoverage(); + await page.goto(server.PREFIX + '/jscoverage/unused.html'); + const coverage = await page.coverage.stopJSCoverage(); + expect(coverage.length).toBe(1); + const entry = coverage[0]; + expect(entry.url).toContain('unused.html'); + expect(entry.ranges.length).toBe(0); + }); + it('should work with conditionals', async () => { + const { page, server } = getTestState(); + + await page.coverage.startJSCoverage(); + await page.goto(server.PREFIX + '/jscoverage/involved.html'); + const coverage = await page.coverage.stopJSCoverage(); + expect( + JSON.stringify(coverage, null, 2).replace(/:\d{4}\//g, ':<PORT>/') + ).toBeGolden('jscoverage-involved.txt'); + }); + describe('resetOnNavigation', function () { + it('should report scripts across navigations when disabled', async () => { + const { page, server } = getTestState(); + + await page.coverage.startJSCoverage({ resetOnNavigation: false }); + await page.goto(server.PREFIX + '/jscoverage/multiple.html'); + await page.goto(server.EMPTY_PAGE); + const coverage = await page.coverage.stopJSCoverage(); + expect(coverage.length).toBe(2); + }); + + it('should NOT report scripts across navigations when enabled', async () => { + const { page, server } = getTestState(); + + await page.coverage.startJSCoverage(); // Enabled by default. + await page.goto(server.PREFIX + '/jscoverage/multiple.html'); + await page.goto(server.EMPTY_PAGE); + const coverage = await page.coverage.stopJSCoverage(); + expect(coverage.length).toBe(0); + }); + }); + // @see https://crbug.com/990945 + xit('should not hang when there is a debugger statement', async () => { + const { page, server } = getTestState(); + + await page.coverage.startJSCoverage(); + await page.goto(server.EMPTY_PAGE); + await page.evaluate(() => { + debugger; // eslint-disable-line no-debugger + }); + await page.coverage.stopJSCoverage(); + }); + }); + + describeChromeOnly('CSSCoverage', function () { + setupTestBrowserHooks(); + setupTestPageAndContextHooks(); + + it('should work', async () => { + const { page, server } = getTestState(); + + await page.coverage.startCSSCoverage(); + await page.goto(server.PREFIX + '/csscoverage/simple.html'); + const coverage = await page.coverage.stopCSSCoverage(); + expect(coverage.length).toBe(1); + expect(coverage[0].url).toContain('/csscoverage/simple.html'); + expect(coverage[0].ranges).toEqual([{ start: 1, end: 22 }]); + const range = coverage[0].ranges[0]; + expect(coverage[0].text.substring(range.start, range.end)).toBe( + 'div { color: green; }' + ); + }); + it('should report sourceURLs', async () => { + const { page, server } = getTestState(); + + await page.coverage.startCSSCoverage(); + await page.goto(server.PREFIX + '/csscoverage/sourceurl.html'); + const coverage = await page.coverage.stopCSSCoverage(); + expect(coverage.length).toBe(1); + expect(coverage[0].url).toBe('nicename.css'); + }); + it('should report multiple stylesheets', async () => { + const { page, server } = getTestState(); + + await page.coverage.startCSSCoverage(); + await page.goto(server.PREFIX + '/csscoverage/multiple.html'); + const coverage = await page.coverage.stopCSSCoverage(); + expect(coverage.length).toBe(2); + coverage.sort((a, b) => a.url.localeCompare(b.url)); + expect(coverage[0].url).toContain('/csscoverage/stylesheet1.css'); + expect(coverage[1].url).toContain('/csscoverage/stylesheet2.css'); + }); + it('should report stylesheets that have no coverage', async () => { + const { page, server } = getTestState(); + + await page.coverage.startCSSCoverage(); + await page.goto(server.PREFIX + '/csscoverage/unused.html'); + const coverage = await page.coverage.stopCSSCoverage(); + expect(coverage.length).toBe(1); + expect(coverage[0].url).toBe('unused.css'); + expect(coverage[0].ranges.length).toBe(0); + }); + it('should work with media queries', async () => { + const { page, server } = getTestState(); + + await page.coverage.startCSSCoverage(); + await page.goto(server.PREFIX + '/csscoverage/media.html'); + const coverage = await page.coverage.stopCSSCoverage(); + expect(coverage.length).toBe(1); + expect(coverage[0].url).toContain('/csscoverage/media.html'); + expect(coverage[0].ranges).toEqual([{ start: 17, end: 38 }]); + }); + it('should work with complicated usecases', async () => { + const { page, server } = getTestState(); + + await page.coverage.startCSSCoverage(); + await page.goto(server.PREFIX + '/csscoverage/involved.html'); + const coverage = await page.coverage.stopCSSCoverage(); + expect( + JSON.stringify(coverage, null, 2).replace(/:\d{4}\//g, ':<PORT>/') + ).toBeGolden('csscoverage-involved.txt'); + }); + it('should ignore injected stylesheets', async () => { + const { page } = getTestState(); + + await page.coverage.startCSSCoverage(); + await page.addStyleTag({ content: 'body { margin: 10px;}' }); + // trigger style recalc + const margin = await page.evaluate( + () => window.getComputedStyle(document.body).margin + ); + expect(margin).toBe('10px'); + const coverage = await page.coverage.stopCSSCoverage(); + expect(coverage.length).toBe(0); + }); + describe('resetOnNavigation', function () { + it('should report stylesheets across navigations', async () => { + const { page, server } = getTestState(); + + await page.coverage.startCSSCoverage({ resetOnNavigation: false }); + await page.goto(server.PREFIX + '/csscoverage/multiple.html'); + await page.goto(server.EMPTY_PAGE); + const coverage = await page.coverage.stopCSSCoverage(); + expect(coverage.length).toBe(2); + }); + it('should NOT report scripts across navigations', async () => { + const { page, server } = getTestState(); + + await page.coverage.startCSSCoverage(); // Enabled by default. + await page.goto(server.PREFIX + '/csscoverage/multiple.html'); + await page.goto(server.EMPTY_PAGE); + const coverage = await page.coverage.stopCSSCoverage(); + expect(coverage.length).toBe(0); + }); + }); + it('should work with a recently loaded stylesheet', async () => { + const { page, server } = getTestState(); + + await page.coverage.startCSSCoverage(); + await page.evaluate<(url: string) => Promise<void>>(async (url) => { + document.body.textContent = 'hello, world'; + + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = url; + document.head.appendChild(link); + await new Promise((x) => (link.onload = x)); + }, server.PREFIX + '/csscoverage/stylesheet1.css'); + const coverage = await page.coverage.stopCSSCoverage(); + expect(coverage.length).toBe(1); + }); + }); +}); diff --git a/remote/test/puppeteer/test/defaultbrowsercontext.spec.ts b/remote/test/puppeteer/test/defaultbrowsercontext.spec.ts new file mode 100644 index 0000000000..23521ae762 --- /dev/null +++ b/remote/test/puppeteer/test/defaultbrowsercontext.spec.ts @@ -0,0 +1,104 @@ +/** + * 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. + */ +import expect from 'expect'; +import { + getTestState, + setupTestBrowserHooks, + setupTestPageAndContextHooks, + itFailsFirefox, +} from './mocha-utils'; // eslint-disable-line import/extensions + +describe('DefaultBrowserContext', function () { + setupTestBrowserHooks(); + setupTestPageAndContextHooks(); + it('page.cookies() should work', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.evaluate(() => { + document.cookie = 'username=John Doe'; + }); + expect(await page.cookies()).toEqual([ + { + name: 'username', + value: 'John Doe', + domain: 'localhost', + path: '/', + expires: -1, + size: 16, + httpOnly: false, + secure: false, + session: true, + }, + ]); + }); + it('page.setCookie() should work', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.setCookie({ + name: 'username', + value: 'John Doe', + }); + expect(await page.evaluate(() => document.cookie)).toBe( + 'username=John Doe' + ); + expect(await page.cookies()).toEqual([ + { + name: 'username', + value: 'John Doe', + domain: 'localhost', + path: '/', + expires: -1, + size: 16, + httpOnly: false, + secure: false, + session: true, + }, + ]); + }); + it('page.deleteCookie() should work', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.setCookie( + { + name: 'cookie1', + value: '1', + }, + { + name: 'cookie2', + value: '2', + } + ); + expect(await page.evaluate('document.cookie')).toBe('cookie1=1; cookie2=2'); + await page.deleteCookie({ name: 'cookie2' }); + expect(await page.evaluate('document.cookie')).toBe('cookie1=1'); + expect(await page.cookies()).toEqual([ + { + name: 'cookie1', + value: '1', + domain: 'localhost', + path: '/', + expires: -1, + size: 8, + httpOnly: false, + secure: false, + session: true, + }, + ]); + }); +}); diff --git a/remote/test/puppeteer/test/dialog.spec.ts b/remote/test/puppeteer/test/dialog.spec.ts new file mode 100644 index 0000000000..0064020fff --- /dev/null +++ b/remote/test/puppeteer/test/dialog.spec.ts @@ -0,0 +1,73 @@ +/** + * 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. + */ +import expect from 'expect'; +import sinon from 'sinon'; + +import { + getTestState, + setupTestPageAndContextHooks, + setupTestBrowserHooks, +} from './mocha-utils'; // eslint-disable-line import/extensions + +describe('Page.Events.Dialog', function () { + setupTestBrowserHooks(); + setupTestPageAndContextHooks(); + + it('should fire', async () => { + const { page } = getTestState(); + + const onDialog = sinon.stub().callsFake((dialog) => { + dialog.accept(); + }); + page.on('dialog', onDialog); + + await page.evaluate(() => alert('yo')); + + expect(onDialog.callCount).toEqual(1); + const dialog = onDialog.firstCall.args[0]; + expect(dialog.type()).toBe('alert'); + expect(dialog.defaultValue()).toBe(''); + expect(dialog.message()).toBe('yo'); + }); + + it('should allow accepting prompts', async () => { + const { page } = getTestState(); + + const onDialog = sinon.stub().callsFake((dialog) => { + dialog.accept('answer!'); + }); + page.on('dialog', onDialog); + + const result = await page.evaluate(() => prompt('question?', 'yes.')); + + expect(onDialog.callCount).toEqual(1); + const dialog = onDialog.firstCall.args[0]; + expect(dialog.type()).toBe('prompt'); + expect(dialog.defaultValue()).toBe('yes.'); + expect(dialog.message()).toBe('question?'); + + expect(result).toBe('answer!'); + }); + it('should dismiss the prompt', async () => { + const { page } = getTestState(); + + page.on('dialog', (dialog) => { + dialog.dismiss(); + }); + const result = await page.evaluate(() => prompt('question?')); + expect(result).toBe(null); + }); +}); diff --git a/remote/test/puppeteer/test/diffstyle.css b/remote/test/puppeteer/test/diffstyle.css new file mode 100644 index 0000000000..c58f0e90a6 --- /dev/null +++ b/remote/test/puppeteer/test/diffstyle.css @@ -0,0 +1,13 @@ +body { + font-family: monospace; + white-space: pre; +} + +ins { + background-color: #9cffa0; + text-decoration: none; +} + +del { + background-color: #ff9e9e; +} diff --git a/remote/test/puppeteer/test/elementhandle.spec.ts b/remote/test/puppeteer/test/elementhandle.spec.ts new file mode 100644 index 0000000000..617ff8e0ec --- /dev/null +++ b/remote/test/puppeteer/test/elementhandle.spec.ts @@ -0,0 +1,449 @@ +/** + * 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. + */ + +import expect from 'expect'; +import sinon from 'sinon'; +import { + getTestState, + setupTestBrowserHooks, + setupTestPageAndContextHooks, +} from './mocha-utils'; // eslint-disable-line import/extensions + +import utils from './utils.js'; +import { ElementHandle } from '../lib/cjs/puppeteer/common/JSHandle.js'; + +describe('ElementHandle specs', function () { + setupTestBrowserHooks(); + setupTestPageAndContextHooks(); + + describe('ElementHandle.boundingBox', function () { + it('should work', async () => { + const { page, server } = getTestState(); + + await page.setViewport({ width: 500, height: 500 }); + await page.goto(server.PREFIX + '/grid.html'); + const elementHandle = await page.$('.box:nth-of-type(13)'); + const box = await elementHandle.boundingBox(); + expect(box).toEqual({ x: 100, y: 50, width: 50, height: 50 }); + }); + it('should handle nested frames', async () => { + const { page, server, isChrome } = getTestState(); + + await page.setViewport({ width: 500, height: 500 }); + await page.goto(server.PREFIX + '/frames/nested-frames.html'); + const nestedFrame = page.frames()[1].childFrames()[1]; + const elementHandle = await nestedFrame.$('div'); + const box = await elementHandle.boundingBox(); + if (isChrome) + expect(box).toEqual({ x: 28, y: 182, width: 264, height: 18 }); + else expect(box).toEqual({ x: 28, y: 182, width: 254, height: 18 }); + }); + it('should return null for invisible elements', async () => { + const { page } = getTestState(); + + await page.setContent('<div style="display:none">hi</div>'); + const element = await page.$('div'); + expect(await element.boundingBox()).toBe(null); + }); + it('should force a layout', async () => { + const { page } = getTestState(); + + await page.setViewport({ width: 500, height: 500 }); + await page.setContent( + '<div style="width: 100px; height: 100px">hello</div>' + ); + const elementHandle = await page.$('div'); + await page.evaluate<(element: HTMLElement) => void>( + (element) => (element.style.height = '200px'), + elementHandle + ); + const box = await elementHandle.boundingBox(); + expect(box).toEqual({ x: 8, y: 8, width: 100, height: 200 }); + }); + it('should work with SVG nodes', async () => { + const { page } = getTestState(); + + await page.setContent(` + <svg xmlns="http://www.w3.org/2000/svg" width="500" height="500"> + <rect id="theRect" x="30" y="50" width="200" height="300"></rect> + </svg> + `); + const element = await page.$('#therect'); + const pptrBoundingBox = await element.boundingBox(); + const webBoundingBox = await page.evaluate((e: HTMLElement) => { + const rect = e.getBoundingClientRect(); + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; + }, element); + expect(pptrBoundingBox).toEqual(webBoundingBox); + }); + }); + + describe('ElementHandle.boxModel', function () { + it('should work', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/resetcss.html'); + + // Step 1: Add Frame and position it absolutely. + await utils.attachFrame(page, 'frame1', server.PREFIX + '/resetcss.html'); + await page.evaluate(() => { + const frame = document.querySelector<HTMLElement>('#frame1'); + frame.style.position = 'absolute'; + frame.style.left = '1px'; + frame.style.top = '2px'; + }); + + // Step 2: Add div and position it absolutely inside frame. + const frame = page.frames()[1]; + const divHandle = ( + await frame.evaluateHandle(() => { + const div = document.createElement('div'); + document.body.appendChild(div); + div.style.boxSizing = 'border-box'; + div.style.position = 'absolute'; + div.style.borderLeft = '1px solid black'; + div.style.paddingLeft = '2px'; + div.style.marginLeft = '3px'; + div.style.left = '4px'; + div.style.top = '5px'; + div.style.width = '6px'; + div.style.height = '7px'; + return div; + }) + ).asElement(); + + // Step 3: query div's boxModel and assert box values. + const box = await divHandle.boxModel(); + expect(box.width).toBe(6); + expect(box.height).toBe(7); + expect(box.margin[0]).toEqual({ + x: 1 + 4, // frame.left + div.left + y: 2 + 5, + }); + expect(box.border[0]).toEqual({ + x: 1 + 4 + 3, // frame.left + div.left + div.margin-left + y: 2 + 5, + }); + expect(box.padding[0]).toEqual({ + x: 1 + 4 + 3 + 1, // frame.left + div.left + div.marginLeft + div.borderLeft + y: 2 + 5, + }); + expect(box.content[0]).toEqual({ + x: 1 + 4 + 3 + 1 + 2, // frame.left + div.left + div.marginLeft + div.borderLeft + dif.paddingLeft + y: 2 + 5, + }); + }); + + it('should return null for invisible elements', async () => { + const { page } = getTestState(); + + await page.setContent('<div style="display:none">hi</div>'); + const element = await page.$('div'); + expect(await element.boxModel()).toBe(null); + }); + }); + + describe('ElementHandle.contentFrame', function () { + it('should work', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + await utils.attachFrame(page, 'frame1', server.EMPTY_PAGE); + const elementHandle = await page.$('#frame1'); + const frame = await elementHandle.contentFrame(); + expect(frame).toBe(page.frames()[1]); + }); + }); + + describe('ElementHandle.click', function () { + it('should work', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/input/button.html'); + const button = await page.$('button'); + await button.click(); + expect(await page.evaluate(() => globalThis.result)).toBe('Clicked'); + }); + it('should work for Shadow DOM v1', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/shadow.html'); + const buttonHandle = await page.evaluateHandle<ElementHandle>( + // @ts-expect-error button is expected to be in the page's scope. + () => button + ); + await buttonHandle.click(); + expect( + await page.evaluate( + // @ts-expect-error clicked is expected to be in the page's scope. + () => clicked + ) + ).toBe(true); + }); + it('should work for TextNodes', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/input/button.html'); + const buttonTextNode = await page.evaluateHandle<ElementHandle>( + () => document.querySelector('button').firstChild + ); + let error = null; + await buttonTextNode.click().catch((error_) => (error = error_)); + expect(error.message).toBe('Node is not of type HTMLElement'); + }); + it('should throw for detached nodes', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/input/button.html'); + const button = await page.$('button'); + await page.evaluate((button: HTMLElement) => button.remove(), button); + let error = null; + await button.click().catch((error_) => (error = error_)); + expect(error.message).toBe('Node is detached from document'); + }); + it('should throw for hidden nodes', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/input/button.html'); + const button = await page.$('button'); + await page.evaluate( + (button: HTMLElement) => (button.style.display = 'none'), + button + ); + const error = await button.click().catch((error_) => error_); + expect(error.message).toBe( + 'Node is either not visible or not an HTMLElement' + ); + }); + it('should throw for recursively hidden nodes', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/input/button.html'); + const button = await page.$('button'); + await page.evaluate( + (button: HTMLElement) => (button.parentElement.style.display = 'none'), + button + ); + const error = await button.click().catch((error_) => error_); + expect(error.message).toBe( + 'Node is either not visible or not an HTMLElement' + ); + }); + it('should throw for <br> elements', async () => { + const { page } = getTestState(); + + await page.setContent('hello<br>goodbye'); + const br = await page.$('br'); + const error = await br.click().catch((error_) => error_); + expect(error.message).toBe( + 'Node is either not visible or not an HTMLElement' + ); + }); + }); + + describe('ElementHandle.hover', function () { + it('should work', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/input/scrollable.html'); + const button = await page.$('#button-6'); + await button.hover(); + expect( + await page.evaluate(() => document.querySelector('button:hover').id) + ).toBe('button-6'); + }); + }); + + describe('ElementHandle.isIntersectingViewport', function () { + it('should work', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/offscreenbuttons.html'); + for (let i = 0; i < 11; ++i) { + const button = await page.$('#btn' + i); + // All but last button are visible. + const visible = i < 10; + expect(await button.isIntersectingViewport()).toBe(visible); + } + }); + }); + + describe('Custom queries', function () { + this.afterEach(() => { + const { puppeteer } = getTestState(); + puppeteer.clearCustomQueryHandlers(); + }); + it('should register and unregister', async () => { + const { page, puppeteer } = getTestState(); + await page.setContent('<div id="not-foo"></div><div id="foo"></div>'); + + // Register. + puppeteer.registerCustomQueryHandler('getById', { + queryOne: (element, selector) => + document.querySelector(`[id="${selector}"]`), + }); + const element = await page.$('getById/foo'); + expect( + await page.evaluate<(element: HTMLElement) => string>( + (element) => element.id, + element + ) + ).toBe('foo'); + const handlerNamesAfterRegistering = puppeteer.customQueryHandlerNames(); + expect(handlerNamesAfterRegistering.includes('getById')).toBeTruthy(); + + // Unregister. + puppeteer.unregisterCustomQueryHandler('getById'); + try { + await page.$('getById/foo'); + throw new Error('Custom query handler name not set - throw expected'); + } catch (error) { + expect(error).toStrictEqual( + new Error( + 'Query set to use "getById", but no query handler of that name was found' + ) + ); + } + const handlerNamesAfterUnregistering = puppeteer.customQueryHandlerNames(); + expect(handlerNamesAfterUnregistering.includes('getById')).toBeFalsy(); + }); + it('should throw with invalid query names', () => { + try { + const { puppeteer } = getTestState(); + puppeteer.registerCustomQueryHandler('1/2/3', { + queryOne: () => document.querySelector('foo'), + }); + throw new Error( + 'Custom query handler name was invalid - throw expected' + ); + } catch (error) { + expect(error).toStrictEqual( + new Error('Custom query handler names may only contain [a-zA-Z]') + ); + } + }); + it('should work for multiple elements', async () => { + const { page, puppeteer } = getTestState(); + await page.setContent( + '<div id="not-foo"></div><div class="foo">Foo1</div><div class="foo baz">Foo2</div>' + ); + puppeteer.registerCustomQueryHandler('getByClass', { + queryAll: (element, selector) => + document.querySelectorAll(`.${selector}`), + }); + const elements = await page.$$('getByClass/foo'); + const classNames = await Promise.all( + elements.map( + async (element) => + await page.evaluate<(element: HTMLElement) => string>( + (element) => element.className, + element + ) + ) + ); + + expect(classNames).toStrictEqual(['foo', 'foo baz']); + }); + it('should eval correctly', async () => { + const { page, puppeteer } = getTestState(); + await page.setContent( + '<div id="not-foo"></div><div class="foo">Foo1</div><div class="foo baz">Foo2</div>' + ); + puppeteer.registerCustomQueryHandler('getByClass', { + queryAll: (element, selector) => + document.querySelectorAll(`.${selector}`), + }); + const elements = await page.$$eval( + 'getByClass/foo', + (divs) => divs.length + ); + + expect(elements).toBe(2); + }); + it('should wait correctly with waitForSelector', async () => { + const { page, puppeteer } = getTestState(); + puppeteer.registerCustomQueryHandler('getByClass', { + queryOne: (element, selector) => element.querySelector(`.${selector}`), + }); + const waitFor = page.waitForSelector('getByClass/foo'); + + // Set the page content after the waitFor has been started. + await page.setContent( + '<div id="not-foo"></div><div class="foo">Foo1</div>' + ); + const element = await waitFor; + + expect(element).toBeDefined(); + }); + + it('should wait correctly with waitFor', async () => { + /* page.waitFor is deprecated so we silence the warning to avoid test noise */ + sinon.stub(console, 'warn').callsFake(() => {}); + const { page, puppeteer } = getTestState(); + puppeteer.registerCustomQueryHandler('getByClass', { + queryOne: (element, selector) => element.querySelector(`.${selector}`), + }); + const waitFor = page.waitFor('getByClass/foo'); + + // Set the page content after the waitFor has been started. + await page.setContent( + '<div id="not-foo"></div><div class="foo">Foo1</div>' + ); + const element = await waitFor; + + expect(element).toBeDefined(); + }); + it('should work when both queryOne and queryAll are registered', async () => { + const { page, puppeteer } = getTestState(); + await page.setContent( + '<div id="not-foo"></div><div class="foo"><div id="nested-foo" class="foo"/></div><div class="foo baz">Foo2</div>' + ); + puppeteer.registerCustomQueryHandler('getByClass', { + queryOne: (element, selector) => element.querySelector(`.${selector}`), + queryAll: (element, selector) => + element.querySelectorAll(`.${selector}`), + }); + + const element = await page.$('getByClass/foo'); + expect(element).toBeDefined(); + + const elements = await page.$$('getByClass/foo'); + expect(elements.length).toBe(3); + }); + it('should eval when both queryOne and queryAll are registered', async () => { + const { page, puppeteer } = getTestState(); + await page.setContent( + '<div id="not-foo"></div><div class="foo">text</div><div class="foo baz">content</div>' + ); + puppeteer.registerCustomQueryHandler('getByClass', { + queryOne: (element, selector) => element.querySelector(`.${selector}`), + queryAll: (element, selector) => + element.querySelectorAll(`.${selector}`), + }); + + const txtContent = await page.$eval( + 'getByClass/foo', + (div) => div.textContent + ); + expect(txtContent).toBe('text'); + + const txtContents = await page.$$eval('getByClass/foo', (divs) => + divs.map((d) => d.textContent).join('') + ); + expect(txtContents).toBe('textcontent'); + }); + }); +}); diff --git a/remote/test/puppeteer/test/emulation.spec.ts b/remote/test/puppeteer/test/emulation.spec.ts new file mode 100644 index 0000000000..f594127a63 --- /dev/null +++ b/remote/test/puppeteer/test/emulation.spec.ts @@ -0,0 +1,355 @@ +/** + * 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. + */ + +import expect from 'expect'; +import { + getTestState, + setupTestBrowserHooks, + setupTestPageAndContextHooks, +} from './mocha-utils'; // eslint-disable-line import/extensions + +describe('Emulation', () => { + setupTestBrowserHooks(); + setupTestPageAndContextHooks(); + let iPhone; + let iPhoneLandscape; + + before(() => { + const { puppeteer } = getTestState(); + iPhone = puppeteer.devices['iPhone 6']; + iPhoneLandscape = puppeteer.devices['iPhone 6 landscape']; + }); + + describe('Page.viewport', function () { + it('should get the proper viewport size', async () => { + const { page } = getTestState(); + + expect(page.viewport()).toEqual({ width: 800, height: 600 }); + await page.setViewport({ width: 123, height: 456 }); + expect(page.viewport()).toEqual({ width: 123, height: 456 }); + }); + it('should support mobile emulation', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/mobile.html'); + expect(await page.evaluate(() => window.innerWidth)).toBe(800); + await page.setViewport(iPhone.viewport); + expect(await page.evaluate(() => window.innerWidth)).toBe(375); + await page.setViewport({ width: 400, height: 300 }); + expect(await page.evaluate(() => window.innerWidth)).toBe(400); + }); + it('should support touch emulation', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/mobile.html'); + expect(await page.evaluate(() => 'ontouchstart' in window)).toBe(false); + await page.setViewport(iPhone.viewport); + expect(await page.evaluate(() => 'ontouchstart' in window)).toBe(true); + expect(await page.evaluate(dispatchTouch)).toBe('Received touch'); + await page.setViewport({ width: 100, height: 100 }); + expect(await page.evaluate(() => 'ontouchstart' in window)).toBe(false); + + function dispatchTouch() { + let fulfill; + const promise = new Promise((x) => (fulfill = x)); + window.ontouchstart = () => { + fulfill('Received touch'); + }; + window.dispatchEvent(new Event('touchstart')); + + fulfill('Did not receive touch'); + + return promise; + } + }); + it('should be detectable by Modernizr', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/detect-touch.html'); + expect(await page.evaluate(() => document.body.textContent.trim())).toBe( + 'NO' + ); + await page.setViewport(iPhone.viewport); + await page.goto(server.PREFIX + '/detect-touch.html'); + expect(await page.evaluate(() => document.body.textContent.trim())).toBe( + 'YES' + ); + }); + it('should detect touch when applying viewport with touches', async () => { + const { page, server } = getTestState(); + + await page.setViewport({ width: 800, height: 600, hasTouch: true }); + await page.addScriptTag({ url: server.PREFIX + '/modernizr.js' }); + expect(await page.evaluate(() => globalThis.Modernizr.touchevents)).toBe( + true + ); + }); + it('should support landscape emulation', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/mobile.html'); + expect(await page.evaluate(() => screen.orientation.type)).toBe( + 'portrait-primary' + ); + await page.setViewport(iPhoneLandscape.viewport); + expect(await page.evaluate(() => screen.orientation.type)).toBe( + 'landscape-primary' + ); + await page.setViewport({ width: 100, height: 100 }); + expect(await page.evaluate(() => screen.orientation.type)).toBe( + 'portrait-primary' + ); + }); + }); + + describe('Page.emulate', function () { + it('should work', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/mobile.html'); + await page.emulate(iPhone); + expect(await page.evaluate(() => window.innerWidth)).toBe(375); + expect(await page.evaluate(() => navigator.userAgent)).toContain( + 'iPhone' + ); + }); + it('should support clicking', async () => { + const { page, server } = getTestState(); + + await page.emulate(iPhone); + await page.goto(server.PREFIX + '/input/button.html'); + const button = await page.$('button'); + await page.evaluate( + (button: HTMLElement) => (button.style.marginTop = '200px'), + button + ); + await button.click(); + expect(await page.evaluate(() => globalThis.result)).toBe('Clicked'); + }); + }); + + describe('Page.emulateMediaType', function () { + it('should work', async () => { + const { page } = getTestState(); + + expect(await page.evaluate(() => matchMedia('screen').matches)).toBe( + true + ); + expect(await page.evaluate(() => matchMedia('print').matches)).toBe( + false + ); + await page.emulateMediaType('print'); + expect(await page.evaluate(() => matchMedia('screen').matches)).toBe( + false + ); + expect(await page.evaluate(() => matchMedia('print').matches)).toBe(true); + await page.emulateMediaType(null); + expect(await page.evaluate(() => matchMedia('screen').matches)).toBe( + true + ); + expect(await page.evaluate(() => matchMedia('print').matches)).toBe( + false + ); + }); + it('should throw in case of bad argument', async () => { + const { page } = getTestState(); + + let error = null; + await page.emulateMediaType('bad').catch((error_) => (error = error_)); + expect(error.message).toBe('Unsupported media type: bad'); + }); + }); + + describe('Page.emulateMediaFeatures', function () { + it('should work', async () => { + const { page } = getTestState(); + + await page.emulateMediaFeatures([ + { name: 'prefers-reduced-motion', value: 'reduce' }, + ]); + expect( + await page.evaluate( + () => matchMedia('(prefers-reduced-motion: reduce)').matches + ) + ).toBe(true); + expect( + await page.evaluate( + () => matchMedia('(prefers-reduced-motion: no-preference)').matches + ) + ).toBe(false); + await page.emulateMediaFeatures([ + { name: 'prefers-color-scheme', value: 'light' }, + ]); + expect( + await page.evaluate( + () => matchMedia('(prefers-color-scheme: light)').matches + ) + ).toBe(true); + expect( + await page.evaluate( + () => matchMedia('(prefers-color-scheme: dark)').matches + ) + ).toBe(false); + await page.emulateMediaFeatures([ + { name: 'prefers-color-scheme', value: 'dark' }, + ]); + expect( + await page.evaluate( + () => matchMedia('(prefers-color-scheme: dark)').matches + ) + ).toBe(true); + expect( + await page.evaluate( + () => matchMedia('(prefers-color-scheme: light)').matches + ) + ).toBe(false); + await page.emulateMediaFeatures([ + { name: 'prefers-reduced-motion', value: 'reduce' }, + { name: 'prefers-color-scheme', value: 'light' }, + ]); + expect( + await page.evaluate( + () => matchMedia('(prefers-reduced-motion: reduce)').matches + ) + ).toBe(true); + expect( + await page.evaluate( + () => matchMedia('(prefers-reduced-motion: no-preference)').matches + ) + ).toBe(false); + expect( + await page.evaluate( + () => matchMedia('(prefers-color-scheme: light)').matches + ) + ).toBe(true); + expect( + await page.evaluate( + () => matchMedia('(prefers-color-scheme: dark)').matches + ) + ).toBe(false); + }); + it('should throw in case of bad argument', async () => { + const { page } = getTestState(); + + let error = null; + await page + .emulateMediaFeatures([{ name: 'bad', value: '' }]) + .catch((error_) => (error = error_)); + expect(error.message).toBe('Unsupported media feature: bad'); + }); + }); + + describe('Page.emulateTimezone', function () { + it('should work', async () => { + const { page } = getTestState(); + + await page.evaluate(() => { + globalThis.date = new Date(1479579154987); + }); + await page.emulateTimezone('America/Jamaica'); + expect(await page.evaluate(() => globalThis.date.toString())).toBe( + 'Sat Nov 19 2016 13:12:34 GMT-0500 (Eastern Standard Time)' + ); + + await page.emulateTimezone('Pacific/Honolulu'); + expect(await page.evaluate(() => globalThis.date.toString())).toBe( + 'Sat Nov 19 2016 08:12:34 GMT-1000 (Hawaii-Aleutian Standard Time)' + ); + + await page.emulateTimezone('America/Buenos_Aires'); + expect(await page.evaluate(() => globalThis.date.toString())).toBe( + 'Sat Nov 19 2016 15:12:34 GMT-0300 (Argentina Standard Time)' + ); + + await page.emulateTimezone('Europe/Berlin'); + expect(await page.evaluate(() => globalThis.date.toString())).toBe( + 'Sat Nov 19 2016 19:12:34 GMT+0100 (Central European Standard Time)' + ); + }); + + it('should throw for invalid timezone IDs', async () => { + const { page } = getTestState(); + + let error = null; + await page.emulateTimezone('Foo/Bar').catch((error_) => (error = error_)); + expect(error.message).toBe('Invalid timezone ID: Foo/Bar'); + await page.emulateTimezone('Baz/Qux').catch((error_) => (error = error_)); + expect(error.message).toBe('Invalid timezone ID: Baz/Qux'); + }); + }); + + describe('Page.emulateVisionDeficiency', function () { + it('should work', async () => { + const { page, server } = getTestState(); + + await page.setViewport({ width: 500, height: 500 }); + await page.goto(server.PREFIX + '/grid.html'); + + { + await page.emulateVisionDeficiency('none'); + const screenshot = await page.screenshot(); + expect(screenshot).toBeGolden('screenshot-sanity.png'); + } + + { + await page.emulateVisionDeficiency('achromatopsia'); + const screenshot = await page.screenshot(); + expect(screenshot).toBeGolden('vision-deficiency-achromatopsia.png'); + } + + { + await page.emulateVisionDeficiency('blurredVision'); + const screenshot = await page.screenshot(); + expect(screenshot).toBeGolden('vision-deficiency-blurredVision.png'); + } + + { + await page.emulateVisionDeficiency('deuteranopia'); + const screenshot = await page.screenshot(); + expect(screenshot).toBeGolden('vision-deficiency-deuteranopia.png'); + } + + { + await page.emulateVisionDeficiency('protanopia'); + const screenshot = await page.screenshot(); + expect(screenshot).toBeGolden('vision-deficiency-protanopia.png'); + } + + { + await page.emulateVisionDeficiency('tritanopia'); + const screenshot = await page.screenshot(); + expect(screenshot).toBeGolden('vision-deficiency-tritanopia.png'); + } + + { + await page.emulateVisionDeficiency('none'); + const screenshot = await page.screenshot(); + expect(screenshot).toBeGolden('screenshot-sanity.png'); + } + }); + + it('should throw for invalid vision deficiencies', async () => { + const { page } = getTestState(); + + let error = null; + await page + // @ts-expect-error deliberately passign invalid deficiency + .emulateVisionDeficiency('invalid') + .catch((error_) => (error = error_)); + expect(error.message).toBe('Unsupported vision deficiency: invalid'); + }); + }); +}); diff --git a/remote/test/puppeteer/test/evaluation.spec.ts b/remote/test/puppeteer/test/evaluation.spec.ts new file mode 100644 index 0000000000..3e9423728a --- /dev/null +++ b/remote/test/puppeteer/test/evaluation.spec.ts @@ -0,0 +1,474 @@ +/** + * 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. + */ + +import utils from './utils.js'; +import expect from 'expect'; +import { + getTestState, + setupTestBrowserHooks, + setupTestPageAndContextHooks, +} from './mocha-utils'; // eslint-disable-line import/extensions + +const bigint = typeof BigInt !== 'undefined'; + +describe('Evaluation specs', function () { + setupTestBrowserHooks(); + setupTestPageAndContextHooks(); + + describe('Page.evaluate', function () { + it('should work', async () => { + const { page } = getTestState(); + + const result = await page.evaluate(() => 7 * 3); + expect(result).toBe(21); + }); + (bigint ? it : xit)('should transfer BigInt', async () => { + const { page } = getTestState(); + + const result = await page.evaluate((a: BigInt) => a, BigInt(42)); + expect(result).toBe(BigInt(42)); + }); + it('should transfer NaN', async () => { + const { page } = getTestState(); + + const result = await page.evaluate((a) => a, NaN); + expect(Object.is(result, NaN)).toBe(true); + }); + it('should transfer -0', async () => { + const { page } = getTestState(); + + const result = await page.evaluate((a) => a, -0); + expect(Object.is(result, -0)).toBe(true); + }); + it('should transfer Infinity', async () => { + const { page } = getTestState(); + + const result = await page.evaluate((a) => a, Infinity); + expect(Object.is(result, Infinity)).toBe(true); + }); + it('should transfer -Infinity', async () => { + const { page } = getTestState(); + + const result = await page.evaluate((a) => a, -Infinity); + expect(Object.is(result, -Infinity)).toBe(true); + }); + it('should transfer arrays', async () => { + const { page } = getTestState(); + + const result = await page.evaluate((a) => a, [1, 2, 3]); + expect(result).toEqual([1, 2, 3]); + }); + it('should transfer arrays as arrays, not objects', async () => { + const { page } = getTestState(); + + const result = await page.evaluate((a) => Array.isArray(a), [1, 2, 3]); + expect(result).toBe(true); + }); + it('should modify global environment', async () => { + const { page } = getTestState(); + + await page.evaluate(() => (globalThis.globalVar = 123)); + expect(await page.evaluate('globalVar')).toBe(123); + }); + it('should evaluate in the page context', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/global-var.html'); + expect(await page.evaluate('globalVar')).toBe(123); + }); + it( + 'should return undefined for objects with symbols', + async () => { + const { page } = getTestState(); + + expect(await page.evaluate(() => [Symbol('foo4')])).toBe(undefined); + } + ); + it('should work with function shorthands', async () => { + const { page } = getTestState(); + + const a = { + sum(a, b) { + return a + b; + }, + + async mult(a, b) { + return a * b; + }, + }; + expect(await page.evaluate(a.sum, 1, 2)).toBe(3); + expect(await page.evaluate(a.mult, 2, 4)).toBe(8); + }); + it('should work with unicode chars', async () => { + const { page } = getTestState(); + + const result = await page.evaluate((a) => a['中文字符'], { + 中文字符: 42, + }); + expect(result).toBe(42); + }); + it('should throw when evaluation triggers reload', async () => { + const { page } = getTestState(); + + let error = null; + await page + .evaluate(() => { + location.reload(); + return new Promise(() => {}); + }) + .catch((error_) => (error = error_)); + expect(error.message).toContain('Protocol error'); + }); + it('should await promise', async () => { + const { page } = getTestState(); + + const result = await page.evaluate(() => Promise.resolve(8 * 7)); + expect(result).toBe(56); + }); + it('should work right after framenavigated', async () => { + const { page, server } = getTestState(); + + let frameEvaluation = null; + page.on('framenavigated', async (frame) => { + frameEvaluation = frame.evaluate(() => 6 * 7); + }); + await page.goto(server.EMPTY_PAGE); + expect(await frameEvaluation).toBe(42); + }); + it('should work from-inside an exposed function', async () => { + const { page } = getTestState(); + + // Setup inpage callback, which calls Page.evaluate + await page.exposeFunction('callController', async function (a, b) { + return await page.evaluate<(a: number, b: number) => number>( + (a, b) => a * b, + a, + b + ); + }); + const result = await page.evaluate(async function () { + return await globalThis.callController(9, 3); + }); + expect(result).toBe(27); + }); + it('should reject promise with exception', async () => { + const { page } = getTestState(); + + let error = null; + await page + // @ts-expect-error we know the object doesn't exist + .evaluate(() => notExistingObject.property) + .catch((error_) => (error = error_)); + expect(error).toBeTruthy(); + expect(error.message).toContain('notExistingObject'); + }); + it('should support thrown strings as error messages', async () => { + const { page } = getTestState(); + + let error = null; + await page + .evaluate(() => { + throw 'qwerty'; + }) + .catch((error_) => (error = error_)); + expect(error).toBeTruthy(); + expect(error.message).toContain('qwerty'); + }); + it('should support thrown numbers as error messages', async () => { + const { page } = getTestState(); + + let error = null; + await page + .evaluate(() => { + throw 100500; + }) + .catch((error_) => (error = error_)); + expect(error).toBeTruthy(); + expect(error.message).toContain('100500'); + }); + it('should return complex objects', async () => { + const { page } = getTestState(); + + const object = { foo: 'bar!' }; + const result = await page.evaluate((a) => a, object); + expect(result).not.toBe(object); + expect(result).toEqual(object); + }); + (bigint ? it : xit)('should return BigInt', async () => { + const { page } = getTestState(); + + const result = await page.evaluate(() => BigInt(42)); + expect(result).toBe(BigInt(42)); + }); + it('should return NaN', async () => { + const { page } = getTestState(); + + const result = await page.evaluate(() => NaN); + expect(Object.is(result, NaN)).toBe(true); + }); + it('should return -0', async () => { + const { page } = getTestState(); + + const result = await page.evaluate(() => -0); + expect(Object.is(result, -0)).toBe(true); + }); + it('should return Infinity', async () => { + const { page } = getTestState(); + + const result = await page.evaluate(() => Infinity); + expect(Object.is(result, Infinity)).toBe(true); + }); + it('should return -Infinity', async () => { + const { page } = getTestState(); + + const result = await page.evaluate(() => -Infinity); + expect(Object.is(result, -Infinity)).toBe(true); + }); + it('should accept "undefined" as one of multiple parameters', async () => { + const { page } = getTestState(); + + const result = await page.evaluate( + (a, b) => Object.is(a, undefined) && Object.is(b, 'foo'), + undefined, + 'foo' + ); + expect(result).toBe(true); + }); + it('should properly serialize null fields', async () => { + const { page } = getTestState(); + + expect(await page.evaluate(() => ({ a: undefined }))).toEqual({}); + }); + it( + 'should return undefined for non-serializable objects', + async () => { + const { page } = getTestState(); + + expect(await page.evaluate(() => window)).toBe(undefined); + } + ); + it('should fail for circular object', async () => { + const { page } = getTestState(); + + const result = await page.evaluate(() => { + const a: { [x: string]: any } = {}; + const b = { a }; + a.b = b; + return a; + }); + expect(result).toBe(undefined); + }); + it('should be able to throw a tricky error', async () => { + const { page } = getTestState(); + + const windowHandle = await page.evaluateHandle(() => window); + const errorText = await windowHandle + .jsonValue() + .catch((error_) => error_.message); + const error = await page + .evaluate<(errorText: string) => Error>((errorText) => { + throw new Error(errorText); + }, errorText) + .catch((error_) => error_); + expect(error.message).toContain(errorText); + }); + it('should accept a string', async () => { + const { page } = getTestState(); + + const result = await page.evaluate('1 + 2'); + expect(result).toBe(3); + }); + it('should accept a string with semi colons', async () => { + const { page } = getTestState(); + + const result = await page.evaluate('1 + 5;'); + expect(result).toBe(6); + }); + it('should accept a string with comments', async () => { + const { page } = getTestState(); + + const result = await page.evaluate('2 + 5;\n// do some math!'); + expect(result).toBe(7); + }); + it('should accept element handle as an argument', async () => { + const { page } = getTestState(); + + await page.setContent('<section>42</section>'); + const element = await page.$('section'); + const text = await page.evaluate<(e: HTMLElement) => string>( + (e) => e.textContent, + element + ); + expect(text).toBe('42'); + }); + it('should throw if underlying element was disposed', async () => { + const { page } = getTestState(); + + await page.setContent('<section>39</section>'); + const element = await page.$('section'); + expect(element).toBeTruthy(); + await element.dispose(); + let error = null; + await page + .evaluate((e: HTMLElement) => e.textContent, element) + .catch((error_) => (error = error_)); + expect(error.message).toContain('JSHandle is disposed'); + }); + it( + 'should throw if elementHandles are from other frames', + async () => { + const { page, server } = getTestState(); + + await utils.attachFrame(page, 'frame1', server.EMPTY_PAGE); + const bodyHandle = await page.frames()[1].$('body'); + let error = null; + await page + .evaluate((body: HTMLElement) => body.innerHTML, bodyHandle) + .catch((error_) => (error = error_)); + expect(error).toBeTruthy(); + expect(error.message).toContain( + 'JSHandles can be evaluated only in the context they were created' + ); + } + ); + it('should simulate a user gesture', async () => { + const { page } = getTestState(); + + const result = await page.evaluate(() => { + document.body.appendChild(document.createTextNode('test')); + document.execCommand('selectAll'); + return document.execCommand('copy'); + }); + expect(result).toBe(true); + }); + it('should throw a nice error after a navigation', async () => { + const { page } = getTestState(); + + const executionContext = await page.mainFrame().executionContext(); + + await Promise.all([ + page.waitForNavigation(), + executionContext.evaluate(() => window.location.reload()), + ]); + const error = await executionContext + .evaluate(() => null) + .catch((error_) => error_); + expect((error as Error).message).toContain('navigation'); + }); + it( + 'should not throw an error when evaluation does a navigation', + async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/one-style.html'); + const result = await page.evaluate(() => { + (window as any).location = '/empty.html'; + return [42]; + }); + expect(result).toEqual([42]); + } + ); + it('should transfer 100Mb of data from page to node.js', async function () { + const { page } = getTestState(); + + const a = await page.evaluate<() => string>(() => + Array(100 * 1024 * 1024 + 1).join('a') + ); + expect(a.length).toBe(100 * 1024 * 1024); + }); + it('should throw error with detailed information on exception inside promise ', async () => { + const { page } = getTestState(); + + let error = null; + await page + .evaluate( + () => + new Promise(() => { + throw new Error('Error in promise'); + }) + ) + .catch((error_) => (error = error_)); + expect(error.message).toContain('Error in promise'); + }); + }); + + describe('Page.evaluateOnNewDocument', function () { + it('should evaluate before anything else on the page', async () => { + const { page, server } = getTestState(); + + await page.evaluateOnNewDocument(function () { + globalThis.injected = 123; + }); + await page.goto(server.PREFIX + '/tamperable.html'); + expect(await page.evaluate(() => globalThis.result)).toBe(123); + }); + it('should work with CSP', async () => { + const { page, server } = getTestState(); + + server.setCSP('/empty.html', 'script-src ' + server.PREFIX); + await page.evaluateOnNewDocument(function () { + globalThis.injected = 123; + }); + await page.goto(server.PREFIX + '/empty.html'); + expect(await page.evaluate(() => globalThis.injected)).toBe(123); + + // Make sure CSP works. + await page + .addScriptTag({ content: 'window.e = 10;' }) + .catch((error) => void error); + expect(await page.evaluate(() => (window as any).e)).toBe(undefined); + }); + }); + + describe('Frame.evaluate', function () { + it('should have different execution contexts', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + await utils.attachFrame(page, 'frame1', server.EMPTY_PAGE); + expect(page.frames().length).toBe(2); + await page.frames()[0].evaluate(() => (globalThis.FOO = 'foo')); + await page.frames()[1].evaluate(() => (globalThis.FOO = 'bar')); + expect(await page.frames()[0].evaluate(() => globalThis.FOO)).toBe('foo'); + expect(await page.frames()[1].evaluate(() => globalThis.FOO)).toBe('bar'); + }); + it('should have correct execution contexts', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/frames/one-frame.html'); + expect(page.frames().length).toBe(2); + expect( + await page.frames()[0].evaluate(() => document.body.textContent.trim()) + ).toBe(''); + expect( + await page.frames()[1].evaluate(() => document.body.textContent.trim()) + ).toBe(`Hi, I'm frame`); + }); + it('should execute after cross-site navigation', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + const mainFrame = page.mainFrame(); + expect(await mainFrame.evaluate(() => window.location.href)).toContain( + 'localhost' + ); + await page.goto(server.CROSS_PROCESS_PREFIX + '/empty.html'); + expect(await mainFrame.evaluate(() => window.location.href)).toContain( + '127' + ); + }); + }); +}); diff --git a/remote/test/puppeteer/test/fixtures.spec.ts b/remote/test/puppeteer/test/fixtures.spec.ts new file mode 100644 index 0000000000..8eca362071 --- /dev/null +++ b/remote/test/puppeteer/test/fixtures.spec.ts @@ -0,0 +1,93 @@ +/** + * Copyright 2019 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. + */ + +/* eslint-disable @typescript-eslint/no-var-requires */ + +import expect from 'expect'; +import { getTestState, itChromeOnly } from './mocha-utils'; // eslint-disable-line import/extensions + +import path from 'path'; + +describe('Fixtures', function () { + itChromeOnly('dumpio option should work with pipe option ', async () => { + const { defaultBrowserOptions, puppeteerPath } = getTestState(); + + let dumpioData = ''; + const { spawn } = require('child_process'); + const options = Object.assign({}, defaultBrowserOptions, { + pipe: true, + dumpio: true, + }); + const res = spawn('node', [ + path.join(__dirname, 'fixtures', 'dumpio.js'), + puppeteerPath, + JSON.stringify(options), + ]); + res.stderr.on('data', (data) => (dumpioData += data.toString('utf8'))); + await new Promise((resolve) => res.on('close', resolve)); + expect(dumpioData).toContain('message from dumpio'); + }); + it('should dump browser process stderr', async () => { + const { defaultBrowserOptions, puppeteerPath } = getTestState(); + + let dumpioData = ''; + const { spawn } = require('child_process'); + const options = Object.assign({}, defaultBrowserOptions, { dumpio: true }); + const res = spawn('node', [ + path.join(__dirname, 'fixtures', 'dumpio.js'), + puppeteerPath, + JSON.stringify(options), + ]); + res.stderr.on('data', (data) => (dumpioData += data.toString('utf8'))); + await new Promise((resolve) => res.on('close', resolve)); + expect(dumpioData).toContain('DevTools listening on ws://'); + }); + it('should close the browser when the node process closes', async () => { + const { defaultBrowserOptions, puppeteerPath, puppeteer } = getTestState(); + + const { spawn, execSync } = require('child_process'); + const options = Object.assign({}, defaultBrowserOptions, { + // Disable DUMPIO to cleanly read stdout. + dumpio: false, + }); + const res = spawn('node', [ + path.join(__dirname, 'fixtures', 'closeme.js'), + puppeteerPath, + JSON.stringify(options), + ]); + let wsEndPointCallback; + const wsEndPointPromise = new Promise<string>( + (x) => (wsEndPointCallback = x) + ); + let output = ''; + res.stdout.on('data', (data) => { + output += data; + if (output.indexOf('\n')) + wsEndPointCallback(output.substring(0, output.indexOf('\n'))); + }); + const browser = await puppeteer.connect({ + browserWSEndpoint: await wsEndPointPromise, + }); + const promises = [ + new Promise((resolve) => browser.once('disconnected', resolve)), + new Promise((resolve) => res.on('close', resolve)), + ]; + if (process.platform === 'win32') + execSync(`taskkill /pid ${res.pid} /T /F`); + else process.kill(res.pid); + await Promise.all(promises); + }); +}); diff --git a/remote/test/puppeteer/test/fixtures/closeme.js b/remote/test/puppeteer/test/fixtures/closeme.js new file mode 100644 index 0000000000..dbe798f70d --- /dev/null +++ b/remote/test/puppeteer/test/fixtures/closeme.js @@ -0,0 +1,5 @@ +(async () => { + const [, , puppeteerRoot, options] = process.argv; + const browser = await require(puppeteerRoot).launch(JSON.parse(options)); + console.log(browser.wsEndpoint()); +})(); diff --git a/remote/test/puppeteer/test/fixtures/dumpio.js b/remote/test/puppeteer/test/fixtures/dumpio.js new file mode 100644 index 0000000000..40b9714f6c --- /dev/null +++ b/remote/test/puppeteer/test/fixtures/dumpio.js @@ -0,0 +1,8 @@ +(async () => { + const [, , puppeteerRoot, options] = process.argv; + const browser = await require(puppeteerRoot).launch(JSON.parse(options)); + const page = await browser.newPage(); + await page.evaluate(() => console.error('message from dumpio')); + await page.close(); + await browser.close(); +})(); diff --git a/remote/test/puppeteer/test/frame.spec.ts b/remote/test/puppeteer/test/frame.spec.ts new file mode 100644 index 0000000000..6f226c12ea --- /dev/null +++ b/remote/test/puppeteer/test/frame.spec.ts @@ -0,0 +1,269 @@ +/** + * 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. + */ + +import utils from './utils.js'; +import expect from 'expect'; +import { + getTestState, + setupTestBrowserHooks, + setupTestPageAndContextHooks, +} from './mocha-utils'; // eslint-disable-line import/extensions + +describe('Frame specs', function () { + setupTestBrowserHooks(); + setupTestPageAndContextHooks(); + + describe('Frame.executionContext', function () { + it('should work', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + await utils.attachFrame(page, 'frame1', server.EMPTY_PAGE); + expect(page.frames().length).toBe(2); + const [frame1, frame2] = page.frames(); + const context1 = await frame1.executionContext(); + const context2 = await frame2.executionContext(); + expect(context1).toBeTruthy(); + expect(context2).toBeTruthy(); + expect(context1 !== context2).toBeTruthy(); + expect(context1.frame()).toBe(frame1); + expect(context2.frame()).toBe(frame2); + + await Promise.all([ + context1.evaluate(() => (globalThis.a = 1)), + context2.evaluate(() => (globalThis.a = 2)), + ]); + const [a1, a2] = await Promise.all([ + context1.evaluate(() => globalThis.a), + context2.evaluate(() => globalThis.a), + ]); + expect(a1).toBe(1); + expect(a2).toBe(2); + }); + }); + + describe('Frame.evaluateHandle', function () { + it('should work', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + const mainFrame = page.mainFrame(); + const windowHandle = await mainFrame.evaluateHandle(() => window); + expect(windowHandle).toBeTruthy(); + }); + }); + + describe('Frame.evaluate', function () { + it('should throw for detached frames', async () => { + const { page, server } = getTestState(); + + const frame1 = await utils.attachFrame(page, 'frame1', server.EMPTY_PAGE); + await utils.detachFrame(page, 'frame1'); + let error = null; + await frame1.evaluate(() => 7 * 8).catch((error_) => (error = error_)); + expect(error.message).toContain( + 'Execution context is not available in detached frame' + ); + }); + }); + + describe('Frame Management', function () { + it('should handle nested frames', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/frames/nested-frames.html'); + expect(utils.dumpFrames(page.mainFrame())).toEqual([ + 'http://localhost:<PORT>/frames/nested-frames.html', + ' http://localhost:<PORT>/frames/two-frames.html (2frames)', + ' http://localhost:<PORT>/frames/frame.html (uno)', + ' http://localhost:<PORT>/frames/frame.html (dos)', + ' http://localhost:<PORT>/frames/frame.html (aframe)', + ]); + }); + it( + 'should send events when frames are manipulated dynamically', + async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + // validate frameattached events + const attachedFrames = []; + page.on('frameattached', (frame) => attachedFrames.push(frame)); + await utils.attachFrame(page, 'frame1', './assets/frame.html'); + expect(attachedFrames.length).toBe(1); + expect(attachedFrames[0].url()).toContain('/assets/frame.html'); + + // validate framenavigated events + const navigatedFrames = []; + page.on('framenavigated', (frame) => navigatedFrames.push(frame)); + await utils.navigateFrame(page, 'frame1', './empty.html'); + expect(navigatedFrames.length).toBe(1); + expect(navigatedFrames[0].url()).toBe(server.EMPTY_PAGE); + + // validate framedetached events + const detachedFrames = []; + page.on('framedetached', (frame) => detachedFrames.push(frame)); + await utils.detachFrame(page, 'frame1'); + expect(detachedFrames.length).toBe(1); + expect(detachedFrames[0].isDetached()).toBe(true); + } + ); + it( + 'should send "framenavigated" when navigating on anchor URLs', + async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + await Promise.all([ + page.goto(server.EMPTY_PAGE + '#foo'), + utils.waitEvent(page, 'framenavigated'), + ]); + expect(page.url()).toBe(server.EMPTY_PAGE + '#foo'); + } + ); + it('should persist mainFrame on cross-process navigation', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + const mainFrame = page.mainFrame(); + await page.goto(server.CROSS_PROCESS_PREFIX + '/empty.html'); + expect(page.mainFrame() === mainFrame).toBeTruthy(); + }); + it('should not send attach/detach events for main frame', async () => { + const { page, server } = getTestState(); + + let hasEvents = false; + page.on('frameattached', () => (hasEvents = true)); + page.on('framedetached', () => (hasEvents = true)); + await page.goto(server.EMPTY_PAGE); + expect(hasEvents).toBe(false); + }); + it('should detach child frames on navigation', async () => { + const { page, server } = getTestState(); + + let attachedFrames = []; + let detachedFrames = []; + let navigatedFrames = []; + page.on('frameattached', (frame) => attachedFrames.push(frame)); + page.on('framedetached', (frame) => detachedFrames.push(frame)); + page.on('framenavigated', (frame) => navigatedFrames.push(frame)); + await page.goto(server.PREFIX + '/frames/nested-frames.html'); + expect(attachedFrames.length).toBe(4); + expect(detachedFrames.length).toBe(0); + expect(navigatedFrames.length).toBe(5); + + attachedFrames = []; + detachedFrames = []; + navigatedFrames = []; + await page.goto(server.EMPTY_PAGE); + expect(attachedFrames.length).toBe(0); + expect(detachedFrames.length).toBe(4); + expect(navigatedFrames.length).toBe(1); + }); + it('should support framesets', async () => { + const { page, server } = getTestState(); + + let attachedFrames = []; + let detachedFrames = []; + let navigatedFrames = []; + page.on('frameattached', (frame) => attachedFrames.push(frame)); + page.on('framedetached', (frame) => detachedFrames.push(frame)); + page.on('framenavigated', (frame) => navigatedFrames.push(frame)); + await page.goto(server.PREFIX + '/frames/frameset.html'); + expect(attachedFrames.length).toBe(4); + expect(detachedFrames.length).toBe(0); + expect(navigatedFrames.length).toBe(5); + + attachedFrames = []; + detachedFrames = []; + navigatedFrames = []; + await page.goto(server.EMPTY_PAGE); + expect(attachedFrames.length).toBe(0); + expect(detachedFrames.length).toBe(4); + expect(navigatedFrames.length).toBe(1); + }); + it('should report frame from-inside shadow DOM', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/shadow.html'); + await page.evaluate(async (url: string) => { + const frame = document.createElement('iframe'); + frame.src = url; + document.body.shadowRoot.appendChild(frame); + await new Promise((x) => (frame.onload = x)); + }, server.EMPTY_PAGE); + expect(page.frames().length).toBe(2); + expect(page.frames()[1].url()).toBe(server.EMPTY_PAGE); + }); + it('should report frame.name()', async () => { + const { page, server } = getTestState(); + + await utils.attachFrame(page, 'theFrameId', server.EMPTY_PAGE); + await page.evaluate((url: string) => { + const frame = document.createElement('iframe'); + frame.name = 'theFrameName'; + frame.src = url; + document.body.appendChild(frame); + return new Promise((x) => (frame.onload = x)); + }, server.EMPTY_PAGE); + expect(page.frames()[0].name()).toBe(''); + expect(page.frames()[1].name()).toBe('theFrameId'); + expect(page.frames()[2].name()).toBe('theFrameName'); + }); + it('should report frame.parent()', async () => { + const { page, server } = getTestState(); + + await utils.attachFrame(page, 'frame1', server.EMPTY_PAGE); + await utils.attachFrame(page, 'frame2', server.EMPTY_PAGE); + expect(page.frames()[0].parentFrame()).toBe(null); + expect(page.frames()[1].parentFrame()).toBe(page.mainFrame()); + expect(page.frames()[2].parentFrame()).toBe(page.mainFrame()); + }); + it( + 'should report different frame instance when frame re-attaches', + async () => { + const { page, server } = getTestState(); + + const frame1 = await utils.attachFrame( + page, + 'frame1', + server.EMPTY_PAGE + ); + await page.evaluate(() => { + globalThis.frame = document.querySelector('#frame1'); + globalThis.frame.remove(); + }); + expect(frame1.isDetached()).toBe(true); + const [frame2] = await Promise.all([ + utils.waitEvent(page, 'frameattached'), + page.evaluate(() => document.body.appendChild(globalThis.frame)), + ]); + expect(frame2.isDetached()).toBe(false); + expect(frame1).not.toBe(frame2); + } + ); + it('should support url fragment', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/frames/one-frame-url-fragment.html'); + + expect(page.frames().length).toBe(2); + expect(page.frames()[1].url()).toBe( + server.PREFIX + '/frames/frame.html?param=value#fragment' + ); + }); + }); +}); diff --git a/remote/test/puppeteer/test/golden-chromium/csscoverage-involved.txt b/remote/test/puppeteer/test/golden-chromium/csscoverage-involved.txt new file mode 100644 index 0000000000..9b851d0bd3 --- /dev/null +++ b/remote/test/puppeteer/test/golden-chromium/csscoverage-involved.txt @@ -0,0 +1,16 @@ +[ + { + "url": "http://localhost:<PORT>/csscoverage/involved.html", + "ranges": [ + { + "start": 149, + "end": 297 + }, + { + "start": 327, + "end": 433 + } + ], + "text": "\n@charset \"utf-8\";\n@namespace svg url(http://www.w3.org/2000/svg);\n@font-face {\n font-family: \"Example Font\";\n src: url(\"./Dosis-Regular.ttf\");\n}\n\n#fluffy {\n border: 1px solid black;\n z-index: 1;\n /* -webkit-disabled-property: rgb(1, 2, 3) */\n -lol-cats: \"dogs\" /* non-existing property */\n}\n\n@media (min-width: 1px) {\n span {\n -webkit-border-radius: 10px;\n font-family: \"Example Font\";\n animation: 1s identifier;\n }\n}\n" + } +]
\ No newline at end of file diff --git a/remote/test/puppeteer/test/golden-chromium/grid-cell-0.png b/remote/test/puppeteer/test/golden-chromium/grid-cell-0.png Binary files differnew file mode 100644 index 0000000000..ff282e989b --- /dev/null +++ b/remote/test/puppeteer/test/golden-chromium/grid-cell-0.png diff --git a/remote/test/puppeteer/test/golden-chromium/grid-cell-1.png b/remote/test/puppeteer/test/golden-chromium/grid-cell-1.png Binary files differnew file mode 100644 index 0000000000..91a1cb8510 --- /dev/null +++ b/remote/test/puppeteer/test/golden-chromium/grid-cell-1.png diff --git a/remote/test/puppeteer/test/golden-chromium/grid-cell-2.png b/remote/test/puppeteer/test/golden-chromium/grid-cell-2.png Binary files differnew file mode 100644 index 0000000000..7b01753b6a --- /dev/null +++ b/remote/test/puppeteer/test/golden-chromium/grid-cell-2.png diff --git a/remote/test/puppeteer/test/golden-chromium/grid-cell-3.png b/remote/test/puppeteer/test/golden-chromium/grid-cell-3.png Binary files differnew file mode 100644 index 0000000000..b9b8b2922b --- /dev/null +++ b/remote/test/puppeteer/test/golden-chromium/grid-cell-3.png diff --git a/remote/test/puppeteer/test/golden-chromium/jscoverage-involved.txt b/remote/test/puppeteer/test/golden-chromium/jscoverage-involved.txt new file mode 100644 index 0000000000..6f28e1580e --- /dev/null +++ b/remote/test/puppeteer/test/golden-chromium/jscoverage-involved.txt @@ -0,0 +1,28 @@ +[ + { + "url": "http://localhost:<PORT>/jscoverage/involved.html", + "ranges": [ + { + "start": 0, + "end": 35 + }, + { + "start": 50, + "end": 100 + }, + { + "start": 107, + "end": 141 + }, + { + "start": 148, + "end": 160 + }, + { + "start": 168, + "end": 207 + } + ], + "text": "\nfunction foo() {\n if (1 > 2)\n console.log(1);\n if (1 < 2)\n console.log(2);\n let x = 1 > 2 ? 'foo' : 'bar';\n let y = 1 < 2 ? 'foo' : 'bar';\n let z = () => {};\n let q = () => {};\n q();\n}\n\nfoo();\n" + } +]
\ No newline at end of file diff --git a/remote/test/puppeteer/test/golden-chromium/mock-binary-response.png b/remote/test/puppeteer/test/golden-chromium/mock-binary-response.png Binary files differnew file mode 100644 index 0000000000..8595e0598e --- /dev/null +++ b/remote/test/puppeteer/test/golden-chromium/mock-binary-response.png diff --git a/remote/test/puppeteer/test/golden-chromium/screenshot-clip-odd-size.png b/remote/test/puppeteer/test/golden-chromium/screenshot-clip-odd-size.png Binary files differnew file mode 100644 index 0000000000..b010d1f87f --- /dev/null +++ b/remote/test/puppeteer/test/golden-chromium/screenshot-clip-odd-size.png diff --git a/remote/test/puppeteer/test/golden-chromium/screenshot-clip-rect.png b/remote/test/puppeteer/test/golden-chromium/screenshot-clip-rect.png Binary files differnew file mode 100644 index 0000000000..ac23b7de50 --- /dev/null +++ b/remote/test/puppeteer/test/golden-chromium/screenshot-clip-rect.png diff --git a/remote/test/puppeteer/test/golden-chromium/screenshot-element-bounding-box.png b/remote/test/puppeteer/test/golden-chromium/screenshot-element-bounding-box.png Binary files differnew file mode 100644 index 0000000000..32e05bf05b --- /dev/null +++ b/remote/test/puppeteer/test/golden-chromium/screenshot-element-bounding-box.png diff --git a/remote/test/puppeteer/test/golden-chromium/screenshot-element-fractional-offset.png b/remote/test/puppeteer/test/golden-chromium/screenshot-element-fractional-offset.png Binary files differnew file mode 100644 index 0000000000..cc8669d598 --- /dev/null +++ b/remote/test/puppeteer/test/golden-chromium/screenshot-element-fractional-offset.png diff --git a/remote/test/puppeteer/test/golden-chromium/screenshot-element-fractional.png b/remote/test/puppeteer/test/golden-chromium/screenshot-element-fractional.png Binary files differnew file mode 100644 index 0000000000..35c53377f9 --- /dev/null +++ b/remote/test/puppeteer/test/golden-chromium/screenshot-element-fractional.png diff --git a/remote/test/puppeteer/test/golden-chromium/screenshot-element-larger-than-viewport.png b/remote/test/puppeteer/test/golden-chromium/screenshot-element-larger-than-viewport.png Binary files differnew file mode 100644 index 0000000000..5fcdb92355 --- /dev/null +++ b/remote/test/puppeteer/test/golden-chromium/screenshot-element-larger-than-viewport.png diff --git a/remote/test/puppeteer/test/golden-chromium/screenshot-element-padding-border.png b/remote/test/puppeteer/test/golden-chromium/screenshot-element-padding-border.png Binary files differnew file mode 100644 index 0000000000..917dd48188 --- /dev/null +++ b/remote/test/puppeteer/test/golden-chromium/screenshot-element-padding-border.png diff --git a/remote/test/puppeteer/test/golden-chromium/screenshot-element-rotate.png b/remote/test/puppeteer/test/golden-chromium/screenshot-element-rotate.png Binary files differnew file mode 100644 index 0000000000..52e2a0f6d3 --- /dev/null +++ b/remote/test/puppeteer/test/golden-chromium/screenshot-element-rotate.png diff --git a/remote/test/puppeteer/test/golden-chromium/screenshot-element-scrolled-into-view.png b/remote/test/puppeteer/test/golden-chromium/screenshot-element-scrolled-into-view.png Binary files differnew file mode 100644 index 0000000000..917dd48188 --- /dev/null +++ b/remote/test/puppeteer/test/golden-chromium/screenshot-element-scrolled-into-view.png diff --git a/remote/test/puppeteer/test/golden-chromium/screenshot-grid-fullpage.png b/remote/test/puppeteer/test/golden-chromium/screenshot-grid-fullpage.png Binary files differnew file mode 100644 index 0000000000..d6d38217f7 --- /dev/null +++ b/remote/test/puppeteer/test/golden-chromium/screenshot-grid-fullpage.png diff --git a/remote/test/puppeteer/test/golden-chromium/screenshot-offscreen-clip.png b/remote/test/puppeteer/test/golden-chromium/screenshot-offscreen-clip.png Binary files differnew file mode 100644 index 0000000000..31a0935cda --- /dev/null +++ b/remote/test/puppeteer/test/golden-chromium/screenshot-offscreen-clip.png diff --git a/remote/test/puppeteer/test/golden-chromium/screenshot-sanity.png b/remote/test/puppeteer/test/golden-chromium/screenshot-sanity.png Binary files differnew file mode 100644 index 0000000000..ecab61fe17 --- /dev/null +++ b/remote/test/puppeteer/test/golden-chromium/screenshot-sanity.png diff --git a/remote/test/puppeteer/test/golden-chromium/transparent.png b/remote/test/puppeteer/test/golden-chromium/transparent.png Binary files differnew file mode 100644 index 0000000000..1cf45d8688 --- /dev/null +++ b/remote/test/puppeteer/test/golden-chromium/transparent.png diff --git a/remote/test/puppeteer/test/golden-chromium/vision-deficiency-achromatopsia.png b/remote/test/puppeteer/test/golden-chromium/vision-deficiency-achromatopsia.png Binary files differnew file mode 100644 index 0000000000..4d74aac44c --- /dev/null +++ b/remote/test/puppeteer/test/golden-chromium/vision-deficiency-achromatopsia.png diff --git a/remote/test/puppeteer/test/golden-chromium/vision-deficiency-blurredVision.png b/remote/test/puppeteer/test/golden-chromium/vision-deficiency-blurredVision.png Binary files differnew file mode 100644 index 0000000000..d89858ef53 --- /dev/null +++ b/remote/test/puppeteer/test/golden-chromium/vision-deficiency-blurredVision.png diff --git a/remote/test/puppeteer/test/golden-chromium/vision-deficiency-deuteranopia.png b/remote/test/puppeteer/test/golden-chromium/vision-deficiency-deuteranopia.png Binary files differnew file mode 100644 index 0000000000..79b4b0fa1b --- /dev/null +++ b/remote/test/puppeteer/test/golden-chromium/vision-deficiency-deuteranopia.png diff --git a/remote/test/puppeteer/test/golden-chromium/vision-deficiency-protanopia.png b/remote/test/puppeteer/test/golden-chromium/vision-deficiency-protanopia.png Binary files differnew file mode 100644 index 0000000000..bede7c1ed0 --- /dev/null +++ b/remote/test/puppeteer/test/golden-chromium/vision-deficiency-protanopia.png diff --git a/remote/test/puppeteer/test/golden-chromium/vision-deficiency-tritanopia.png b/remote/test/puppeteer/test/golden-chromium/vision-deficiency-tritanopia.png Binary files differnew file mode 100644 index 0000000000..d5f6bbec2e --- /dev/null +++ b/remote/test/puppeteer/test/golden-chromium/vision-deficiency-tritanopia.png diff --git a/remote/test/puppeteer/test/golden-chromium/white.jpg b/remote/test/puppeteer/test/golden-chromium/white.jpg Binary files differnew file mode 100644 index 0000000000..fb9070def3 --- /dev/null +++ b/remote/test/puppeteer/test/golden-chromium/white.jpg diff --git a/remote/test/puppeteer/test/golden-firefox/grid-cell-0.png b/remote/test/puppeteer/test/golden-firefox/grid-cell-0.png Binary files differnew file mode 100644 index 0000000000..4677bdbc4f --- /dev/null +++ b/remote/test/puppeteer/test/golden-firefox/grid-cell-0.png diff --git a/remote/test/puppeteer/test/golden-firefox/grid-cell-1.png b/remote/test/puppeteer/test/golden-firefox/grid-cell-1.png Binary files differnew file mode 100644 index 0000000000..532dc8db65 --- /dev/null +++ b/remote/test/puppeteer/test/golden-firefox/grid-cell-1.png diff --git a/remote/test/puppeteer/test/golden-firefox/screenshot-clip-odd-size.png b/remote/test/puppeteer/test/golden-firefox/screenshot-clip-odd-size.png Binary files differnew file mode 100644 index 0000000000..8e86dc9017 --- /dev/null +++ b/remote/test/puppeteer/test/golden-firefox/screenshot-clip-odd-size.png diff --git a/remote/test/puppeteer/test/golden-firefox/screenshot-clip-rect.png b/remote/test/puppeteer/test/golden-firefox/screenshot-clip-rect.png Binary files differnew file mode 100644 index 0000000000..7a74457869 --- /dev/null +++ b/remote/test/puppeteer/test/golden-firefox/screenshot-clip-rect.png diff --git a/remote/test/puppeteer/test/golden-firefox/screenshot-element-bounding-box.png b/remote/test/puppeteer/test/golden-firefox/screenshot-element-bounding-box.png Binary files differnew file mode 100644 index 0000000000..f4e059c300 --- /dev/null +++ b/remote/test/puppeteer/test/golden-firefox/screenshot-element-bounding-box.png diff --git a/remote/test/puppeteer/test/golden-firefox/screenshot-element-fractional-offset.png b/remote/test/puppeteer/test/golden-firefox/screenshot-element-fractional-offset.png Binary files differnew file mode 100644 index 0000000000..f554b1d62c --- /dev/null +++ b/remote/test/puppeteer/test/golden-firefox/screenshot-element-fractional-offset.png diff --git a/remote/test/puppeteer/test/golden-firefox/screenshot-element-fractional.png b/remote/test/puppeteer/test/golden-firefox/screenshot-element-fractional.png Binary files differnew file mode 100644 index 0000000000..d1431bd91d --- /dev/null +++ b/remote/test/puppeteer/test/golden-firefox/screenshot-element-fractional.png diff --git a/remote/test/puppeteer/test/golden-firefox/screenshot-element-larger-than-viewport.png b/remote/test/puppeteer/test/golden-firefox/screenshot-element-larger-than-viewport.png Binary files differnew file mode 100644 index 0000000000..6d28cddcea --- /dev/null +++ b/remote/test/puppeteer/test/golden-firefox/screenshot-element-larger-than-viewport.png diff --git a/remote/test/puppeteer/test/golden-firefox/screenshot-element-padding-border.png b/remote/test/puppeteer/test/golden-firefox/screenshot-element-padding-border.png Binary files differnew file mode 100644 index 0000000000..2b72c7528b --- /dev/null +++ b/remote/test/puppeteer/test/golden-firefox/screenshot-element-padding-border.png diff --git a/remote/test/puppeteer/test/golden-firefox/screenshot-element-rotate.png b/remote/test/puppeteer/test/golden-firefox/screenshot-element-rotate.png Binary files differnew file mode 100644 index 0000000000..0a78fb1ae7 --- /dev/null +++ b/remote/test/puppeteer/test/golden-firefox/screenshot-element-rotate.png diff --git a/remote/test/puppeteer/test/golden-firefox/screenshot-element-scrolled-into-view.png b/remote/test/puppeteer/test/golden-firefox/screenshot-element-scrolled-into-view.png Binary files differnew file mode 100644 index 0000000000..2b72c7528b --- /dev/null +++ b/remote/test/puppeteer/test/golden-firefox/screenshot-element-scrolled-into-view.png diff --git a/remote/test/puppeteer/test/golden-firefox/screenshot-grid-fullpage.png b/remote/test/puppeteer/test/golden-firefox/screenshot-grid-fullpage.png Binary files differnew file mode 100644 index 0000000000..ac47ec83b1 --- /dev/null +++ b/remote/test/puppeteer/test/golden-firefox/screenshot-grid-fullpage.png diff --git a/remote/test/puppeteer/test/golden-firefox/screenshot-offscreen-clip.png b/remote/test/puppeteer/test/golden-firefox/screenshot-offscreen-clip.png Binary files differnew file mode 100644 index 0000000000..31a0935cda --- /dev/null +++ b/remote/test/puppeteer/test/golden-firefox/screenshot-offscreen-clip.png diff --git a/remote/test/puppeteer/test/golden-firefox/screenshot-sanity.png b/remote/test/puppeteer/test/golden-firefox/screenshot-sanity.png Binary files differnew file mode 100644 index 0000000000..07890a04b3 --- /dev/null +++ b/remote/test/puppeteer/test/golden-firefox/screenshot-sanity.png diff --git a/remote/test/puppeteer/test/golden-utils.js b/remote/test/puppeteer/test/golden-utils.js new file mode 100644 index 0000000000..f820afe6bf --- /dev/null +++ b/remote/test/puppeteer/test/golden-utils.js @@ -0,0 +1,160 @@ +// @ts-nocheck +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +const path = require('path'); +const fs = require('fs'); +const Diff = require('text-diff'); +const mime = require('mime'); +const PNG = require('pngjs').PNG; +const jpeg = require('jpeg-js'); +const pixelmatch = require('pixelmatch'); + +module.exports = { compare }; + +const GoldenComparators = { + 'image/png': compareImages, + 'image/jpeg': compareImages, + 'text/plain': compareText, +}; + +/** + * @param {?Object} actualBuffer + * @param {!Buffer} expectedBuffer + * @param {!string} mimeType + * @returns {?{diff: (!Object:undefined), errorMessage: (string|undefined)}} + */ +function compareImages(actualBuffer, expectedBuffer, mimeType) { + if (!actualBuffer || !(actualBuffer instanceof Buffer)) + return { errorMessage: 'Actual result should be Buffer.' }; + + const actual = + mimeType === 'image/png' + ? PNG.sync.read(actualBuffer) + : jpeg.decode(actualBuffer); + const expected = + mimeType === 'image/png' + ? PNG.sync.read(expectedBuffer) + : jpeg.decode(expectedBuffer); + if (expected.width !== actual.width || expected.height !== actual.height) { + return { + errorMessage: `Sizes differ: expected image ${expected.width}px X ${expected.height}px, but got ${actual.width}px X ${actual.height}px. `, + }; + } + const diff = new PNG({ width: expected.width, height: expected.height }); + const count = pixelmatch( + expected.data, + actual.data, + diff.data, + expected.width, + expected.height, + { threshold: 0.1 } + ); + return count > 0 ? { diff: PNG.sync.write(diff) } : null; +} + +/** + * @param {?Object} actual + * @param {!Buffer} expectedBuffer + * @returns {?{diff: (!Object:undefined), errorMessage: (string|undefined)}} + */ +function compareText(actual, expectedBuffer) { + if (typeof actual !== 'string') + return { errorMessage: 'Actual result should be string' }; + const expected = expectedBuffer.toString('utf-8'); + if (expected === actual) return null; + const diff = new Diff(); + const result = diff.main(expected, actual); + diff.cleanupSemantic(result); + let html = diff.prettyHtml(result); + const diffStylePath = path.join(__dirname, 'diffstyle.css'); + html = `<link rel="stylesheet" href="file://${diffStylePath}">` + html; + return { + diff: html, + diffExtension: '.html', + }; +} + +/** + * @param {?Object} actual + * @param {string} goldenName + * @returns {!{pass: boolean, message: (undefined|string)}} + */ +function compare(goldenPath, outputPath, actual, goldenName) { + goldenPath = path.normalize(goldenPath); + outputPath = path.normalize(outputPath); + const expectedPath = path.join(goldenPath, goldenName); + const actualPath = path.join(outputPath, goldenName); + + const messageSuffix = + 'Output is saved in "' + path.basename(outputPath + '" directory'); + + if (!fs.existsSync(expectedPath)) { + ensureOutputDir(); + fs.writeFileSync(actualPath, actual); + return { + pass: false, + message: goldenName + ' is missing in golden results. ' + messageSuffix, + }; + } + const expected = fs.readFileSync(expectedPath); + const mimeType = mime.getType(goldenName); + const comparator = GoldenComparators[mimeType]; + if (!comparator) { + return { + pass: false, + message: + 'Failed to find comparator with type ' + mimeType + ': ' + goldenName, + }; + } + const result = comparator(actual, expected, mimeType); + if (!result) return { pass: true }; + ensureOutputDir(); + if (goldenPath === outputPath) { + fs.writeFileSync(addSuffix(actualPath, '-actual'), actual); + } else { + fs.writeFileSync(actualPath, actual); + // Copy expected to the output/ folder for convenience. + fs.writeFileSync(addSuffix(actualPath, '-expected'), expected); + } + if (result.diff) { + const diffPath = addSuffix(actualPath, '-diff', result.diffExtension); + fs.writeFileSync(diffPath, result.diff); + } + + let message = goldenName + ' mismatch!'; + if (result.errorMessage) message += ' ' + result.errorMessage; + return { + pass: false, + message: message + ' ' + messageSuffix, + }; + + function ensureOutputDir() { + if (!fs.existsSync(outputPath)) fs.mkdirSync(outputPath); + } +} + +/** + * @param {string} filePath + * @param {string} suffix + * @param {string=} customExtension + * @returns {string} + */ +function addSuffix(filePath, suffix, customExtension) { + const dirname = path.dirname(filePath); + const ext = path.extname(filePath); + const name = path.basename(filePath, ext); + return path.join(dirname, name + suffix + (customExtension || ext)); +} diff --git a/remote/test/puppeteer/test/headful.spec.ts b/remote/test/puppeteer/test/headful.spec.ts new file mode 100644 index 0000000000..119823eb50 --- /dev/null +++ b/remote/test/puppeteer/test/headful.spec.ts @@ -0,0 +1,204 @@ +/** + * 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. + */ + +import path from 'path'; +import os from 'os'; +import fs from 'fs'; +import { promisify } from 'util'; +import expect from 'expect'; +import { + getTestState, + describeChromeOnly, + itFailsWindows, +} from './mocha-utils'; // eslint-disable-line import/extensions +import rimraf from 'rimraf'; + +const rmAsync = promisify(rimraf); +const mkdtempAsync = promisify(fs.mkdtemp); + +const TMP_FOLDER = path.join(os.tmpdir(), 'pptr_tmp_folder-'); + +const extensionPath = path.join(__dirname, 'assets', 'simple-extension'); + +describeChromeOnly('headful tests', function () { + /* These tests fire up an actual browser so let's + * allow a higher timeout + */ + this.timeout(20 * 1000); + + let headfulOptions; + let headlessOptions; + let extensionOptions; + + beforeEach(() => { + const { defaultBrowserOptions } = getTestState(); + headfulOptions = Object.assign({}, defaultBrowserOptions, { + headless: false, + }); + headlessOptions = Object.assign({}, defaultBrowserOptions, { + headless: true, + }); + + extensionOptions = Object.assign({}, defaultBrowserOptions, { + headless: false, + args: [ + `--disable-extensions-except=${extensionPath}`, + `--load-extension=${extensionPath}`, + ], + }); + }); + + describe('HEADFUL', function () { + it('background_page target type should be available', async () => { + const { puppeteer } = getTestState(); + const browserWithExtension = await puppeteer.launch(extensionOptions); + const page = await browserWithExtension.newPage(); + const backgroundPageTarget = await browserWithExtension.waitForTarget( + (target) => target.type() === 'background_page' + ); + await page.close(); + await browserWithExtension.close(); + expect(backgroundPageTarget).toBeTruthy(); + }); + it('target.page() should return a background_page', async function () { + const { puppeteer } = getTestState(); + const browserWithExtension = await puppeteer.launch(extensionOptions); + const backgroundPageTarget = await browserWithExtension.waitForTarget( + (target) => target.type() === 'background_page' + ); + const page = await backgroundPageTarget.page(); + expect(await page.evaluate(() => 2 * 3)).toBe(6); + expect(await page.evaluate(() => globalThis.MAGIC)).toBe(42); + await browserWithExtension.close(); + }); + it('should have default url when launching browser', async function () { + const { puppeteer } = getTestState(); + const browser = await puppeteer.launch(extensionOptions); + const pages = (await browser.pages()).map((page) => page.url()); + expect(pages).toEqual(['about:blank']); + await browser.close(); + }); + itFailsWindows( + 'headless should be able to read cookies written by headful', + async () => { + /* Needs investigation into why but this fails consistently on Windows CI. */ + const { server, puppeteer } = getTestState(); + + const userDataDir = await mkdtempAsync(TMP_FOLDER); + // Write a cookie in headful chrome + const headfulBrowser = await puppeteer.launch( + Object.assign({ userDataDir }, headfulOptions) + ); + const headfulPage = await headfulBrowser.newPage(); + await headfulPage.goto(server.EMPTY_PAGE); + await headfulPage.evaluate( + () => + (document.cookie = + 'foo=true; expires=Fri, 31 Dec 9999 23:59:59 GMT') + ); + await headfulBrowser.close(); + // Read the cookie from headless chrome + const headlessBrowser = await puppeteer.launch( + Object.assign({ userDataDir }, headlessOptions) + ); + const headlessPage = await headlessBrowser.newPage(); + await headlessPage.goto(server.EMPTY_PAGE); + const cookie = await headlessPage.evaluate(() => document.cookie); + await headlessBrowser.close(); + // This might throw. See https://github.com/puppeteer/puppeteer/issues/2778 + await rmAsync(userDataDir).catch(() => {}); + expect(cookie).toBe('foo=true'); + } + ); + // TODO: Support OOOPIF. @see https://github.com/puppeteer/puppeteer/issues/2548 + xit('OOPIF: should report google.com frame', async () => { + const { server, puppeteer } = getTestState(); + + // https://google.com is isolated by default in Chromium embedder. + const browser = await puppeteer.launch(headfulOptions); + const page = await browser.newPage(); + await page.goto(server.EMPTY_PAGE); + await page.setRequestInterception(true); + page.on('request', (r) => r.respond({ body: 'YO, GOOGLE.COM' })); + await page.evaluate(() => { + const frame = document.createElement('iframe'); + frame.setAttribute('src', 'https://google.com/'); + document.body.appendChild(frame); + return new Promise((x) => (frame.onload = x)); + }); + await page.waitForSelector('iframe[src="https://google.com/"]'); + const urls = page + .frames() + .map((frame) => frame.url()) + .sort(); + expect(urls).toEqual([server.EMPTY_PAGE, 'https://google.com/']); + await browser.close(); + }); + it('should close browser with beforeunload page', async () => { + const { server, puppeteer } = getTestState(); + + const browser = await puppeteer.launch(headfulOptions); + const page = await browser.newPage(); + await page.goto(server.PREFIX + '/beforeunload.html'); + // We have to interact with a page so that 'beforeunload' handlers + // fire. + await page.click('body'); + await browser.close(); + }); + it('should open devtools when "devtools: true" option is given', async () => { + const { puppeteer } = getTestState(); + + const browser = await puppeteer.launch( + Object.assign({ devtools: true }, headfulOptions) + ); + const context = await browser.createIncognitoBrowserContext(); + await Promise.all([ + context.newPage(), + context.waitForTarget((target) => target.url().includes('devtools://')), + ]); + await browser.close(); + }); + }); + + describe('Page.bringToFront', function () { + it('should work', async () => { + const { puppeteer } = getTestState(); + const browser = await puppeteer.launch(headfulOptions); + const page1 = await browser.newPage(); + const page2 = await browser.newPage(); + + await page1.bringToFront(); + expect(await page1.evaluate(() => document.visibilityState)).toBe( + 'visible' + ); + expect(await page2.evaluate(() => document.visibilityState)).toBe( + 'hidden' + ); + + await page2.bringToFront(); + expect(await page1.evaluate(() => document.visibilityState)).toBe( + 'hidden' + ); + expect(await page2.evaluate(() => document.visibilityState)).toBe( + 'visible' + ); + + await page1.close(); + await page2.close(); + await browser.close(); + }); + }); +}); diff --git a/remote/test/puppeteer/test/idle_override.spec.ts b/remote/test/puppeteer/test/idle_override.spec.ts new file mode 100644 index 0000000000..b6a927e48a --- /dev/null +++ b/remote/test/puppeteer/test/idle_override.spec.ts @@ -0,0 +1,93 @@ +/** + * Copyright 2020 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. + */ + +import expect from 'expect'; +import { + getTestState, + setupTestBrowserHooks, + setupTestPageAndContextHooks, +} from './mocha-utils'; // eslint-disable-line import/extensions + +describe('Emulate idle state', () => { + setupTestBrowserHooks(); + setupTestPageAndContextHooks(); + + async function getIdleState() { + const { page } = getTestState(); + + const stateElement = await page.$('#state'); + return await page.evaluate((element: HTMLElement) => { + return element.innerText; + }, stateElement); + } + + async function verifyState(expectedState: string) { + const actualState = await getIdleState(); + expect(actualState).toEqual(expectedState); + } + + it('changing idle state emulation causes change of the IdleDetector state', async () => { + const { page, server, context } = getTestState(); + await context.overridePermissions(server.PREFIX + '/idle-detector.html', [ + 'idle-detection', + ]); + + await page.goto(server.PREFIX + '/idle-detector.html'); + + // Store initial state, as soon as it is not guaranteed to be `active, unlocked`. + const initialState = await getIdleState(); + + // Emulate Idle states and verify IdleDetector updates state accordingly. + await page.emulateIdleState({ + isUserActive: false, + isScreenUnlocked: false, + }); + await verifyState('Idle state: idle, locked.'); + + await page.emulateIdleState({ + isUserActive: true, + isScreenUnlocked: false, + }); + await verifyState('Idle state: active, locked.'); + + await page.emulateIdleState({ + isUserActive: true, + isScreenUnlocked: true, + }); + await verifyState('Idle state: active, unlocked.'); + + await page.emulateIdleState({ + isUserActive: false, + isScreenUnlocked: true, + }); + await verifyState('Idle state: idle, unlocked.'); + + // Remove Idle emulation and verify IdleDetector is in initial state. + await page.emulateIdleState(); + await verifyState(initialState); + + // Emulate idle state again after removing emulation. + await page.emulateIdleState({ + isUserActive: false, + isScreenUnlocked: false, + }); + await verifyState('Idle state: idle, locked.'); + + // Remove emulation second time. + await page.emulateIdleState(); + await verifyState(initialState); + }); +}); diff --git a/remote/test/puppeteer/test/ignorehttpserrors.spec.ts b/remote/test/puppeteer/test/ignorehttpserrors.spec.ts new file mode 100644 index 0000000000..81252af298 --- /dev/null +++ b/remote/test/puppeteer/test/ignorehttpserrors.spec.ts @@ -0,0 +1,133 @@ +/** + * 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. + */ + +import expect from 'expect'; +import { + getTestState, +} from './mocha-utils'; // eslint-disable-line import/extensions + +describe('ignoreHTTPSErrors', function () { + /* Note that this test creates its own browser rather than use + * the one provided by the test set-up as we need one + * with ignoreHTTPSErrors set to true + */ + let browser; + let context; + let page; + + before(async () => { + const { defaultBrowserOptions, puppeteer } = getTestState(); + const options = Object.assign( + { ignoreHTTPSErrors: true }, + defaultBrowserOptions + ); + browser = await puppeteer.launch(options); + }); + + after(async () => { + await browser.close(); + browser = null; + }); + + beforeEach(async () => { + context = await browser.createIncognitoBrowserContext(); + page = await context.newPage(); + }); + + afterEach(async () => { + await context.close(); + context = null; + page = null; + }); + + describe('Response.securityDetails', function () { + it('should work', async () => { + const { httpsServer } = getTestState(); + + const [serverRequest, response] = await Promise.all([ + httpsServer.waitForRequest('/empty.html'), + page.goto(httpsServer.EMPTY_PAGE), + ]); + const securityDetails = response.securityDetails(); + expect(securityDetails.issuer()).toBe('puppeteer-tests'); + const protocol = serverRequest.socket.getProtocol().replace('v', ' '); + expect(securityDetails.protocol()).toBe(protocol); + expect(securityDetails.subjectName()).toBe('puppeteer-tests'); + expect(securityDetails.validFrom()).toBe(1589357069); + expect(securityDetails.validTo()).toBe(1904717069); + expect(securityDetails.subjectAlternativeNames()).toEqual([ + 'www.puppeteer-tests.test', + 'www.puppeteer-tests-1.test', + ]); + }); + it('should be |null| for non-secure requests', async () => { + const { server } = getTestState(); + + const response = await page.goto(server.EMPTY_PAGE); + expect(response.securityDetails()).toBe(null); + }); + it('Network redirects should report SecurityDetails', async () => { + const { httpsServer } = getTestState(); + + httpsServer.setRedirect('/plzredirect', '/empty.html'); + const responses = []; + page.on('response', (response) => responses.push(response)); + const [serverRequest] = await Promise.all([ + httpsServer.waitForRequest('/plzredirect'), + page.goto(httpsServer.PREFIX + '/plzredirect'), + ]); + expect(responses.length).toBe(2); + expect(responses[0].status()).toBe(302); + const securityDetails = responses[0].securityDetails(); + const protocol = serverRequest.socket.getProtocol().replace('v', ' '); + expect(securityDetails.protocol()).toBe(protocol); + }); + }); + + it('should work', async () => { + const { httpsServer } = getTestState(); + + let error = null; + const response = await page + .goto(httpsServer.EMPTY_PAGE) + .catch((error_) => (error = error_)); + expect(error).toBe(null); + expect(response.ok()).toBe(true); + }); + it('should work with request interception', async () => { + const { httpsServer } = getTestState(); + + await page.setRequestInterception(true); + page.on('request', (request) => request.continue()); + const response = await page.goto(httpsServer.EMPTY_PAGE); + expect(response.status()).toBe(200); + }); + it('should work with mixed content', async () => { + const { server, httpsServer } = getTestState(); + + httpsServer.setRoute('/mixedcontent.html', (req, res) => { + res.end(`<iframe src=${server.EMPTY_PAGE}></iframe>`); + }); + await page.goto(httpsServer.PREFIX + '/mixedcontent.html', { + waitUntil: 'load', + }); + expect(page.frames().length).toBe(2); + // Make sure blocked iframe has functional execution context + // @see https://github.com/puppeteer/puppeteer/issues/2709 + expect(await page.frames()[0].evaluate('1 + 2')).toBe(3); + expect(await page.frames()[1].evaluate('2 + 3')).toBe(5); + }); +}); diff --git a/remote/test/puppeteer/test/input.spec.ts b/remote/test/puppeteer/test/input.spec.ts new file mode 100644 index 0000000000..d87aa1375f --- /dev/null +++ b/remote/test/puppeteer/test/input.spec.ts @@ -0,0 +1,337 @@ +/** + * 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. + */ + +import path from 'path'; +import expect from 'expect'; +import { + getTestState, + setupTestBrowserHooks, + setupTestPageAndContextHooks, + describeFailsFirefox, +} from './mocha-utils'; // eslint-disable-line import/extensions + +const FILE_TO_UPLOAD = path.join(__dirname, '/assets/file-to-upload.txt'); + +describe('input tests', function () { + setupTestBrowserHooks(); + setupTestPageAndContextHooks(); + + describeFailsFirefox('input', function () { + it('should upload the file', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/input/fileupload.html'); + const filePath = path.relative(process.cwd(), FILE_TO_UPLOAD); + const input = await page.$('input'); + await page.evaluate((e: HTMLElement) => { + globalThis._inputEvents = []; + e.addEventListener('change', (ev) => + globalThis._inputEvents.push(ev.type) + ); + e.addEventListener('input', (ev) => + globalThis._inputEvents.push(ev.type) + ); + }, input); + await input.uploadFile(filePath); + expect( + await page.evaluate((e: HTMLInputElement) => e.files[0].name, input) + ).toBe('file-to-upload.txt'); + expect( + await page.evaluate((e: HTMLInputElement) => e.files[0].type, input) + ).toBe('text/plain'); + expect(await page.evaluate(() => globalThis._inputEvents)).toEqual([ + 'input', + 'change', + ]); + expect( + await page.evaluate((e: HTMLInputElement) => { + const reader = new FileReader(); + const promise = new Promise((fulfill) => (reader.onload = fulfill)); + reader.readAsText(e.files[0]); + return promise.then(() => reader.result); + }, input) + ).toBe('contents of the file'); + }); + }); + + describeFailsFirefox('Page.waitForFileChooser', function () { + it('should work when file input is attached to DOM', async () => { + const { page } = getTestState(); + + await page.setContent(`<input type=file>`); + const [chooser] = await Promise.all([ + page.waitForFileChooser(), + page.click('input'), + ]); + expect(chooser).toBeTruthy(); + }); + it('should work when file input is not attached to DOM', async () => { + const { page } = getTestState(); + + const [chooser] = await Promise.all([ + page.waitForFileChooser(), + page.evaluate(() => { + const el = document.createElement('input'); + el.type = 'file'; + el.click(); + }), + ]); + expect(chooser).toBeTruthy(); + }); + it('should respect timeout', async () => { + const { page, puppeteer } = getTestState(); + + let error = null; + await page + .waitForFileChooser({ timeout: 1 }) + .catch((error_) => (error = error_)); + expect(error).toBeInstanceOf(puppeteer.errors.TimeoutError); + }); + it('should respect default timeout when there is no custom timeout', async () => { + const { page, puppeteer } = getTestState(); + + page.setDefaultTimeout(1); + let error = null; + await page.waitForFileChooser().catch((error_) => (error = error_)); + expect(error).toBeInstanceOf(puppeteer.errors.TimeoutError); + }); + it('should prioritize exact timeout over default timeout', async () => { + const { page, puppeteer } = getTestState(); + + page.setDefaultTimeout(0); + let error = null; + await page + .waitForFileChooser({ timeout: 1 }) + .catch((error_) => (error = error_)); + expect(error).toBeInstanceOf(puppeteer.errors.TimeoutError); + }); + it('should work with no timeout', async () => { + const { page } = getTestState(); + + const [chooser] = await Promise.all([ + page.waitForFileChooser({ timeout: 0 }), + page.evaluate(() => + setTimeout(() => { + const el = document.createElement('input'); + el.type = 'file'; + el.click(); + }, 50) + ), + ]); + expect(chooser).toBeTruthy(); + }); + it('should return the same file chooser when there are many watchdogs simultaneously', async () => { + const { page } = getTestState(); + + await page.setContent(`<input type=file>`); + const [fileChooser1, fileChooser2] = await Promise.all([ + page.waitForFileChooser(), + page.waitForFileChooser(), + page.$eval('input', (input: HTMLInputElement) => input.click()), + ]); + expect(fileChooser1 === fileChooser2).toBe(true); + }); + }); + + describeFailsFirefox('FileChooser.accept', function () { + it('should accept single file', async () => { + const { page } = getTestState(); + + await page.setContent( + `<input type=file oninput='javascript:console.timeStamp()'>` + ); + const [chooser] = await Promise.all([ + page.waitForFileChooser(), + page.click('input'), + ]); + await Promise.all([ + chooser.accept([FILE_TO_UPLOAD]), + new Promise((x) => page.once('metrics', x)), + ]); + expect( + await page.$eval( + 'input', + (input: HTMLInputElement) => input.files.length + ) + ).toBe(1); + expect( + await page.$eval( + 'input', + (input: HTMLInputElement) => input.files[0].name + ) + ).toBe('file-to-upload.txt'); + }); + it('should be able to read selected file', async () => { + const { page } = getTestState(); + + await page.setContent(`<input type=file>`); + page + .waitForFileChooser() + .then((chooser) => chooser.accept([FILE_TO_UPLOAD])); + expect( + await page.$eval('input', async (picker: HTMLInputElement) => { + picker.click(); + await new Promise((x) => (picker.oninput = x)); + const reader = new FileReader(); + const promise = new Promise((fulfill) => (reader.onload = fulfill)); + reader.readAsText(picker.files[0]); + return promise.then(() => reader.result); + }) + ).toBe('contents of the file'); + }); + it('should be able to reset selected files with empty file list', async () => { + const { page } = getTestState(); + + await page.setContent(`<input type=file>`); + page + .waitForFileChooser() + .then((chooser) => chooser.accept([FILE_TO_UPLOAD])); + expect( + await page.$eval('input', async (picker: HTMLInputElement) => { + picker.click(); + await new Promise((x) => (picker.oninput = x)); + return picker.files.length; + }) + ).toBe(1); + page.waitForFileChooser().then((chooser) => chooser.accept([])); + expect( + await page.$eval('input', async (picker: HTMLInputElement) => { + picker.click(); + await new Promise((x) => (picker.oninput = x)); + return picker.files.length; + }) + ).toBe(0); + }); + it('should not accept multiple files for single-file input', async () => { + const { page } = getTestState(); + + await page.setContent(`<input type=file>`); + const [chooser] = await Promise.all([ + page.waitForFileChooser(), + page.click('input'), + ]); + let error = null; + await chooser + .accept([ + path.relative( + process.cwd(), + __dirname + '/assets/file-to-upload.txt' + ), + path.relative(process.cwd(), __dirname + '/assets/pptr.png'), + ]) + .catch((error_) => (error = error_)); + expect(error).not.toBe(null); + }); + it('should fail for non-existent files', async () => { + const { page } = getTestState(); + + await page.setContent(`<input type=file>`); + const [chooser] = await Promise.all([ + page.waitForFileChooser(), + page.click('input'), + ]); + let error = null; + await chooser + .accept(['file-does-not-exist.txt']) + .catch((error_) => (error = error_)); + expect(error).not.toBe(null); + }); + it('should fail when accepting file chooser twice', async () => { + const { page } = getTestState(); + + await page.setContent(`<input type=file>`); + const [fileChooser] = await Promise.all([ + page.waitForFileChooser(), + page.$eval('input', (input: HTMLInputElement) => input.click()), + ]); + await fileChooser.accept([]); + let error = null; + await fileChooser.accept([]).catch((error_) => (error = error_)); + expect(error.message).toBe( + 'Cannot accept FileChooser which is already handled!' + ); + }); + }); + + describeFailsFirefox('FileChooser.cancel', function () { + it('should cancel dialog', async () => { + const { page } = getTestState(); + + // Consider file chooser canceled if we can summon another one. + // There's no reliable way in WebPlatform to see that FileChooser was + // canceled. + await page.setContent(`<input type=file>`); + const [fileChooser1] = await Promise.all([ + page.waitForFileChooser(), + page.$eval('input', (input: HTMLInputElement) => input.click()), + ]); + await fileChooser1.cancel(); + // If this resolves, than we successfully canceled file chooser. + await Promise.all([ + page.waitForFileChooser(), + page.$eval('input', (input: HTMLInputElement) => input.click()), + ]); + }); + it('should fail when canceling file chooser twice', async () => { + const { page } = getTestState(); + + await page.setContent(`<input type=file>`); + const [fileChooser] = await Promise.all([ + page.waitForFileChooser(), + page.$eval('input', (input: HTMLInputElement) => input.click()), + ]); + await fileChooser.cancel(); + let error = null; + await fileChooser.cancel().catch((error_) => (error = error_)); + expect(error.message).toBe( + 'Cannot cancel FileChooser which is already handled!' + ); + }); + }); + + describeFailsFirefox('FileChooser.isMultiple', () => { + it('should work for single file pick', async () => { + const { page } = getTestState(); + + await page.setContent(`<input type=file>`); + const [chooser] = await Promise.all([ + page.waitForFileChooser(), + page.click('input'), + ]); + expect(chooser.isMultiple()).toBe(false); + }); + it('should work for "multiple"', async () => { + const { page } = getTestState(); + + await page.setContent(`<input multiple type=file>`); + const [chooser] = await Promise.all([ + page.waitForFileChooser(), + page.click('input'), + ]); + expect(chooser.isMultiple()).toBe(true); + }); + it('should work for "webkitdirectory"', async () => { + const { page } = getTestState(); + + await page.setContent(`<input multiple webkitdirectory type=file>`); + const [chooser] = await Promise.all([ + page.waitForFileChooser(), + page.click('input'), + ]); + expect(chooser.isMultiple()).toBe(true); + }); + }); +}); diff --git a/remote/test/puppeteer/test/jshandle.spec.ts b/remote/test/puppeteer/test/jshandle.spec.ts new file mode 100644 index 0000000000..35b0f8edbe --- /dev/null +++ b/remote/test/puppeteer/test/jshandle.spec.ts @@ -0,0 +1,282 @@ +/** + * 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. + */ + +import expect from 'expect'; +import { + getTestState, + setupTestBrowserHooks, + setupTestPageAndContextHooks, +} from './mocha-utils'; // eslint-disable-line import/extensions + +describe('JSHandle', function () { + setupTestBrowserHooks(); + setupTestPageAndContextHooks(); + + describe('Page.evaluateHandle', function () { + it('should work', async () => { + const { page } = getTestState(); + + const windowHandle = await page.evaluateHandle(() => window); + expect(windowHandle).toBeTruthy(); + }); + it('should accept object handle as an argument', async () => { + const { page } = getTestState(); + + const navigatorHandle = await page.evaluateHandle(() => navigator); + const text = await page.evaluate( + (e: Navigator) => e.userAgent, + navigatorHandle + ); + expect(text).toContain('Mozilla'); + }); + it('should accept object handle to primitive types', async () => { + const { page } = getTestState(); + + const aHandle = await page.evaluateHandle(() => 5); + const isFive = await page.evaluate((e) => Object.is(e, 5), aHandle); + expect(isFive).toBeTruthy(); + }); + it('should warn on nested object handles', async () => { + const { page } = getTestState(); + + const aHandle = await page.evaluateHandle(() => document.body); + let error = null; + await page + // @ts-expect-error we are deliberately passing a bad type here (nested object) + .evaluateHandle((opts) => opts.elem.querySelector('p'), { + elem: aHandle, + }) + .catch((error_) => (error = error_)); + expect(error.message).toContain('Are you passing a nested JSHandle?'); + }); + it('should accept object handle to unserializable value', async () => { + const { page } = getTestState(); + + const aHandle = await page.evaluateHandle(() => Infinity); + expect(await page.evaluate((e) => Object.is(e, Infinity), aHandle)).toBe( + true + ); + }); + it('should use the same JS wrappers', async () => { + const { page } = getTestState(); + + const aHandle = await page.evaluateHandle(() => { + globalThis.FOO = 123; + return window; + }); + expect(await page.evaluate((e: { FOO: number }) => e.FOO, aHandle)).toBe( + 123 + ); + }); + it('should work with primitives', async () => { + const { page } = getTestState(); + + const aHandle = await page.evaluateHandle(() => { + globalThis.FOO = 123; + return window; + }); + expect(await page.evaluate((e: { FOO: number }) => e.FOO, aHandle)).toBe( + 123 + ); + }); + }); + + describe('JSHandle.getProperty', function () { + it('should work', async () => { + const { page } = getTestState(); + + const aHandle = await page.evaluateHandle(() => ({ + one: 1, + two: 2, + three: 3, + })); + const twoHandle = await aHandle.getProperty('two'); + expect(await twoHandle.jsonValue()).toEqual(2); + }); + }); + + describe('JSHandle.jsonValue', function () { + it('should work', async () => { + const { page } = getTestState(); + + const aHandle = await page.evaluateHandle(() => ({ foo: 'bar' })); + const json = await aHandle.jsonValue(); + expect(json).toEqual({ foo: 'bar' }); + }); + it('should not work with dates', async () => { + const { page } = getTestState(); + + const dateHandle = await page.evaluateHandle( + () => new Date('2017-09-26T00:00:00.000Z') + ); + const json = await dateHandle.jsonValue(); + expect(json).toEqual({}); + }); + it('should throw for circular objects', async () => { + const { page, isChrome } = getTestState(); + + const windowHandle = await page.evaluateHandle('window'); + let error = null; + await windowHandle.jsonValue().catch((error_) => (error = error_)); + if (isChrome) + expect(error.message).toContain('Object reference chain is too long'); + else expect(error.message).toContain('Object is not serializable'); + }); + }); + + describe('JSHandle.getProperties', function () { + it('should work', async () => { + const { page } = getTestState(); + + const aHandle = await page.evaluateHandle(() => ({ + foo: 'bar', + })); + const properties = await aHandle.getProperties(); + const foo = properties.get('foo'); + expect(foo).toBeTruthy(); + expect(await foo.jsonValue()).toBe('bar'); + }); + it('should return even non-own properties', async () => { + const { page } = getTestState(); + + const aHandle = await page.evaluateHandle(() => { + class A { + a: string; + constructor() { + this.a = '1'; + } + } + class B extends A { + b: string; + constructor() { + super(); + this.b = '2'; + } + } + return new B(); + }); + const properties = await aHandle.getProperties(); + expect(await properties.get('a').jsonValue()).toBe('1'); + expect(await properties.get('b').jsonValue()).toBe('2'); + }); + }); + + describe('JSHandle.asElement', function () { + it('should work', async () => { + const { page } = getTestState(); + + const aHandle = await page.evaluateHandle(() => document.body); + const element = aHandle.asElement(); + expect(element).toBeTruthy(); + }); + it('should return null for non-elements', async () => { + const { page } = getTestState(); + + const aHandle = await page.evaluateHandle(() => 2); + const element = aHandle.asElement(); + expect(element).toBeFalsy(); + }); + it('should return ElementHandle for TextNodes', async () => { + const { page } = getTestState(); + + await page.setContent('<div>ee!</div>'); + const aHandle = await page.evaluateHandle( + () => document.querySelector('div').firstChild + ); + const element = aHandle.asElement(); + expect(element).toBeTruthy(); + expect( + await page.evaluate( + (e: HTMLElement) => e.nodeType === Node.TEXT_NODE, + element + ) + ); + }); + }); + + describe('JSHandle.toString', function () { + it('should work for primitives', async () => { + const { page } = getTestState(); + + const numberHandle = await page.evaluateHandle(() => 2); + expect(numberHandle.toString()).toBe('JSHandle:2'); + const stringHandle = await page.evaluateHandle(() => 'a'); + expect(stringHandle.toString()).toBe('JSHandle:a'); + }); + it('should work for complicated objects', async () => { + const { page } = getTestState(); + + const aHandle = await page.evaluateHandle(() => window); + expect(aHandle.toString()).toBe('JSHandle@object'); + }); + it('should work with different subtypes', async () => { + const { page } = getTestState(); + + expect((await page.evaluateHandle('(function(){})')).toString()).toBe( + 'JSHandle@function' + ); + expect((await page.evaluateHandle('12')).toString()).toBe('JSHandle:12'); + expect((await page.evaluateHandle('true')).toString()).toBe( + 'JSHandle:true' + ); + expect((await page.evaluateHandle('undefined')).toString()).toBe( + 'JSHandle:undefined' + ); + expect((await page.evaluateHandle('"foo"')).toString()).toBe( + 'JSHandle:foo' + ); + expect((await page.evaluateHandle('Symbol()')).toString()).toBe( + 'JSHandle@symbol' + ); + expect((await page.evaluateHandle('new Map()')).toString()).toBe( + 'JSHandle@map' + ); + expect((await page.evaluateHandle('new Set()')).toString()).toBe( + 'JSHandle@set' + ); + expect((await page.evaluateHandle('[]')).toString()).toBe( + 'JSHandle@array' + ); + expect((await page.evaluateHandle('null')).toString()).toBe( + 'JSHandle:null' + ); + expect((await page.evaluateHandle('/foo/')).toString()).toBe( + 'JSHandle@regexp' + ); + expect((await page.evaluateHandle('document.body')).toString()).toBe( + 'JSHandle@node' + ); + expect((await page.evaluateHandle('new Date()')).toString()).toBe( + 'JSHandle@date' + ); + expect((await page.evaluateHandle('new WeakMap()')).toString()).toBe( + 'JSHandle@weakmap' + ); + expect((await page.evaluateHandle('new WeakSet()')).toString()).toBe( + 'JSHandle@weakset' + ); + expect((await page.evaluateHandle('new Error()')).toString()).toBe( + 'JSHandle@error' + ); + expect((await page.evaluateHandle('new Int32Array()')).toString()).toBe( + 'JSHandle@typedarray' + ); + expect((await page.evaluateHandle('new Proxy({}, {})')).toString()).toBe( + 'JSHandle@proxy' + ); + }); + }); +}); diff --git a/remote/test/puppeteer/test/keyboard.spec.ts b/remote/test/puppeteer/test/keyboard.spec.ts new file mode 100644 index 0000000000..c6d28dac8e --- /dev/null +++ b/remote/test/puppeteer/test/keyboard.spec.ts @@ -0,0 +1,407 @@ +/** + * 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. + */ + +import utils from './utils.js'; +import os from 'os'; +import expect from 'expect'; +import { + getTestState, + setupTestBrowserHooks, + setupTestPageAndContextHooks, +} from './mocha-utils'; // eslint-disable-line import/extensions +import { KeyInput } from '../lib/cjs/puppeteer/common/USKeyboardLayout.js'; + +describe('Keyboard', function () { + setupTestBrowserHooks(); + setupTestPageAndContextHooks(); + + it('should type into a textarea', async () => { + const { page } = getTestState(); + + await page.evaluate(() => { + const textarea = document.createElement('textarea'); + document.body.appendChild(textarea); + textarea.focus(); + }); + const text = 'Hello world. I am the text that was typed!'; + await page.keyboard.type(text); + expect( + await page.evaluate(() => document.querySelector('textarea').value) + ).toBe(text); + }); + it('should press the metaKey', async () => { + const { page, isFirefox } = getTestState(); + + await page.evaluate(() => { + (window as any).keyPromise = new Promise((resolve) => + document.addEventListener('keydown', (event) => resolve(event.key)) + ); + }); + await page.keyboard.press('Meta'); + expect(await page.evaluate('keyPromise')).toBe( + isFirefox && os.platform() !== 'darwin' ? 'OS' : 'Meta' + ); + }); + it('should move with the arrow keys', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/input/textarea.html'); + await page.type('textarea', 'Hello World!'); + expect( + await page.evaluate(() => document.querySelector('textarea').value) + ).toBe('Hello World!'); + for (let i = 0; i < 'World!'.length; i++) page.keyboard.press('ArrowLeft'); + await page.keyboard.type('inserted '); + expect( + await page.evaluate(() => document.querySelector('textarea').value) + ).toBe('Hello inserted World!'); + page.keyboard.down('Shift'); + for (let i = 0; i < 'inserted '.length; i++) + page.keyboard.press('ArrowLeft'); + page.keyboard.up('Shift'); + await page.keyboard.press('Backspace'); + expect( + await page.evaluate(() => document.querySelector('textarea').value) + ).toBe('Hello World!'); + }); + it('should send a character with ElementHandle.press', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/input/textarea.html'); + const textarea = await page.$('textarea'); + await textarea.press('a'); + expect( + await page.evaluate(() => document.querySelector('textarea').value) + ).toBe('a'); + + await page.evaluate(() => + window.addEventListener('keydown', (e) => e.preventDefault(), true) + ); + + await textarea.press('b'); + expect( + await page.evaluate(() => document.querySelector('textarea').value) + ).toBe('a'); + }); + it( + 'ElementHandle.press should support |text| option', + async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/input/textarea.html'); + const textarea = await page.$('textarea'); + await textarea.press('a', { text: 'ё' }); + expect( + await page.evaluate(() => document.querySelector('textarea').value) + ).toBe('ё'); + } + ); + it('should send a character with sendCharacter', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/input/textarea.html'); + await page.focus('textarea'); + await page.keyboard.sendCharacter('嗨'); + expect( + await page.evaluate(() => document.querySelector('textarea').value) + ).toBe('嗨'); + await page.evaluate(() => + window.addEventListener('keydown', (e) => e.preventDefault(), true) + ); + await page.keyboard.sendCharacter('a'); + expect( + await page.evaluate(() => document.querySelector('textarea').value) + ).toBe('嗨a'); + }); + it('should report shiftKey', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/input/keyboard.html'); + const keyboard = page.keyboard; + const codeForKey = new Map<KeyInput, number>([ + ['Shift', 16], + ['Alt', 18], + ['Control', 17], + ]); + for (const [modifierKey, modifierCode] of codeForKey) { + await keyboard.down(modifierKey); + expect(await page.evaluate(() => globalThis.getResult())).toBe( + 'Keydown: ' + + modifierKey + + ' ' + + modifierKey + + 'Left ' + + modifierCode + + ' [' + + modifierKey + + ']' + ); + await keyboard.down('!'); + // Shift+! will generate a keypress + if (modifierKey === 'Shift') + expect(await page.evaluate(() => globalThis.getResult())).toBe( + 'Keydown: ! Digit1 49 [' + + modifierKey + + ']\nKeypress: ! Digit1 33 33 [' + + modifierKey + + ']' + ); + else + expect(await page.evaluate(() => globalThis.getResult())).toBe( + 'Keydown: ! Digit1 49 [' + modifierKey + ']' + ); + + await keyboard.up('!'); + expect(await page.evaluate(() => globalThis.getResult())).toBe( + 'Keyup: ! Digit1 49 [' + modifierKey + ']' + ); + await keyboard.up(modifierKey); + expect(await page.evaluate(() => globalThis.getResult())).toBe( + 'Keyup: ' + + modifierKey + + ' ' + + modifierKey + + 'Left ' + + modifierCode + + ' []' + ); + } + }); + it('should report multiple modifiers', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/input/keyboard.html'); + const keyboard = page.keyboard; + await keyboard.down('Control'); + expect(await page.evaluate(() => globalThis.getResult())).toBe( + 'Keydown: Control ControlLeft 17 [Control]' + ); + await keyboard.down('Alt'); + expect(await page.evaluate(() => globalThis.getResult())).toBe( + 'Keydown: Alt AltLeft 18 [Alt Control]' + ); + await keyboard.down(';'); + expect(await page.evaluate(() => globalThis.getResult())).toBe( + 'Keydown: ; Semicolon 186 [Alt Control]' + ); + await keyboard.up(';'); + expect(await page.evaluate(() => globalThis.getResult())).toBe( + 'Keyup: ; Semicolon 186 [Alt Control]' + ); + await keyboard.up('Control'); + expect(await page.evaluate(() => globalThis.getResult())).toBe( + 'Keyup: Control ControlLeft 17 [Alt]' + ); + await keyboard.up('Alt'); + expect(await page.evaluate(() => globalThis.getResult())).toBe( + 'Keyup: Alt AltLeft 18 []' + ); + }); + it('should send proper codes while typing', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/input/keyboard.html'); + await page.keyboard.type('!'); + expect(await page.evaluate(() => globalThis.getResult())).toBe( + [ + 'Keydown: ! Digit1 49 []', + 'Keypress: ! Digit1 33 33 []', + 'Keyup: ! Digit1 49 []', + ].join('\n') + ); + await page.keyboard.type('^'); + expect(await page.evaluate(() => globalThis.getResult())).toBe( + [ + 'Keydown: ^ Digit6 54 []', + 'Keypress: ^ Digit6 94 94 []', + 'Keyup: ^ Digit6 54 []', + ].join('\n') + ); + }); + it('should send proper codes while typing with shift', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/input/keyboard.html'); + const keyboard = page.keyboard; + await keyboard.down('Shift'); + await page.keyboard.type('~'); + expect(await page.evaluate(() => globalThis.getResult())).toBe( + [ + 'Keydown: Shift ShiftLeft 16 [Shift]', + 'Keydown: ~ Backquote 192 [Shift]', // 192 is ` keyCode + 'Keypress: ~ Backquote 126 126 [Shift]', // 126 is ~ charCode + 'Keyup: ~ Backquote 192 [Shift]', + ].join('\n') + ); + await keyboard.up('Shift'); + }); + it('should not type canceled events', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/input/textarea.html'); + await page.focus('textarea'); + await page.evaluate(() => { + window.addEventListener( + 'keydown', + (event) => { + event.stopPropagation(); + event.stopImmediatePropagation(); + if (event.key === 'l') event.preventDefault(); + if (event.key === 'o') event.preventDefault(); + }, + false + ); + }); + await page.keyboard.type('Hello World!'); + expect(await page.evaluate(() => globalThis.textarea.value)).toBe( + 'He Wrd!' + ); + }); + it('should specify repeat property', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/input/textarea.html'); + await page.focus('textarea'); + await page.evaluate(() => + document + .querySelector('textarea') + .addEventListener('keydown', (e) => (globalThis.lastEvent = e), true) + ); + await page.keyboard.down('a'); + expect(await page.evaluate(() => globalThis.lastEvent.repeat)).toBe(false); + await page.keyboard.press('a'); + expect(await page.evaluate(() => globalThis.lastEvent.repeat)).toBe(true); + + await page.keyboard.down('b'); + expect(await page.evaluate(() => globalThis.lastEvent.repeat)).toBe(false); + await page.keyboard.down('b'); + expect(await page.evaluate(() => globalThis.lastEvent.repeat)).toBe(true); + + await page.keyboard.up('a'); + await page.keyboard.down('a'); + expect(await page.evaluate(() => globalThis.lastEvent.repeat)).toBe(false); + }); + it('should type all kinds of characters', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/input/textarea.html'); + await page.focus('textarea'); + const text = 'This text goes onto two lines.\nThis character is 嗨.'; + await page.keyboard.type(text); + expect(await page.evaluate('result')).toBe(text); + }); + it('should specify location', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/input/textarea.html'); + await page.evaluate(() => { + window.addEventListener( + 'keydown', + (event) => (globalThis.keyLocation = event.location), + true + ); + }); + const textarea = await page.$('textarea'); + + await textarea.press('Digit5'); + expect(await page.evaluate('keyLocation')).toBe(0); + + await textarea.press('ControlLeft'); + expect(await page.evaluate('keyLocation')).toBe(1); + + await textarea.press('ControlRight'); + expect(await page.evaluate('keyLocation')).toBe(2); + + await textarea.press('NumpadSubtract'); + expect(await page.evaluate('keyLocation')).toBe(3); + }); + it('should throw on unknown keys', async () => { + const { page } = getTestState(); + + let error = await page.keyboard + // @ts-expect-error bad input + .press('NotARealKey') + .catch((error_) => error_); + expect(error.message).toBe('Unknown key: "NotARealKey"'); + + // @ts-expect-error bad input + error = await page.keyboard.press('ё').catch((error_) => error_); + expect(error && error.message).toBe('Unknown key: "ё"'); + + // @ts-expect-error bad input + error = await page.keyboard.press('😊').catch((error_) => error_); + expect(error && error.message).toBe('Unknown key: "😊"'); + }); + it('should type emoji', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/input/textarea.html'); + await page.type('textarea', '👹 Tokyo street Japan 🇯🇵'); + expect( + await page.$eval( + 'textarea', + (textarea: HTMLInputElement) => textarea.value + ) + ).toBe('👹 Tokyo street Japan 🇯🇵'); + }); + it('should type emoji into an iframe', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + await utils.attachFrame( + page, + 'emoji-test', + server.PREFIX + '/input/textarea.html' + ); + const frame = page.frames()[1]; + const textarea = await frame.$('textarea'); + await textarea.type('👹 Tokyo street Japan 🇯🇵'); + expect( + await frame.$eval( + 'textarea', + (textarea: HTMLInputElement) => textarea.value + ) + ).toBe('👹 Tokyo street Japan 🇯🇵'); + }); + it('should press the meta key', async () => { + const { page, isFirefox } = getTestState(); + + await page.evaluate(() => { + globalThis.result = null; + document.addEventListener('keydown', (event) => { + globalThis.result = [event.key, event.code, event.metaKey]; + }); + }); + await page.keyboard.press('Meta'); + // Have to do this because we lose a lot of type info when evaluating a + // string not a function. This is why functions are recommended rather than + // using strings (although we'll leave this test so we have coverage of both + // approaches.) + const [key, code, metaKey] = (await page.evaluate('result')) as [ + string, + string, + boolean + ]; + if (isFirefox && os.platform() !== 'darwin') expect(key).toBe('OS'); + else expect(key).toBe('Meta'); + + if (isFirefox) expect(code).toBe('OSLeft'); + else expect(code).toBe('MetaLeft'); + + if (isFirefox && os.platform() !== 'darwin') expect(metaKey).toBe(false); + else expect(metaKey).toBe(true); + }); +}); diff --git a/remote/test/puppeteer/test/launcher.spec.ts b/remote/test/puppeteer/test/launcher.spec.ts new file mode 100644 index 0000000000..4c9c2b6e61 --- /dev/null +++ b/remote/test/puppeteer/test/launcher.spec.ts @@ -0,0 +1,654 @@ +/** + * 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. + */ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import sinon from 'sinon'; +import { promisify } from 'util'; +import { + getTestState, + itOnlyRegularInstall, +} from './mocha-utils'; // eslint-disable-line import/extensions +import utils from './utils.js'; +import expect from 'expect'; +import rimraf from 'rimraf'; +import { Page } from '../lib/cjs/puppeteer/common/Page.js'; + +const rmAsync = promisify(rimraf); +const mkdtempAsync = promisify(fs.mkdtemp); +const readFileAsync = promisify(fs.readFile); +const statAsync = promisify(fs.stat); +const TMP_FOLDER = path.join(os.tmpdir(), 'pptr_tmp_folder-'); +const FIREFOX_TIMEOUT = 30 * 1000; + +describe('Launcher specs', function () { + if (getTestState().isFirefox) this.timeout(FIREFOX_TIMEOUT); + + describe('Puppeteer', function () { + describe('BrowserFetcher', function () { + it('should download and extract chrome linux binary', async () => { + const { server, puppeteer } = getTestState(); + + const downloadsFolder = await mkdtempAsync(TMP_FOLDER); + const browserFetcher = puppeteer.createBrowserFetcher({ + platform: 'linux', + path: downloadsFolder, + host: server.PREFIX, + }); + const expectedRevision = '123456'; + let revisionInfo = browserFetcher.revisionInfo(expectedRevision); + server.setRoute( + revisionInfo.url.substring(server.PREFIX.length), + (req, res) => { + server.serveFile(req, res, '/chromium-linux.zip'); + } + ); + + expect(revisionInfo.local).toBe(false); + expect(browserFetcher.platform()).toBe('linux'); + expect(browserFetcher.product()).toBe('chrome'); + expect(!!browserFetcher.host()).toBe(true); + expect(await browserFetcher.canDownload('100000')).toBe(false); + expect(await browserFetcher.canDownload(expectedRevision)).toBe(true); + + revisionInfo = await browserFetcher.download(expectedRevision); + expect(revisionInfo.local).toBe(true); + expect(await readFileAsync(revisionInfo.executablePath, 'utf8')).toBe( + 'LINUX BINARY\n' + ); + const expectedPermissions = os.platform() === 'win32' ? 0o666 : 0o755; + expect( + (await statAsync(revisionInfo.executablePath)).mode & 0o777 + ).toBe(expectedPermissions); + expect(await browserFetcher.localRevisions()).toEqual([ + expectedRevision, + ]); + await browserFetcher.remove(expectedRevision); + expect(await browserFetcher.localRevisions()).toEqual([]); + await rmAsync(downloadsFolder); + }); + it('should download and extract firefox linux binary', async () => { + const { server, puppeteer } = getTestState(); + + const downloadsFolder = await mkdtempAsync(TMP_FOLDER); + const browserFetcher = puppeteer.createBrowserFetcher({ + platform: 'linux', + path: downloadsFolder, + host: server.PREFIX, + product: 'firefox', + }); + const expectedVersion = '75.0a1'; + let revisionInfo = browserFetcher.revisionInfo(expectedVersion); + server.setRoute( + revisionInfo.url.substring(server.PREFIX.length), + (req, res) => { + server.serveFile( + req, + res, + `/firefox-${expectedVersion}.en-US.linux-x86_64.tar.bz2` + ); + } + ); + + expect(revisionInfo.local).toBe(false); + expect(browserFetcher.platform()).toBe('linux'); + expect(browserFetcher.product()).toBe('firefox'); + expect(await browserFetcher.canDownload('100000')).toBe(false); + expect(await browserFetcher.canDownload(expectedVersion)).toBe(true); + + revisionInfo = await browserFetcher.download(expectedVersion); + expect(revisionInfo.local).toBe(true); + expect(await readFileAsync(revisionInfo.executablePath, 'utf8')).toBe( + 'FIREFOX LINUX BINARY\n' + ); + const expectedPermissions = os.platform() === 'win32' ? 0o666 : 0o755; + expect( + (await statAsync(revisionInfo.executablePath)).mode & 0o777 + ).toBe(expectedPermissions); + expect(await browserFetcher.localRevisions()).toEqual([ + expectedVersion, + ]); + await browserFetcher.remove(expectedVersion); + expect(await browserFetcher.localRevisions()).toEqual([]); + await rmAsync(downloadsFolder); + }); + }); + + describe('Browser.disconnect', function () { + it('should reject navigation when browser closes', async () => { + const { server, puppeteer, defaultBrowserOptions } = getTestState(); + server.setRoute('/one-style.css', () => {}); + const browser = await puppeteer.launch(defaultBrowserOptions); + const remote = await puppeteer.connect({ + browserWSEndpoint: browser.wsEndpoint(), + }); + const page = await remote.newPage(); + const navigationPromise = page + .goto(server.PREFIX + '/one-style.html', { timeout: 60000 }) + .catch((error_) => error_); + await server.waitForRequest('/one-style.css'); + remote.disconnect(); + const error = await navigationPromise; + expect(error.message).toBe( + 'Navigation failed because browser has disconnected!' + ); + await browser.close(); + }); + it('should reject waitForSelector when browser closes', async () => { + const { server, puppeteer, defaultBrowserOptions } = getTestState(); + + server.setRoute('/empty.html', () => {}); + const browser = await puppeteer.launch(defaultBrowserOptions); + const remote = await puppeteer.connect({ + browserWSEndpoint: browser.wsEndpoint(), + }); + const page = await remote.newPage(); + const watchdog = page + .waitForSelector('div', { timeout: 60000 }) + .catch((error_) => error_); + remote.disconnect(); + const error = await watchdog; + expect(error.message).toContain('Protocol error'); + await browser.close(); + }); + }); + describe('Browser.close', function () { + it('should terminate network waiters', async () => { + const { server, puppeteer, defaultBrowserOptions } = getTestState(); + + const browser = await puppeteer.launch(defaultBrowserOptions); + const remote = await puppeteer.connect({ + browserWSEndpoint: browser.wsEndpoint(), + }); + const newPage = await remote.newPage(); + const results = await Promise.all([ + newPage.waitForRequest(server.EMPTY_PAGE).catch((error) => error), + newPage.waitForResponse(server.EMPTY_PAGE).catch((error) => error), + browser.close(), + ]); + for (let i = 0; i < 2; i++) { + const message = results[i].message; + expect(message).toContain('Target closed'); + expect(message).not.toContain('Timeout'); + } + await browser.close(); + }); + }); + describe('Puppeteer.launch', function () { + it('should reject all promises when browser is closed', async () => { + const { defaultBrowserOptions, puppeteer } = getTestState(); + const browser = await puppeteer.launch(defaultBrowserOptions); + const page = await browser.newPage(); + let error = null; + const neverResolves = page + .evaluate(() => new Promise(() => {})) + .catch((error_) => (error = error_)); + await browser.close(); + await neverResolves; + expect(error.message).toContain('Protocol error'); + }); + it('should reject if executable path is invalid', async () => { + const { defaultBrowserOptions, puppeteer } = getTestState(); + + let waitError = null; + const options = Object.assign({}, defaultBrowserOptions, { + executablePath: 'random-invalid-path', + }); + await puppeteer.launch(options).catch((error) => (waitError = error)); + expect(waitError.message).toContain('Failed to launch'); + }); + it('userDataDir option', async () => { + const { defaultBrowserOptions, puppeteer } = getTestState(); + + const userDataDir = await mkdtempAsync(TMP_FOLDER); + const options = Object.assign({ userDataDir }, defaultBrowserOptions); + const browser = await puppeteer.launch(options); + // Open a page to make sure its functional. + await browser.newPage(); + expect(fs.readdirSync(userDataDir).length).toBeGreaterThan(0); + await browser.close(); + expect(fs.readdirSync(userDataDir).length).toBeGreaterThan(0); + // This might throw. See https://github.com/puppeteer/puppeteer/issues/2778 + await rmAsync(userDataDir).catch(() => {}); + }); + it('userDataDir argument', async () => { + const { isChrome, puppeteer, defaultBrowserOptions } = getTestState(); + + const userDataDir = await mkdtempAsync(TMP_FOLDER); + const options = Object.assign({}, defaultBrowserOptions); + if (isChrome) { + options.args = [ + ...(defaultBrowserOptions.args || []), + `--user-data-dir=${userDataDir}`, + ]; + } else { + options.args = [ + ...(defaultBrowserOptions.args || []), + `-profile`, + userDataDir, + ]; + } + const browser = await puppeteer.launch(options); + expect(fs.readdirSync(userDataDir).length).toBeGreaterThan(0); + await browser.close(); + expect(fs.readdirSync(userDataDir).length).toBeGreaterThan(0); + // This might throw. See https://github.com/puppeteer/puppeteer/issues/2778 + await rmAsync(userDataDir).catch(() => {}); + }); + it('userDataDir option should restore state', async () => { + const { server, puppeteer, defaultBrowserOptions } = getTestState(); + + const userDataDir = await mkdtempAsync(TMP_FOLDER); + const options = Object.assign({ userDataDir }, defaultBrowserOptions); + const browser = await puppeteer.launch(options); + const page = await browser.newPage(); + await page.goto(server.EMPTY_PAGE); + await page.evaluate(() => (localStorage.hey = 'hello')); + await browser.close(); + + const browser2 = await puppeteer.launch(options); + const page2 = await browser2.newPage(); + await page2.goto(server.EMPTY_PAGE); + expect(await page2.evaluate(() => localStorage.hey)).toBe('hello'); + await browser2.close(); + // This might throw. See https://github.com/puppeteer/puppeteer/issues/2778 + await rmAsync(userDataDir).catch(() => {}); + }); + // This mysteriously fails on Windows on AppVeyor. See + // https://github.com/puppeteer/puppeteer/issues/4111 + xit('userDataDir option should restore cookies', async () => { + const { server, puppeteer, defaultBrowserOptions } = getTestState(); + + const userDataDir = await mkdtempAsync(TMP_FOLDER); + const options = Object.assign({ userDataDir }, defaultBrowserOptions); + const browser = await puppeteer.launch(options); + const page = await browser.newPage(); + await page.goto(server.EMPTY_PAGE); + await page.evaluate( + () => + (document.cookie = + 'doSomethingOnlyOnce=true; expires=Fri, 31 Dec 9999 23:59:59 GMT') + ); + await browser.close(); + + const browser2 = await puppeteer.launch(options); + const page2 = await browser2.newPage(); + await page2.goto(server.EMPTY_PAGE); + expect(await page2.evaluate(() => document.cookie)).toBe( + 'doSomethingOnlyOnce=true' + ); + await browser2.close(); + // This might throw. See https://github.com/puppeteer/puppeteer/issues/2778 + await rmAsync(userDataDir).catch(() => {}); + }); + it('should return the default arguments', async () => { + const { isChrome, isFirefox, puppeteer } = getTestState(); + + if (isChrome) { + expect(puppeteer.defaultArgs()).toContain('--no-first-run'); + expect(puppeteer.defaultArgs()).toContain('--headless'); + expect(puppeteer.defaultArgs({ headless: false })).not.toContain( + '--headless' + ); + expect(puppeteer.defaultArgs({ userDataDir: 'foo' })).toContain( + `--user-data-dir=${path.resolve('foo')}` + ); + } else if (isFirefox) { + expect(puppeteer.defaultArgs()).toContain('--headless'); + expect(puppeteer.defaultArgs()).toContain('--no-remote'); + expect(puppeteer.defaultArgs()).toContain('--foreground'); + expect(puppeteer.defaultArgs({ headless: false })).not.toContain( + '--headless' + ); + expect(puppeteer.defaultArgs({ userDataDir: 'foo' })).toContain( + '--profile' + ); + expect(puppeteer.defaultArgs({ userDataDir: 'foo' })).toContain( + 'foo' + ); + } else { + expect(puppeteer.defaultArgs()).toContain('-headless'); + expect(puppeteer.defaultArgs({ headless: false })).not.toContain( + '-headless' + ); + expect(puppeteer.defaultArgs({ userDataDir: 'foo' })).toContain( + '-profile' + ); + expect(puppeteer.defaultArgs({ userDataDir: 'foo' })).toContain( + path.resolve('foo') + ); + } + }); + it('should report the correct product', async () => { + const { isChrome, isFirefox, puppeteer } = getTestState(); + if (isChrome) expect(puppeteer.product).toBe('chrome'); + else if (isFirefox) expect(puppeteer.product).toBe('firefox'); + }); + it('should work with no default arguments', async () => { + const { defaultBrowserOptions, puppeteer } = getTestState(); + const options = Object.assign({}, defaultBrowserOptions); + options.ignoreDefaultArgs = true; + const browser = await puppeteer.launch(options); + const page = await browser.newPage(); + expect(await page.evaluate('11 * 11')).toBe(121); + await page.close(); + await browser.close(); + }); + it('should filter out ignored default arguments', async () => { + const { defaultBrowserOptions, puppeteer } = getTestState(); + // Make sure we launch with `--enable-automation` by default. + const defaultArgs = puppeteer.defaultArgs(); + const browser = await puppeteer.launch( + Object.assign({}, defaultBrowserOptions, { + // Ignore first and third default argument. + ignoreDefaultArgs: [defaultArgs[0], defaultArgs[2]], + }) + ); + const spawnargs = browser.process().spawnargs; + if (!spawnargs) { + throw new Error('spawnargs not present'); + } + expect(spawnargs.indexOf(defaultArgs[0])).toBe(-1); + expect(spawnargs.indexOf(defaultArgs[1])).not.toBe(-1); + expect(spawnargs.indexOf(defaultArgs[2])).toBe(-1); + await browser.close(); + }); + it('should have default URL when launching browser', async function () { + const { defaultBrowserOptions, puppeteer } = getTestState(); + const browser = await puppeteer.launch(defaultBrowserOptions); + const pages = (await browser.pages()).map((page) => page.url()); + expect(pages).toEqual(['about:blank']); + await browser.close(); + }); + it( + 'should have custom URL when launching browser', + async () => { + const { server, puppeteer, defaultBrowserOptions } = getTestState(); + + const options = Object.assign({}, defaultBrowserOptions); + options.args = [server.EMPTY_PAGE].concat(options.args || []); + const browser = await puppeteer.launch(options); + const pages = await browser.pages(); + expect(pages.length).toBe(1); + const page = pages[0]; + if (page.url() !== server.EMPTY_PAGE) await page.waitForNavigation(); + expect(page.url()).toBe(server.EMPTY_PAGE); + await browser.close(); + } + ); + it('should set the default viewport', async () => { + const { puppeteer, defaultBrowserOptions } = getTestState(); + const options = Object.assign({}, defaultBrowserOptions, { + defaultViewport: { + width: 456, + height: 789, + }, + }); + const browser = await puppeteer.launch(options); + const page = await browser.newPage(); + expect(await page.evaluate('window.innerWidth')).toBe(456); + expect(await page.evaluate('window.innerHeight')).toBe(789); + await browser.close(); + }); + it('should disable the default viewport', async () => { + const { puppeteer, defaultBrowserOptions } = getTestState(); + const options = Object.assign({}, defaultBrowserOptions, { + defaultViewport: null, + }); + const browser = await puppeteer.launch(options); + const page = await browser.newPage(); + expect(page.viewport()).toBe(null); + await browser.close(); + }); + it('should take fullPage screenshots when defaultViewport is null', async () => { + const { server, puppeteer, defaultBrowserOptions } = getTestState(); + + const options = Object.assign({}, defaultBrowserOptions, { + defaultViewport: null, + }); + const browser = await puppeteer.launch(options); + const page = await browser.newPage(); + await page.goto(server.PREFIX + '/grid.html'); + const screenshot = await page.screenshot({ + fullPage: true, + }); + expect(screenshot).toBeInstanceOf(Buffer); + await browser.close(); + }); + }); + + describe('Puppeteer.launch', function () { + let productName; + + before(async () => { + const { puppeteer } = getTestState(); + productName = puppeteer._productName; + }); + + after(async () => { + const { puppeteer } = getTestState(); + // @ts-expect-error launcher is a private property that users can't + // touch, but for testing purposes we need to reset it. + puppeteer._lazyLauncher = undefined; + puppeteer._productName = productName; + }); + + itOnlyRegularInstall('should be able to launch Chrome', async () => { + const { puppeteer } = getTestState(); + const browser = await puppeteer.launch({ product: 'chrome' }); + const userAgent = await browser.userAgent(); + await browser.close(); + expect(userAgent).toContain('Chrome'); + }); + + it('falls back to launching chrome if there is an unknown product but logs a warning', async () => { + const { puppeteer } = getTestState(); + const consoleStub = sinon.stub(console, 'warn'); + // @ts-expect-error purposeful bad input + const browser = await puppeteer.launch({ product: 'SO_NOT_A_PRODUCT' }); + const userAgent = await browser.userAgent(); + await browser.close(); + expect(userAgent).toContain('Chrome'); + expect(consoleStub.callCount).toEqual(1); + expect(consoleStub.firstCall.args).toEqual([ + 'Warning: unknown product name SO_NOT_A_PRODUCT. Falling back to chrome.', + ]); + }); + + /* We think there's a bug in the FF Windows launcher, or some + * combo of that plus it running on CI, but it's hard to track down. + * See comment here: https://github.com/puppeteer/puppeteer/issues/5673#issuecomment-670141377. + */ + itOnlyRegularInstall('should be able to launch Firefox', async function () { + this.timeout(FIREFOX_TIMEOUT); + const { puppeteer } = getTestState(); + const browser = await puppeteer.launch({ product: 'firefox' }); + const userAgent = await browser.userAgent(); + await browser.close(); + expect(userAgent).toContain('Firefox'); + }); + }); + + describe('Puppeteer.connect', function () { + it('should be able to connect multiple times to the same browser', async () => { + const { puppeteer, defaultBrowserOptions } = getTestState(); + + const originalBrowser = await puppeteer.launch(defaultBrowserOptions); + const otherBrowser = await puppeteer.connect({ + browserWSEndpoint: originalBrowser.wsEndpoint(), + }); + const page = await otherBrowser.newPage(); + expect(await page.evaluate(() => 7 * 8)).toBe(56); + otherBrowser.disconnect(); + + const secondPage = await originalBrowser.newPage(); + expect(await secondPage.evaluate(() => 7 * 6)).toBe(42); + await originalBrowser.close(); + }); + it('should be able to close remote browser', async () => { + const { defaultBrowserOptions, puppeteer } = getTestState(); + + const originalBrowser = await puppeteer.launch(defaultBrowserOptions); + const remoteBrowser = await puppeteer.connect({ + browserWSEndpoint: originalBrowser.wsEndpoint(), + }); + await Promise.all([ + utils.waitEvent(originalBrowser, 'disconnected'), + remoteBrowser.close(), + ]); + }); + it('should support ignoreHTTPSErrors option', async () => { + const { + httpsServer, + puppeteer, + defaultBrowserOptions, + } = getTestState(); + + const originalBrowser = await puppeteer.launch(defaultBrowserOptions); + const browserWSEndpoint = originalBrowser.wsEndpoint(); + + const browser = await puppeteer.connect({ + browserWSEndpoint, + ignoreHTTPSErrors: true, + }); + const page = await browser.newPage(); + let error = null; + const [serverRequest, response] = await Promise.all([ + httpsServer.waitForRequest('/empty.html'), + page.goto(httpsServer.EMPTY_PAGE).catch((error_) => (error = error_)), + ]); + expect(error).toBe(null); + expect(response.ok()).toBe(true); + expect(response.securityDetails()).toBeTruthy(); + const protocol = serverRequest.socket.getProtocol().replace('v', ' '); + expect(response.securityDetails().protocol()).toBe(protocol); + await page.close(); + await browser.close(); + }); + it( + 'should be able to reconnect to a disconnected browser', + async () => { + const { server, puppeteer, defaultBrowserOptions } = getTestState(); + + const originalBrowser = await puppeteer.launch(defaultBrowserOptions); + const browserWSEndpoint = originalBrowser.wsEndpoint(); + const page = await originalBrowser.newPage(); + await page.goto(server.PREFIX + '/frames/nested-frames.html'); + originalBrowser.disconnect(); + + const browser = await puppeteer.connect({ browserWSEndpoint }); + const pages = await browser.pages(); + const restoredPage = pages.find( + (page) => + page.url() === server.PREFIX + '/frames/nested-frames.html' + ); + expect(utils.dumpFrames(restoredPage.mainFrame())).toEqual([ + 'http://localhost:<PORT>/frames/nested-frames.html', + ' http://localhost:<PORT>/frames/two-frames.html (2frames)', + ' http://localhost:<PORT>/frames/frame.html (uno)', + ' http://localhost:<PORT>/frames/frame.html (dos)', + ' http://localhost:<PORT>/frames/frame.html (aframe)', + ]); + expect(await restoredPage.evaluate(() => 7 * 8)).toBe(56); + await browser.close(); + } + ); + // @see https://github.com/puppeteer/puppeteer/issues/4197#issuecomment-481793410 + it( + 'should be able to connect to the same page simultaneously', + async () => { + const { puppeteer } = getTestState(); + + const browserOne = await puppeteer.launch(); + const browserTwo = await puppeteer.connect({ + browserWSEndpoint: browserOne.wsEndpoint(), + }); + const [page1, page2] = await Promise.all([ + new Promise<Page>((x) => + browserOne.once('targetcreated', (target) => x(target.page())) + ), + browserTwo.newPage(), + ]); + expect(await page1.evaluate(() => 7 * 8)).toBe(56); + expect(await page2.evaluate(() => 7 * 6)).toBe(42); + await browserOne.close(); + } + ); + }); + describe('Puppeteer.executablePath', function () { + itOnlyRegularInstall('should work', async () => { + const { puppeteer } = getTestState(); + + const executablePath = puppeteer.executablePath(); + expect(fs.existsSync(executablePath)).toBe(true); + expect(fs.realpathSync(executablePath)).toBe(executablePath); + }); + }); + }); + + describe('Browser target events', function () { + it('should work', async () => { + const { server, puppeteer, defaultBrowserOptions } = getTestState(); + + const browser = await puppeteer.launch(defaultBrowserOptions); + const events = []; + browser.on('targetcreated', () => events.push('CREATED')); + browser.on('targetchanged', () => events.push('CHANGED')); + browser.on('targetdestroyed', () => events.push('DESTROYED')); + const page = await browser.newPage(); + await page.goto(server.EMPTY_PAGE); + await page.close(); + expect(events).toEqual(['CREATED', 'CHANGED', 'DESTROYED']); + await browser.close(); + }); + }); + + describe('Browser.Events.disconnected', function () { + it('should be emitted when: browser gets closed, disconnected or underlying websocket gets closed', async () => { + const { puppeteer, defaultBrowserOptions } = getTestState(); + const originalBrowser = await puppeteer.launch(defaultBrowserOptions); + const browserWSEndpoint = originalBrowser.wsEndpoint(); + const remoteBrowser1 = await puppeteer.connect({ browserWSEndpoint }); + const remoteBrowser2 = await puppeteer.connect({ browserWSEndpoint }); + + let disconnectedOriginal = 0; + let disconnectedRemote1 = 0; + let disconnectedRemote2 = 0; + originalBrowser.on('disconnected', () => ++disconnectedOriginal); + remoteBrowser1.on('disconnected', () => ++disconnectedRemote1); + remoteBrowser2.on('disconnected', () => ++disconnectedRemote2); + + await Promise.all([ + utils.waitEvent(remoteBrowser2, 'disconnected'), + remoteBrowser2.disconnect(), + ]); + + expect(disconnectedOriginal).toBe(0); + expect(disconnectedRemote1).toBe(0); + expect(disconnectedRemote2).toBe(1); + + await Promise.all([ + utils.waitEvent(remoteBrowser1, 'disconnected'), + utils.waitEvent(originalBrowser, 'disconnected'), + originalBrowser.close(), + ]); + + expect(disconnectedOriginal).toBe(1); + expect(disconnectedRemote1).toBe(1); + expect(disconnectedRemote2).toBe(1); + }); + }); +}); diff --git a/remote/test/puppeteer/test/mocha-ts-require.js b/remote/test/puppeteer/test/mocha-ts-require.js new file mode 100644 index 0000000000..a0ac64fa62 --- /dev/null +++ b/remote/test/puppeteer/test/mocha-ts-require.js @@ -0,0 +1,11 @@ +const path = require('path'); + +require('ts-node').register({ + /** + * We ignore the lib/ directory because that's already been TypeScript + * compiled and checked. So we don't want to check it again as part of running + * the unit tests. + */ + ignore: ['lib/*', 'node_modules'], + project: path.join(__dirname, 'tsconfig.test.json'), +}); diff --git a/remote/test/puppeteer/test/mocha-utils.ts b/remote/test/puppeteer/test/mocha-utils.ts new file mode 100644 index 0000000000..d13c82701c --- /dev/null +++ b/remote/test/puppeteer/test/mocha-utils.ts @@ -0,0 +1,279 @@ +/** + * Copyright 2020 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. + */ + +import { TestServer } from '../utils/testserver/index.js'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as os from 'os'; +import sinon from 'sinon'; +import puppeteer from '../lib/cjs/puppeteer/node.js'; +import { + Browser, + BrowserContext, +} from '../lib/cjs/puppeteer/common/Browser.js'; +import { Page } from '../lib/cjs/puppeteer/common/Page.js'; +import { PuppeteerNode } from '../lib/cjs/puppeteer/node/Puppeteer.js'; +import utils from './utils.js'; +import rimraf from 'rimraf'; + +import { trackCoverage } from './coverage-utils.js'; + +const setupServer = async () => { + const assetsPath = path.join(__dirname, 'assets'); + const cachedPath = path.join(__dirname, 'assets', 'cached'); + + const port = 8907; + const server = await TestServer.create(assetsPath, port); + server.enableHTTPCache(cachedPath); + server.PORT = port; + server.PREFIX = `http://localhost:${port}`; + server.CROSS_PROCESS_PREFIX = `http://127.0.0.1:${port}`; + server.EMPTY_PAGE = `http://localhost:${port}/empty.html`; + + const httpsPort = port + 1; + const httpsServer = await TestServer.createHTTPS(assetsPath, httpsPort); + httpsServer.enableHTTPCache(cachedPath); + httpsServer.PORT = httpsPort; + httpsServer.PREFIX = `https://localhost:${httpsPort}`; + httpsServer.CROSS_PROCESS_PREFIX = `https://127.0.0.1:${httpsPort}`; + httpsServer.EMPTY_PAGE = `https://localhost:${httpsPort}/empty.html`; + + return { server, httpsServer }; +}; + +export const getTestState = (): PuppeteerTestState => + state as PuppeteerTestState; + +const product = + process.env.PRODUCT || process.env.PUPPETEER_PRODUCT || 'Chromium'; + +const alternativeInstall = process.env.PUPPETEER_ALT_INSTALL || false; + +const isHeadless = + (process.env.HEADLESS || 'true').trim().toLowerCase() === 'true'; +const isFirefox = product === 'firefox'; +const isChrome = product === 'Chromium'; + +let extraLaunchOptions = {}; +try { + extraLaunchOptions = JSON.parse(process.env.EXTRA_LAUNCH_OPTIONS || '{}'); +} catch (error) { + console.warn( + `Error parsing EXTRA_LAUNCH_OPTIONS: ${error.message}. Skipping.` + ); +} + +const defaultBrowserOptions = Object.assign( + { + handleSIGINT: true, + executablePath: process.env.BINARY, + headless: isHeadless, + dumpio: !!process.env.DUMPIO, + }, + extraLaunchOptions +); + +(async (): Promise<void> => { + if (defaultBrowserOptions.executablePath) { + console.warn( + `WARN: running ${product} tests with ${defaultBrowserOptions.executablePath}` + ); + } else { + // TODO(jackfranklin): declare updateRevision in some form for the Firefox + // launcher. + // @ts-expect-error _updateRevision is defined on the FF launcher + // but not the Chrome one. The types need tidying so that TS can infer that + // properly and not error here. + if (product === 'firefox') await puppeteer._launcher._updateRevision(); + const executablePath = puppeteer.executablePath(); + if (!fs.existsSync(executablePath)) + throw new Error( + `Browser is not downloaded at ${executablePath}. Run 'npm install' and try to re-run tests` + ); + } +})(); + +declare module 'expect/build/types' { + interface Matchers<R> { + toBeGolden(x: string): R; + } +} + +const setupGoldenAssertions = (): void => { + const suffix = product.toLowerCase(); + const GOLDEN_DIR = path.join(__dirname, 'golden-' + suffix); + const OUTPUT_DIR = path.join(__dirname, 'output-' + suffix); + if (fs.existsSync(OUTPUT_DIR)) rimraf.sync(OUTPUT_DIR); + utils.extendExpectWithToBeGolden(GOLDEN_DIR, OUTPUT_DIR); +}; + +setupGoldenAssertions(); + +interface PuppeteerTestState { + browser: Browser; + context: BrowserContext; + page: Page; + puppeteer: PuppeteerNode; + defaultBrowserOptions: { + [x: string]: any; + }; + server: any; + httpsServer: any; + isFirefox: boolean; + isChrome: boolean; + isHeadless: boolean; + puppeteerPath: string; +} +const state: Partial<PuppeteerTestState> = {}; + +export const itFailsFirefox = ( + description: string, + body: Mocha.Func +): Mocha.Test => { + if (isFirefox) return xit(description, body); + else return it(description, body); +}; + +export const itChromeOnly = ( + description: string, + body: Mocha.Func +): Mocha.Test => { + if (isChrome) return it(description, body); + else return xit(description, body); +}; + +export const itOnlyRegularInstall = ( + description: string, + body: Mocha.Func +): Mocha.Test => { + if (alternativeInstall || process.env.BINARY) return xit(description, body); + else return it(description, body); +}; + +export const itFailsWindowsUntilDate = ( + date: Date, + description: string, + body: Mocha.Func +): Mocha.Test => { + if (os.platform() === 'win32' && Date.now() < date.getTime()) { + // we are within the deferred time so skip the test + return xit(description, body); + } + + return it(description, body); +}; + +export const itFailsWindows = (description: string, body: Mocha.Func) => { + if (os.platform() === 'win32') { + return xit(description, body); + } + return it(description, body); +}; + +export const describeFailsFirefox = ( + description: string, + body: (this: Mocha.Suite) => void +): void | Mocha.Suite => { + if (isFirefox) return xdescribe(description, body); + else return describe(description, body); +}; + +export const describeChromeOnly = ( + description: string, + body: (this: Mocha.Suite) => void +): Mocha.Suite => { + if (isChrome) return describe(description, body); +}; + +let coverageHooks = { + beforeAll: (): void => {}, + afterAll: (): void => {}, +}; + +if (process.env.COVERAGE) { + coverageHooks = trackCoverage(); +} + +console.log( + `Running unit tests with: + -> product: ${product} + -> binary: ${ + defaultBrowserOptions.executablePath || + path.relative(process.cwd(), puppeteer.executablePath()) + }` +); + +export const setupTestBrowserHooks = () => { + before(async () => { + const browser = await puppeteer.launch(defaultBrowserOptions); + state.browser = browser; + }); + + after(async () => { + await state.browser.close(); + state.browser = null; + }); +}; + +export const setupTestPageAndContextHooks = () => { + beforeEach(async () => { + state.context = await state.browser.createIncognitoBrowserContext(); + state.page = await state.context.newPage(); + }); + + afterEach(async () => { + await state.context.close(); + state.context = null; + state.page = null; + }); +}; + +export const mochaHooks = { + beforeAll: [ + async () => { + const { server, httpsServer } = await setupServer(); + + state.puppeteer = puppeteer; + state.defaultBrowserOptions = defaultBrowserOptions; + state.server = server; + state.httpsServer = httpsServer; + state.isFirefox = isFirefox; + state.isChrome = isChrome; + state.isHeadless = isHeadless; + state.puppeteerPath = path.resolve(path.join(__dirname, '..')); + }, + coverageHooks.beforeAll, + ], + + beforeEach: async () => { + state.server.reset(); + state.httpsServer.reset(); + }, + + afterAll: [ + async () => { + await state.server.stop(); + state.server = null; + await state.httpsServer.stop(); + state.httpsServer = null; + }, + coverageHooks.afterAll, + ], + + afterEach: () => { + sinon.restore(); + }, +}; diff --git a/remote/test/puppeteer/test/mouse.spec.ts b/remote/test/puppeteer/test/mouse.spec.ts new file mode 100644 index 0000000000..fadd9b9b95 --- /dev/null +++ b/remote/test/puppeteer/test/mouse.spec.ts @@ -0,0 +1,241 @@ +/** + * 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. + */ +import os from 'os'; +import expect from 'expect'; +import { + getTestState, + setupTestBrowserHooks, + setupTestPageAndContextHooks, +} from './mocha-utils'; // eslint-disable-line import/extensions +import { KeyInput } from '../lib/cjs/puppeteer/common/USKeyboardLayout.js'; + +interface Dimensions { + x: number; + y: number; + width: number; + height: number; +} + +function dimensions(): Dimensions { + const rect = document.querySelector('textarea').getBoundingClientRect(); + return { + x: rect.left, + y: rect.top, + width: rect.width, + height: rect.height, + }; +} + +describe('Mouse', function () { + setupTestBrowserHooks(); + setupTestPageAndContextHooks(); + it('should click the document', async () => { + const { page } = getTestState(); + + await page.evaluate(() => { + globalThis.clickPromise = new Promise((resolve) => { + document.addEventListener('click', (event) => { + resolve({ + type: event.type, + detail: event.detail, + clientX: event.clientX, + clientY: event.clientY, + isTrusted: event.isTrusted, + button: event.button, + }); + }); + }); + }); + await page.mouse.click(50, 60); + const event = await page.evaluate<() => MouseEvent>( + () => globalThis.clickPromise + ); + expect(event.type).toBe('click'); + expect(event.detail).toBe(1); + expect(event.clientX).toBe(50); + expect(event.clientY).toBe(60); + expect(event.isTrusted).toBe(true); + expect(event.button).toBe(0); + }); + it('should resize the textarea', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/input/textarea.html'); + const { x, y, width, height } = await page.evaluate<() => Dimensions>( + dimensions + ); + const mouse = page.mouse; + await mouse.move(x + width - 4, y + height - 4); + await mouse.down(); + await mouse.move(x + width + 100, y + height + 100); + await mouse.up(); + const newDimensions = await page.evaluate<() => Dimensions>(dimensions); + expect(newDimensions.width).toBe(Math.round(width + 104)); + expect(newDimensions.height).toBe(Math.round(height + 104)); + }); + it('should select the text with mouse', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/input/textarea.html'); + await page.focus('textarea'); + const text = + "This is the text that we are going to try to select. Let's see how it goes."; + await page.keyboard.type(text); + // Firefox needs an extra frame here after typing or it will fail to set the scrollTop + await page.evaluate(() => new Promise(requestAnimationFrame)); + await page.evaluate( + () => (document.querySelector('textarea').scrollTop = 0) + ); + const { x, y } = await page.evaluate(dimensions); + await page.mouse.move(x + 2, y + 2); + await page.mouse.down(); + await page.mouse.move(100, 100); + await page.mouse.up(); + expect( + await page.evaluate(() => { + const textarea = document.querySelector('textarea'); + return textarea.value.substring( + textarea.selectionStart, + textarea.selectionEnd + ); + }) + ).toBe(text); + }); + it('should trigger hover state', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/input/scrollable.html'); + await page.hover('#button-6'); + expect( + await page.evaluate(() => document.querySelector('button:hover').id) + ).toBe('button-6'); + await page.hover('#button-2'); + expect( + await page.evaluate(() => document.querySelector('button:hover').id) + ).toBe('button-2'); + await page.hover('#button-91'); + expect( + await page.evaluate(() => document.querySelector('button:hover').id) + ).toBe('button-91'); + }); + it( + 'should trigger hover state with removed window.Node', + async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/input/scrollable.html'); + await page.evaluate(() => delete window.Node); + await page.hover('#button-6'); + expect( + await page.evaluate(() => document.querySelector('button:hover').id) + ).toBe('button-6'); + } + ); + it('should set modifier keys on click', async () => { + const { page, server, isFirefox } = getTestState(); + + await page.goto(server.PREFIX + '/input/scrollable.html'); + await page.evaluate(() => + document + .querySelector('#button-3') + .addEventListener('mousedown', (e) => (globalThis.lastEvent = e), true) + ); + const modifiers = new Map<KeyInput, string>([ + ['Shift', 'shiftKey'], + ['Control', 'ctrlKey'], + ['Alt', 'altKey'], + ['Meta', 'metaKey'], + ]); + // In Firefox, the Meta modifier only exists on Mac + if (isFirefox && os.platform() !== 'darwin') delete modifiers['Meta']; + for (const [modifier, key] of modifiers) { + await page.keyboard.down(modifier); + await page.click('#button-3'); + if ( + !(await page.evaluate((mod: string) => globalThis.lastEvent[mod], key)) + ) + throw new Error(key + ' should be true'); + await page.keyboard.up(modifier); + } + await page.click('#button-3'); + for (const [modifier, key] of modifiers) { + if (await page.evaluate((mod: string) => globalThis.lastEvent[mod], key)) + throw new Error(modifiers[modifier] + ' should be false'); + } + }); + it('should send mouse wheel events', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/input/wheel.html'); + const elem = await page.$('div'); + const boundingBoxBefore = await elem.boundingBox(); + expect(boundingBoxBefore).toMatchObject({ + width: 115, + height: 115, + }); + + await page.mouse.move( + boundingBoxBefore.x + boundingBoxBefore.width / 2, + boundingBoxBefore.y + boundingBoxBefore.height / 2 + ); + + await page.mouse.wheel({ deltaY: -100 }); + const boundingBoxAfter = await elem.boundingBox(); + expect(boundingBoxAfter).toMatchObject({ + width: 230, + height: 230, + }); + }); + it('should tween mouse movement', async () => { + const { page } = getTestState(); + + await page.mouse.move(100, 100); + await page.evaluate(() => { + globalThis.result = []; + document.addEventListener('mousemove', (event) => { + globalThis.result.push([event.clientX, event.clientY]); + }); + }); + await page.mouse.move(200, 300, { steps: 5 }); + expect(await page.evaluate('result')).toEqual([ + [120, 140], + [140, 180], + [160, 220], + [180, 260], + [200, 300], + ]); + }); + // @see https://crbug.com/929806 + it( + 'should work with mobile viewports and cross process navigations', + async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.setViewport({ width: 360, height: 640, isMobile: true }); + await page.goto(server.CROSS_PROCESS_PREFIX + '/mobile.html'); + await page.evaluate(() => { + document.addEventListener('click', (event) => { + globalThis.result = { x: event.clientX, y: event.clientY }; + }); + }); + + await page.mouse.click(30, 40); + + expect(await page.evaluate('result')).toEqual({ x: 30, y: 40 }); + } + ); +}); diff --git a/remote/test/puppeteer/test/navigation.spec.ts b/remote/test/puppeteer/test/navigation.spec.ts new file mode 100644 index 0000000000..205d98a0b0 --- /dev/null +++ b/remote/test/puppeteer/test/navigation.spec.ts @@ -0,0 +1,774 @@ +/** + * 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. + */ + +import utils from './utils.js'; +import expect from 'expect'; +import { + getTestState, + setupTestBrowserHooks, + setupTestPageAndContextHooks, +} from './mocha-utils'; // eslint-disable-line import/extensions +import os from 'os'; + +describe('navigation', function () { + setupTestBrowserHooks(); + setupTestPageAndContextHooks(); + describe('Page.goto', function () { + it('should work', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + expect(page.url()).toBe(server.EMPTY_PAGE); + }); + it('should work with anchor navigation', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + expect(page.url()).toBe(server.EMPTY_PAGE); + await page.goto(server.EMPTY_PAGE + '#foo'); + expect(page.url()).toBe(server.EMPTY_PAGE + '#foo'); + await page.goto(server.EMPTY_PAGE + '#bar'); + expect(page.url()).toBe(server.EMPTY_PAGE + '#bar'); + }); + it('should work with redirects', async () => { + const { page, server } = getTestState(); + + server.setRedirect('/redirect/1.html', '/redirect/2.html'); + server.setRedirect('/redirect/2.html', '/empty.html'); + await page.goto(server.PREFIX + '/redirect/1.html'); + expect(page.url()).toBe(server.EMPTY_PAGE); + }); + it('should navigate to about:blank', async () => { + const { page } = getTestState(); + + const response = await page.goto('about:blank'); + expect(response).toBe(null); + }); + it('should return response when page changes its URL after load', async () => { + const { page, server } = getTestState(); + + const response = await page.goto(server.PREFIX + '/historyapi.html'); + expect(response.status()).toBe(200); + }); + it('should work with subframes return 204', async () => { + const { page, server } = getTestState(); + + server.setRoute('/frames/frame.html', (req, res) => { + res.statusCode = 204; + res.end(); + }); + let error = null; + await page + .goto(server.PREFIX + '/frames/one-frame.html') + .catch((error_) => (error = error_)); + expect(error).toBe(null); + }); + it('should fail when server returns 204', async () => { + const { page, server, isChrome } = getTestState(); + + server.setRoute('/empty.html', (req, res) => { + res.statusCode = 204; + res.end(); + }); + let error = null; + await page.goto(server.EMPTY_PAGE).catch((error_) => (error = error_)); + expect(error).not.toBe(null); + if (isChrome) expect(error.message).toContain('net::ERR_ABORTED'); + else expect(error.message).toContain('NS_BINDING_ABORTED'); + }); + it('should navigate to empty page with domcontentloaded', async () => { + const { page, server } = getTestState(); + + const response = await page.goto(server.EMPTY_PAGE, { + waitUntil: 'domcontentloaded', + }); + expect(response.status()).toBe(200); + }); + it('should work when page calls history API in beforeunload', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.evaluate(() => { + window.addEventListener( + 'beforeunload', + () => history.replaceState(null, 'initial', window.location.href), + false + ); + }); + const response = await page.goto(server.PREFIX + '/grid.html'); + expect(response.status()).toBe(200); + }); + it( + 'should navigate to empty page with networkidle0', + async () => { + const { page, server } = getTestState(); + + const response = await page.goto(server.EMPTY_PAGE, { + waitUntil: 'networkidle0', + }); + expect(response.status()).toBe(200); + } + ); + it( + 'should navigate to empty page with networkidle2', + async () => { + const { page, server } = getTestState(); + + const response = await page.goto(server.EMPTY_PAGE, { + waitUntil: 'networkidle2', + }); + expect(response.status()).toBe(200); + } + ); + it('should fail when navigating to bad url', async () => { + const { page, isChrome } = getTestState(); + + let error = null; + await page.goto('asdfasdf').catch((error_) => (error = error_)); + if (isChrome) + expect(error.message).toContain('Cannot navigate to invalid URL'); + else expect(error.message).toContain('Invalid url'); + }); + + /* If you are running this on pre-Catalina versions of macOS this will fail locally. + /* Mac OSX Catalina outputs a different message than other platforms. + * See https://support.google.com/chrome/thread/18125056?hl=en for details. + * If you're running pre-Catalina Mac OSX this test will fail locally. + */ + const EXPECTED_SSL_CERT_MESSAGE = + os.platform() === 'darwin' + ? 'net::ERR_CERT_INVALID' + : 'net::ERR_CERT_AUTHORITY_INVALID'; + + it('should fail when navigating to bad SSL', async () => { + const { page, httpsServer, isChrome } = getTestState(); + + // Make sure that network events do not emit 'undefined'. + // @see https://crbug.com/750469 + const requests = []; + page.on('request', () => requests.push('request')); + page.on('requestfinished', () => requests.push('requestfinished')); + page.on('requestfailed', () => requests.push('requestfailed')); + + let error = null; + await page + .goto(httpsServer.EMPTY_PAGE) + .catch((error_) => (error = error_)); + if (isChrome) expect(error.message).toContain(EXPECTED_SSL_CERT_MESSAGE); + else expect(error.message).toContain('SSL_ERROR_UNKNOWN'); + + expect(requests.length).toBe(2); + expect(requests[0]).toBe('request'); + expect(requests[1]).toBe('requestfailed'); + }); + it('should fail when navigating to bad SSL after redirects', async () => { + const { page, server, httpsServer, isChrome } = getTestState(); + + server.setRedirect('/redirect/1.html', '/redirect/2.html'); + server.setRedirect('/redirect/2.html', '/empty.html'); + let error = null; + await page + .goto(httpsServer.PREFIX + '/redirect/1.html') + .catch((error_) => (error = error_)); + if (isChrome) expect(error.message).toContain(EXPECTED_SSL_CERT_MESSAGE); + else expect(error.message).toContain('SSL_ERROR_UNKNOWN'); + }); + it('should throw if networkidle is passed as an option', async () => { + const { page, server } = getTestState(); + + let error = null; + await page + // @ts-expect-error purposefully passing an old option + .goto(server.EMPTY_PAGE, { waitUntil: 'networkidle' }) + .catch((error_) => (error = error_)); + expect(error.message).toContain( + '"networkidle" option is no longer supported' + ); + }); + it('should fail when main resources failed to load', async () => { + const { page, isChrome } = getTestState(); + + let error = null; + await page + .goto('http://localhost:44123/non-existing-url') + .catch((error_) => (error = error_)); + if (isChrome) + expect(error.message).toContain('net::ERR_CONNECTION_REFUSED'); + else expect(error.message).toContain('NS_ERROR_CONNECTION_REFUSED'); + }); + it('should fail when exceeding maximum navigation timeout', async () => { + const { page, server, puppeteer } = getTestState(); + + // Hang for request to the empty.html + server.setRoute('/empty.html', () => {}); + let error = null; + await page + .goto(server.PREFIX + '/empty.html', { timeout: 1 }) + .catch((error_) => (error = error_)); + expect(error.message).toContain('Navigation timeout of 1 ms exceeded'); + expect(error).toBeInstanceOf(puppeteer.errors.TimeoutError); + }); + it('should fail when exceeding default maximum navigation timeout', async () => { + const { page, server, puppeteer } = getTestState(); + + // Hang for request to the empty.html + server.setRoute('/empty.html', () => {}); + let error = null; + page.setDefaultNavigationTimeout(1); + await page + .goto(server.PREFIX + '/empty.html') + .catch((error_) => (error = error_)); + expect(error.message).toContain('Navigation timeout of 1 ms exceeded'); + expect(error).toBeInstanceOf(puppeteer.errors.TimeoutError); + }); + it('should fail when exceeding default maximum timeout', async () => { + const { page, server, puppeteer } = getTestState(); + + // Hang for request to the empty.html + server.setRoute('/empty.html', () => {}); + let error = null; + page.setDefaultTimeout(1); + await page + .goto(server.PREFIX + '/empty.html') + .catch((error_) => (error = error_)); + expect(error.message).toContain('Navigation timeout of 1 ms exceeded'); + expect(error).toBeInstanceOf(puppeteer.errors.TimeoutError); + }); + it('should prioritize default navigation timeout over default timeout', async () => { + const { page, server, puppeteer } = getTestState(); + + // Hang for request to the empty.html + server.setRoute('/empty.html', () => {}); + let error = null; + page.setDefaultTimeout(0); + page.setDefaultNavigationTimeout(1); + await page + .goto(server.PREFIX + '/empty.html') + .catch((error_) => (error = error_)); + expect(error.message).toContain('Navigation timeout of 1 ms exceeded'); + expect(error).toBeInstanceOf(puppeteer.errors.TimeoutError); + }); + it('should disable timeout when its set to 0', async () => { + const { page, server } = getTestState(); + + let error = null; + let loaded = false; + page.once('load', () => (loaded = true)); + await page + .goto(server.PREFIX + '/grid.html', { timeout: 0, waitUntil: ['load'] }) + .catch((error_) => (error = error_)); + expect(error).toBe(null); + expect(loaded).toBe(true); + }); + it('should work when navigating to valid url', async () => { + const { page, server } = getTestState(); + + const response = await page.goto(server.EMPTY_PAGE); + expect(response.ok()).toBe(true); + }); + it('should work when navigating to data url', async () => { + const { page } = getTestState(); + + const response = await page.goto('data:text/html,hello'); + expect(response.ok()).toBe(true); + }); + it('should work when navigating to 404', async () => { + const { page, server } = getTestState(); + + const response = await page.goto(server.PREFIX + '/not-found'); + expect(response.ok()).toBe(false); + expect(response.status()).toBe(404); + }); + it('should return last response in redirect chain', async () => { + const { page, server } = getTestState(); + + server.setRedirect('/redirect/1.html', '/redirect/2.html'); + server.setRedirect('/redirect/2.html', '/redirect/3.html'); + server.setRedirect('/redirect/3.html', server.EMPTY_PAGE); + const response = await page.goto(server.PREFIX + '/redirect/1.html'); + expect(response.ok()).toBe(true); + expect(response.url()).toBe(server.EMPTY_PAGE); + }); + it( + 'should wait for network idle to succeed navigation', + async () => { + const { page, server } = getTestState(); + + let responses = []; + // Hold on to a bunch of requests without answering. + server.setRoute('/fetch-request-a.js', (req, res) => + responses.push(res) + ); + server.setRoute('/fetch-request-b.js', (req, res) => + responses.push(res) + ); + server.setRoute('/fetch-request-c.js', (req, res) => + responses.push(res) + ); + server.setRoute('/fetch-request-d.js', (req, res) => + responses.push(res) + ); + const initialFetchResourcesRequested = Promise.all([ + server.waitForRequest('/fetch-request-a.js'), + server.waitForRequest('/fetch-request-b.js'), + server.waitForRequest('/fetch-request-c.js'), + ]); + const secondFetchResourceRequested = server.waitForRequest( + '/fetch-request-d.js' + ); + + // Navigate to a page which loads immediately and then does a bunch of + // requests via javascript's fetch method. + const navigationPromise = page.goto( + server.PREFIX + '/networkidle.html', + { + waitUntil: 'networkidle0', + } + ); + // Track when the navigation gets completed. + let navigationFinished = false; + navigationPromise.then(() => (navigationFinished = true)); + + // Wait for the page's 'load' event. + await new Promise((fulfill) => page.once('load', fulfill)); + expect(navigationFinished).toBe(false); + + // Wait for the initial three resources to be requested. + await initialFetchResourcesRequested; + + // Expect navigation still to be not finished. + expect(navigationFinished).toBe(false); + + // Respond to initial requests. + for (const response of responses) { + response.statusCode = 404; + response.end(`File not found`); + } + + // Reset responses array + responses = []; + + // Wait for the second round to be requested. + await secondFetchResourceRequested; + // Expect navigation still to be not finished. + expect(navigationFinished).toBe(false); + + // Respond to requests. + for (const response of responses) { + response.statusCode = 404; + response.end(`File not found`); + } + + const response = await navigationPromise; + // Expect navigation to succeed. + expect(response.ok()).toBe(true); + } + ); + it('should not leak listeners during navigation', async () => { + const { page, server } = getTestState(); + + let warning = null; + const warningHandler = (w) => (warning = w); + process.on('warning', warningHandler); + for (let i = 0; i < 20; ++i) await page.goto(server.EMPTY_PAGE); + process.removeListener('warning', warningHandler); + expect(warning).toBe(null); + }); + it('should not leak listeners during bad navigation', async () => { + const { page } = getTestState(); + + let warning = null; + const warningHandler = (w) => (warning = w); + process.on('warning', warningHandler); + for (let i = 0; i < 20; ++i) + await page.goto('asdf').catch(() => { + /* swallow navigation error */ + }); + process.removeListener('warning', warningHandler); + expect(warning).toBe(null); + }); + it('should not leak listeners during navigation of 11 pages', async () => { + const { context, server } = getTestState(); + + let warning = null; + const warningHandler = (w) => (warning = w); + process.on('warning', warningHandler); + await Promise.all( + [...Array(20)].map(async () => { + const page = await context.newPage(); + await page.goto(server.EMPTY_PAGE); + await page.close(); + }) + ); + process.removeListener('warning', warningHandler); + expect(warning).toBe(null); + }); + it( + 'should navigate to dataURL and fire dataURL requests', + async () => { + const { page } = getTestState(); + + const requests = []; + page.on( + 'request', + (request) => !utils.isFavicon(request) && requests.push(request) + ); + const dataURL = 'data:text/html,<div>yo</div>'; + const response = await page.goto(dataURL); + expect(response.status()).toBe(200); + expect(requests.length).toBe(1); + expect(requests[0].url()).toBe(dataURL); + } + ); + it( + 'should navigate to URL with hash and fire requests without hash', + async () => { + const { page, server } = getTestState(); + + const requests = []; + page.on( + 'request', + (request) => !utils.isFavicon(request) && requests.push(request) + ); + const response = await page.goto(server.EMPTY_PAGE + '#hash'); + expect(response.status()).toBe(200); + expect(response.url()).toBe(server.EMPTY_PAGE); + expect(requests.length).toBe(1); + expect(requests[0].url()).toBe(server.EMPTY_PAGE); + } + ); + it('should work with self requesting page', async () => { + const { page, server } = getTestState(); + + const response = await page.goto(server.PREFIX + '/self-request.html'); + expect(response.status()).toBe(200); + expect(response.url()).toContain('self-request.html'); + }); + it('should fail when navigating and show the url at the error message', async () => { + const { page, httpsServer } = getTestState(); + + const url = httpsServer.PREFIX + '/redirect/1.html'; + let error = null; + try { + await page.goto(url); + } catch (error_) { + error = error_; + } + expect(error.message).toContain(url); + }); + it('should send referer', async () => { + const { page, server } = getTestState(); + + const [request1, request2] = await Promise.all([ + server.waitForRequest('/grid.html'), + server.waitForRequest('/digits/1.png'), + page.goto(server.PREFIX + '/grid.html', { + referer: 'http://google.com/', + }), + ]); + expect(request1.headers['referer']).toBe('http://google.com/'); + // Make sure subresources do not inherit referer. + expect(request2.headers['referer']).toBe(server.PREFIX + '/grid.html'); + }); + }); + + describe('Page.waitForNavigation', function () { + it('should work', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + const [response] = await Promise.all([ + page.waitForNavigation(), + page.evaluate( + (url: string) => (window.location.href = url), + server.PREFIX + '/grid.html' + ), + ]); + expect(response.ok()).toBe(true); + expect(response.url()).toContain('grid.html'); + }); + it('should work with both domcontentloaded and load', async () => { + const { page, server } = getTestState(); + + let response = null; + server.setRoute('/one-style.css', (req, res) => (response = res)); + const navigationPromise = page.goto(server.PREFIX + '/one-style.html'); + const domContentLoadedPromise = page.waitForNavigation({ + waitUntil: 'domcontentloaded', + }); + + let bothFired = false; + const bothFiredPromise = page + .waitForNavigation({ + waitUntil: ['load', 'domcontentloaded'], + }) + .then(() => (bothFired = true)); + + await server.waitForRequest('/one-style.css'); + await domContentLoadedPromise; + expect(bothFired).toBe(false); + response.end(); + await bothFiredPromise; + await navigationPromise; + }); + it('should work with clicking on anchor links', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.setContent(`<a href='#foobar'>foobar</a>`); + const [response] = await Promise.all([ + page.waitForNavigation(), + page.click('a'), + ]); + expect(response).toBe(null); + expect(page.url()).toBe(server.EMPTY_PAGE + '#foobar'); + }); + it('should work with history.pushState()', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.setContent(` + <a onclick='javascript:pushState()'>SPA</a> + <script> + function pushState() { history.pushState({}, '', 'wow.html') } + </script> + `); + const [response] = await Promise.all([ + page.waitForNavigation(), + page.click('a'), + ]); + expect(response).toBe(null); + expect(page.url()).toBe(server.PREFIX + '/wow.html'); + }); + it('should work with history.replaceState()', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.setContent(` + <a onclick='javascript:replaceState()'>SPA</a> + <script> + function replaceState() { history.replaceState({}, '', '/replaced.html') } + </script> + `); + const [response] = await Promise.all([ + page.waitForNavigation(), + page.click('a'), + ]); + expect(response).toBe(null); + expect(page.url()).toBe(server.PREFIX + '/replaced.html'); + }); + it( + 'should work with DOM history.back()/history.forward()', + async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.setContent(` + <a id=back onclick='javascript:goBack()'>back</a> + <a id=forward onclick='javascript:goForward()'>forward</a> + <script> + function goBack() { history.back(); } + function goForward() { history.forward(); } + history.pushState({}, '', '/first.html'); + history.pushState({}, '', '/second.html'); + </script> + `); + expect(page.url()).toBe(server.PREFIX + '/second.html'); + const [backResponse] = await Promise.all([ + page.waitForNavigation(), + page.click('a#back'), + ]); + expect(backResponse).toBe(null); + expect(page.url()).toBe(server.PREFIX + '/first.html'); + const [forwardResponse] = await Promise.all([ + page.waitForNavigation(), + page.click('a#forward'), + ]); + expect(forwardResponse).toBe(null); + expect(page.url()).toBe(server.PREFIX + '/second.html'); + } + ); + it( + 'should work when subframe issues window.stop()', + async () => { + const { page, server } = getTestState(); + + server.setRoute('/frames/style.css', () => {}); + const navigationPromise = page.goto( + server.PREFIX + '/frames/one-frame.html' + ); + const frame = await utils.waitEvent(page, 'frameattached'); + await new Promise((fulfill) => { + page.on('framenavigated', (f) => { + if (f === frame) fulfill(); + }); + }); + await Promise.all([ + frame.evaluate(() => window.stop()), + navigationPromise, + ]); + } + ); + }); + + describe('Page.goBack', function () { + it('should work', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.goto(server.PREFIX + '/grid.html'); + + let response = await page.goBack(); + expect(response.ok()).toBe(true); + expect(response.url()).toContain(server.EMPTY_PAGE); + + response = await page.goForward(); + expect(response.ok()).toBe(true); + expect(response.url()).toContain('/grid.html'); + + response = await page.goForward(); + expect(response).toBe(null); + }); + it('should work with HistoryAPI', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.evaluate(() => { + history.pushState({}, '', '/first.html'); + history.pushState({}, '', '/second.html'); + }); + expect(page.url()).toBe(server.PREFIX + '/second.html'); + + await page.goBack(); + expect(page.url()).toBe(server.PREFIX + '/first.html'); + await page.goBack(); + expect(page.url()).toBe(server.EMPTY_PAGE); + await page.goForward(); + expect(page.url()).toBe(server.PREFIX + '/first.html'); + }); + }); + + describe('Frame.goto', function () { + it('should navigate subframes', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/frames/one-frame.html'); + expect(page.frames()[0].url()).toContain('/frames/one-frame.html'); + expect(page.frames()[1].url()).toContain('/frames/frame.html'); + + const response = await page.frames()[1].goto(server.EMPTY_PAGE); + expect(response.ok()).toBe(true); + expect(response.frame()).toBe(page.frames()[1]); + }); + it('should reject when frame detaches', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/frames/one-frame.html'); + + server.setRoute('/empty.html', () => {}); + const navigationPromise = page + .frames()[1] + .goto(server.EMPTY_PAGE) + .catch((error_) => error_); + await server.waitForRequest('/empty.html'); + + await page.$eval('iframe', (frame) => frame.remove()); + const error = await navigationPromise; + expect(error.message).toBe('Navigating frame was detached'); + }); + it('should return matching responses', async () => { + const { page, server } = getTestState(); + + // Disable cache: otherwise, chromium will cache similar requests. + await page.setCacheEnabled(false); + await page.goto(server.EMPTY_PAGE); + // Attach three frames. + const frames = await Promise.all([ + utils.attachFrame(page, 'frame1', server.EMPTY_PAGE), + utils.attachFrame(page, 'frame2', server.EMPTY_PAGE), + utils.attachFrame(page, 'frame3', server.EMPTY_PAGE), + ]); + // Navigate all frames to the same URL. + const serverResponses = []; + server.setRoute('/one-style.html', (req, res) => + serverResponses.push(res) + ); + const navigations = []; + for (let i = 0; i < 3; ++i) { + navigations.push(frames[i].goto(server.PREFIX + '/one-style.html')); + await server.waitForRequest('/one-style.html'); + } + // Respond from server out-of-order. + const serverResponseTexts = ['AAA', 'BBB', 'CCC']; + for (const i of [1, 2, 0]) { + serverResponses[i].end(serverResponseTexts[i]); + const response = await navigations[i]; + expect(response.frame()).toBe(frames[i]); + expect(await response.text()).toBe(serverResponseTexts[i]); + } + }); + }); + + describe('Frame.waitForNavigation', function () { + it('should work', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/frames/one-frame.html'); + const frame = page.frames()[1]; + const [response] = await Promise.all([ + frame.waitForNavigation(), + frame.evaluate( + (url: string) => (window.location.href = url), + server.PREFIX + '/grid.html' + ), + ]); + expect(response.ok()).toBe(true); + expect(response.url()).toContain('grid.html'); + expect(response.frame()).toBe(frame); + expect(page.url()).toContain('/frames/one-frame.html'); + }); + it('should fail when frame detaches', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/frames/one-frame.html'); + const frame = page.frames()[1]; + + server.setRoute('/empty.html', () => {}); + let error = null; + const navigationPromise = frame + .waitForNavigation() + .catch((error_) => (error = error_)); + await Promise.all([ + server.waitForRequest('/empty.html'), + frame.evaluate(() => ((window as any).location = '/empty.html')), + ]); + await page.$eval('iframe', (frame) => frame.remove()); + await navigationPromise; + expect(error.message).toBe('Navigating frame was detached'); + }); + }); + + describe('Page.reload', function () { + it('should work', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.evaluate(() => (globalThis._foo = 10)); + await page.reload(); + expect(await page.evaluate(() => globalThis._foo)).toBe(undefined); + }); + }); +}); diff --git a/remote/test/puppeteer/test/network.spec.ts b/remote/test/puppeteer/test/network.spec.ts new file mode 100644 index 0000000000..63b2dfb948 --- /dev/null +++ b/remote/test/puppeteer/test/network.spec.ts @@ -0,0 +1,571 @@ +/** + * 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. + */ + +import fs from 'fs'; +import path from 'path'; +import utils from './utils.js'; +import expect from 'expect'; +import { + getTestState, + setupTestBrowserHooks, + setupTestPageAndContextHooks, +} from './mocha-utils'; // eslint-disable-line import/extensions + +describe('network', function () { + setupTestBrowserHooks(); + setupTestPageAndContextHooks(); + + describe('Page.Events.Request', function () { + it('should fire for navigation requests', async () => { + const { page, server } = getTestState(); + + const requests = []; + page.on( + 'request', + (request) => !utils.isFavicon(request) && requests.push(request) + ); + await page.goto(server.EMPTY_PAGE); + expect(requests.length).toBe(1); + }); + it('should fire for iframes', async () => { + const { page, server } = getTestState(); + + const requests = []; + page.on( + 'request', + (request) => !utils.isFavicon(request) && requests.push(request) + ); + await page.goto(server.EMPTY_PAGE); + await utils.attachFrame(page, 'frame1', server.EMPTY_PAGE); + expect(requests.length).toBe(2); + }); + it('should fire for fetches', async () => { + const { page, server } = getTestState(); + + const requests = []; + page.on( + 'request', + (request) => !utils.isFavicon(request) && requests.push(request) + ); + await page.goto(server.EMPTY_PAGE); + await page.evaluate(() => fetch('/empty.html')); + expect(requests.length).toBe(2); + }); + }); + + describe('Request.frame', function () { + it('should work for main frame navigation request', async () => { + const { page, server } = getTestState(); + + const requests = []; + page.on( + 'request', + (request) => !utils.isFavicon(request) && requests.push(request) + ); + await page.goto(server.EMPTY_PAGE); + expect(requests.length).toBe(1); + expect(requests[0].frame()).toBe(page.mainFrame()); + }); + it('should work for subframe navigation request', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + const requests = []; + page.on( + 'request', + (request) => !utils.isFavicon(request) && requests.push(request) + ); + await utils.attachFrame(page, 'frame1', server.EMPTY_PAGE); + expect(requests.length).toBe(1); + expect(requests[0].frame()).toBe(page.frames()[1]); + }); + it('should work for fetch requests', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + let requests = []; + page.on( + 'request', + (request) => !utils.isFavicon(request) && requests.push(request) + ); + await page.evaluate(() => fetch('/digits/1.png')); + requests = requests.filter( + (request) => !request.url().includes('favicon') + ); + expect(requests.length).toBe(1); + expect(requests[0].frame()).toBe(page.mainFrame()); + }); + }); + + describe('Request.headers', function () { + it('should work', async () => { + const { page, server, isChrome } = getTestState(); + + const response = await page.goto(server.EMPTY_PAGE); + if (isChrome) + expect(response.request().headers()['user-agent']).toContain('Chrome'); + else + expect(response.request().headers()['user-agent']).toContain('Firefox'); + }); + }); + + describe('Response.headers', function () { + it('should work', async () => { + const { page, server } = getTestState(); + + server.setRoute('/empty.html', (req, res) => { + res.setHeader('foo', 'bar'); + res.end(); + }); + const response = await page.goto(server.EMPTY_PAGE); + expect(response.headers()['foo']).toBe('bar'); + }); + }); + + describe('Response.fromCache', function () { + it('should return |false| for non-cached content', async () => { + const { page, server } = getTestState(); + + const response = await page.goto(server.EMPTY_PAGE); + expect(response.fromCache()).toBe(false); + }); + + it('should work', async () => { + const { page, server } = getTestState(); + + const responses = new Map(); + page.on( + 'response', + (r) => + !utils.isFavicon(r.request()) && + responses.set(r.url().split('/').pop(), r) + ); + + // Load and re-load to make sure it's cached. + await page.goto(server.PREFIX + '/cached/one-style.html'); + await page.reload(); + + expect(responses.size).toBe(2); + expect(responses.get('one-style.css').status()).toBe(200); + expect(responses.get('one-style.css').fromCache()).toBe(true); + expect(responses.get('one-style.html').status()).toBe(304); + expect(responses.get('one-style.html').fromCache()).toBe(false); + }); + }); + + describe('Response.fromServiceWorker', function () { + it('should return |false| for non-service-worker content', async () => { + const { page, server } = getTestState(); + + const response = await page.goto(server.EMPTY_PAGE); + expect(response.fromServiceWorker()).toBe(false); + }); + + it('Response.fromServiceWorker', async () => { + const { page, server } = getTestState(); + + const responses = new Map(); + page.on('response', (r) => responses.set(r.url().split('/').pop(), r)); + + // Load and re-load to make sure serviceworker is installed and running. + await page.goto(server.PREFIX + '/serviceworkers/fetch/sw.html', { + waitUntil: 'networkidle2', + }); + await page.evaluate(async () => await globalThis.activationPromise); + await page.reload(); + + expect(responses.size).toBe(2); + expect(responses.get('sw.html').status()).toBe(200); + expect(responses.get('sw.html').fromServiceWorker()).toBe(true); + expect(responses.get('style.css').status()).toBe(200); + expect(responses.get('style.css').fromServiceWorker()).toBe(true); + }); + }); + + describe('Request.postData', function () { + it('should work', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + server.setRoute('/post', (req, res) => res.end()); + let request = null; + page.on('request', (r) => (request = r)); + await page.evaluate(() => + fetch('./post', { + method: 'POST', + body: JSON.stringify({ foo: 'bar' }), + }) + ); + expect(request).toBeTruthy(); + expect(request.postData()).toBe('{"foo":"bar"}'); + }); + it('should be |undefined| when there is no post data', async () => { + const { page, server } = getTestState(); + + const response = await page.goto(server.EMPTY_PAGE); + expect(response.request().postData()).toBe(undefined); + }); + }); + + describe('Response.text', function () { + it('should work', async () => { + const { page, server } = getTestState(); + + const response = await page.goto(server.PREFIX + '/simple.json'); + const responseText = (await response.text()).trimEnd(); + expect(responseText).toBe('{"foo": "bar"}'); + }); + it('should return uncompressed text', async () => { + const { page, server } = getTestState(); + + server.enableGzip('/simple.json'); + const response = await page.goto(server.PREFIX + '/simple.json'); + expect(response.headers()['content-encoding']).toBe('gzip'); + const responseText = (await response.text()).trimEnd(); + expect(responseText).toBe('{"foo": "bar"}'); + }); + it('should throw when requesting body of redirected response', async () => { + const { page, server } = getTestState(); + + server.setRedirect('/foo.html', '/empty.html'); + const response = await page.goto(server.PREFIX + '/foo.html'); + const redirectChain = response.request().redirectChain(); + expect(redirectChain.length).toBe(1); + const redirected = redirectChain[0].response(); + expect(redirected.status()).toBe(302); + let error = null; + await redirected.text().catch((error_) => (error = error_)); + expect(error.message).toContain( + 'Response body is unavailable for redirect responses' + ); + }); + it('should wait until response completes', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + // Setup server to trap request. + let serverResponse = null; + server.setRoute('/get', (req, res) => { + serverResponse = res; + // In Firefox, |fetch| will be hanging until it receives |Content-Type| header + // from server. + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.write('hello '); + }); + // Setup page to trap response. + let requestFinished = false; + page.on( + 'requestfinished', + (r) => (requestFinished = requestFinished || r.url().includes('/get')) + ); + // send request and wait for server response + const [pageResponse] = await Promise.all([ + page.waitForResponse((r) => !utils.isFavicon(r.request())), + page.evaluate(() => fetch('./get', { method: 'GET' })), + server.waitForRequest('/get'), + ]); + + expect(serverResponse).toBeTruthy(); + expect(pageResponse).toBeTruthy(); + expect(pageResponse.status()).toBe(200); + expect(requestFinished).toBe(false); + + const responseText = pageResponse.text(); + // Write part of the response and wait for it to be flushed. + await new Promise((x) => serverResponse.write('wor', x)); + // Finish response. + await new Promise((x) => serverResponse.end('ld!', x)); + expect(await responseText).toBe('hello world!'); + }); + }); + + describe('Response.json', function () { + it('should work', async () => { + const { page, server } = getTestState(); + + const response = await page.goto(server.PREFIX + '/simple.json'); + expect(await response.json()).toEqual({ foo: 'bar' }); + }); + }); + + describe('Response.buffer', function () { + it('should work', async () => { + const { page, server } = getTestState(); + + const response = await page.goto(server.PREFIX + '/pptr.png'); + const imageBuffer = fs.readFileSync( + path.join(__dirname, 'assets', 'pptr.png') + ); + const responseBuffer = await response.buffer(); + expect(responseBuffer.equals(imageBuffer)).toBe(true); + }); + it('should work with compression', async () => { + const { page, server } = getTestState(); + + server.enableGzip('/pptr.png'); + const response = await page.goto(server.PREFIX + '/pptr.png'); + const imageBuffer = fs.readFileSync( + path.join(__dirname, 'assets', 'pptr.png') + ); + const responseBuffer = await response.buffer(); + expect(responseBuffer.equals(imageBuffer)).toBe(true); + }); + }); + + describe('Response.statusText', function () { + it('should work', async () => { + const { page, server } = getTestState(); + + server.setRoute('/cool', (req, res) => { + res.writeHead(200, 'cool!'); + res.end(); + }); + const response = await page.goto(server.PREFIX + '/cool'); + expect(response.statusText()).toBe('cool!'); + }); + }); + + describe('Network Events', function () { + it('Page.Events.Request', async () => { + const { page, server } = getTestState(); + + const requests = []; + page.on('request', (request) => requests.push(request)); + await page.goto(server.EMPTY_PAGE); + expect(requests.length).toBe(1); + expect(requests[0].url()).toBe(server.EMPTY_PAGE); + expect(requests[0].resourceType()).toBe('document'); + expect(requests[0].method()).toBe('GET'); + expect(requests[0].response()).toBeTruthy(); + expect(requests[0].frame() === page.mainFrame()).toBe(true); + expect(requests[0].frame().url()).toBe(server.EMPTY_PAGE); + }); + it('Page.Events.Response', async () => { + const { page, server } = getTestState(); + + const responses = []; + page.on('response', (response) => responses.push(response)); + await page.goto(server.EMPTY_PAGE); + expect(responses.length).toBe(1); + expect(responses[0].url()).toBe(server.EMPTY_PAGE); + expect(responses[0].status()).toBe(200); + expect(responses[0].ok()).toBe(true); + expect(responses[0].request()).toBeTruthy(); + const remoteAddress = responses[0].remoteAddress(); + // Either IPv6 or IPv4, depending on environment. + expect( + remoteAddress.ip.includes('::1') || remoteAddress.ip === '127.0.0.1' + ).toBe(true); + expect(remoteAddress.port).toBe(server.PORT); + }); + + it('Page.Events.RequestFailed', async () => { + const { page, server, isChrome } = getTestState(); + + await page.setRequestInterception(true); + page.on('request', (request) => { + if (request.url().endsWith('css')) request.abort(); + else request.continue(); + }); + const failedRequests = []; + page.on('requestfailed', (request) => failedRequests.push(request)); + await page.goto(server.PREFIX + '/one-style.html'); + expect(failedRequests.length).toBe(1); + expect(failedRequests[0].url()).toContain('one-style.css'); + expect(failedRequests[0].response()).toBe(null); + expect(failedRequests[0].resourceType()).toBe('stylesheet'); + if (isChrome) + expect(failedRequests[0].failure().errorText).toBe('net::ERR_FAILED'); + else + expect(failedRequests[0].failure().errorText).toBe('NS_ERROR_FAILURE'); + expect(failedRequests[0].frame()).toBeTruthy(); + }); + it('Page.Events.RequestFinished', async () => { + const { page, server } = getTestState(); + + const requests = []; + page.on('requestfinished', (request) => requests.push(request)); + await page.goto(server.EMPTY_PAGE); + expect(requests.length).toBe(1); + expect(requests[0].url()).toBe(server.EMPTY_PAGE); + expect(requests[0].response()).toBeTruthy(); + expect(requests[0].frame() === page.mainFrame()).toBe(true); + expect(requests[0].frame().url()).toBe(server.EMPTY_PAGE); + }); + it('should fire events in proper order', async () => { + const { page, server } = getTestState(); + + const events = []; + page.on('request', () => events.push('request')); + page.on('response', () => events.push('response')); + page.on('requestfinished', () => events.push('requestfinished')); + await page.goto(server.EMPTY_PAGE); + expect(events).toEqual(['request', 'response', 'requestfinished']); + }); + it('should support redirects', async () => { + const { page, server } = getTestState(); + + const events = []; + page.on('request', (request) => + events.push(`${request.method()} ${request.url()}`) + ); + page.on('response', (response) => + events.push(`${response.status()} ${response.url()}`) + ); + page.on('requestfinished', (request) => + events.push(`DONE ${request.url()}`) + ); + page.on('requestfailed', (request) => + events.push(`FAIL ${request.url()}`) + ); + server.setRedirect('/foo.html', '/empty.html'); + const FOO_URL = server.PREFIX + '/foo.html'; + const response = await page.goto(FOO_URL); + expect(events).toEqual([ + `GET ${FOO_URL}`, + `302 ${FOO_URL}`, + `DONE ${FOO_URL}`, + `GET ${server.EMPTY_PAGE}`, + `200 ${server.EMPTY_PAGE}`, + `DONE ${server.EMPTY_PAGE}`, + ]); + + // Check redirect chain + const redirectChain = response.request().redirectChain(); + expect(redirectChain.length).toBe(1); + expect(redirectChain[0].url()).toContain('/foo.html'); + expect(redirectChain[0].response().remoteAddress().port).toBe( + server.PORT + ); + }); + }); + + describe('Request.isNavigationRequest', () => { + it('should work', async () => { + const { page, server } = getTestState(); + + const requests = new Map(); + page.on('request', (request) => + requests.set(request.url().split('/').pop(), request) + ); + server.setRedirect('/rrredirect', '/frames/one-frame.html'); + await page.goto(server.PREFIX + '/rrredirect'); + expect(requests.get('rrredirect').isNavigationRequest()).toBe(true); + expect(requests.get('one-frame.html').isNavigationRequest()).toBe(true); + expect(requests.get('frame.html').isNavigationRequest()).toBe(true); + expect(requests.get('script.js').isNavigationRequest()).toBe(false); + expect(requests.get('style.css').isNavigationRequest()).toBe(false); + }); + it('should work with request interception', async () => { + const { page, server } = getTestState(); + + const requests = new Map(); + page.on('request', (request) => { + requests.set(request.url().split('/').pop(), request); + request.continue(); + }); + await page.setRequestInterception(true); + server.setRedirect('/rrredirect', '/frames/one-frame.html'); + await page.goto(server.PREFIX + '/rrredirect'); + expect(requests.get('rrredirect').isNavigationRequest()).toBe(true); + expect(requests.get('one-frame.html').isNavigationRequest()).toBe(true); + expect(requests.get('frame.html').isNavigationRequest()).toBe(true); + expect(requests.get('script.js').isNavigationRequest()).toBe(false); + expect(requests.get('style.css').isNavigationRequest()).toBe(false); + }); + it('should work when navigating to image', async () => { + const { page, server } = getTestState(); + + const requests = []; + page.on('request', (request) => requests.push(request)); + await page.goto(server.PREFIX + '/pptr.png'); + expect(requests[0].isNavigationRequest()).toBe(true); + }); + }); + + describe('Page.setExtraHTTPHeaders', function () { + it('should work', async () => { + const { page, server } = getTestState(); + + await page.setExtraHTTPHeaders({ + foo: 'bar', + }); + const [request] = await Promise.all([ + server.waitForRequest('/empty.html'), + page.goto(server.EMPTY_PAGE), + ]); + expect(request.headers['foo']).toBe('bar'); + }); + it('should throw for non-string header values', async () => { + const { page } = getTestState(); + + let error = null; + try { + // @ts-expect-error purposeful bad input + await page.setExtraHTTPHeaders({ foo: 1 }); + } catch (error_) { + error = error_; + } + expect(error.message).toBe( + 'Expected value of header "foo" to be String, but "number" is found.' + ); + }); + }); + + describe('Page.authenticate', function () { + it('should work', async () => { + const { page, server } = getTestState(); + + server.setAuth('/empty.html', 'user', 'pass'); + let response = await page.goto(server.EMPTY_PAGE); + expect(response.status()).toBe(401); + await page.authenticate({ + username: 'user', + password: 'pass', + }); + response = await page.reload(); + expect(response.status()).toBe(200); + }); + it('should fail if wrong credentials', async () => { + const { page, server } = getTestState(); + + // Use unique user/password since Chrome caches credentials per origin. + server.setAuth('/empty.html', 'user2', 'pass2'); + await page.authenticate({ + username: 'foo', + password: 'bar', + }); + const response = await page.goto(server.EMPTY_PAGE); + expect(response.status()).toBe(401); + }); + it('should allow disable authentication', async () => { + const { page, server } = getTestState(); + + // Use unique user/password since Chrome caches credentials per origin. + server.setAuth('/empty.html', 'user3', 'pass3'); + await page.authenticate({ + username: 'user3', + password: 'pass3', + }); + let response = await page.goto(server.EMPTY_PAGE); + expect(response.status()).toBe(200); + await page.authenticate(null); + // Navigate to a different origin to bust Chrome's credential caching. + response = await page.goto(server.CROSS_PROCESS_PREFIX + '/empty.html'); + expect(response.status()).toBe(401); + }); + }); +}); diff --git a/remote/test/puppeteer/test/oopif.spec.ts b/remote/test/puppeteer/test/oopif.spec.ts new file mode 100644 index 0000000000..845429a69f --- /dev/null +++ b/remote/test/puppeteer/test/oopif.spec.ts @@ -0,0 +1,74 @@ +/** + * 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. + */ + +import expect from 'expect'; +import { getTestState, describeChromeOnly } from './mocha-utils'; // eslint-disable-line import/extensions + +describeChromeOnly('OOPIF', function () { + /* We use a special browser for this test as we need the --site-per-process flag */ + let browser; + let context; + let page; + + before(async () => { + const { puppeteer, defaultBrowserOptions } = getTestState(); + browser = await puppeteer.launch( + Object.assign({}, defaultBrowserOptions, { + args: (defaultBrowserOptions.args || []).concat(['--site-per-process']), + }) + ); + }); + + beforeEach(async () => { + context = await browser.createIncognitoBrowserContext(); + page = await context.newPage(); + }); + + afterEach(async () => { + await context.close(); + page = null; + context = null; + }); + + after(async () => { + await browser.close(); + browser = null; + }); + xit('should report oopif frames', async () => { + const { server } = getTestState(); + + await page.goto(server.PREFIX + '/dynamic-oopif.html'); + expect(oopifs(context).length).toBe(1); + expect(page.frames().length).toBe(2); + }); + it('should load oopif iframes with subresources and request interception', async () => { + const { server } = getTestState(); + + await page.setRequestInterception(true); + page.on('request', (request) => request.continue()); + await page.goto(server.PREFIX + '/dynamic-oopif.html'); + expect(oopifs(context).length).toBe(1); + }); +}); + +/** + * @param {!BrowserContext} context + */ +function oopifs(context) { + return context + .targets() + .filter((target) => target._targetInfo.type === 'iframe'); +} diff --git a/remote/test/puppeteer/test/page.spec.ts b/remote/test/puppeteer/test/page.spec.ts new file mode 100644 index 0000000000..512c26921e --- /dev/null +++ b/remote/test/puppeteer/test/page.spec.ts @@ -0,0 +1,1720 @@ +/** + * 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. + */ +import fs from 'fs'; +import path from 'path'; +import utils from './utils.js'; +const { waitEvent } = utils; +import expect from 'expect'; +import sinon from 'sinon'; +import { + getTestState, + itFailsFirefox, + setupTestBrowserHooks, + setupTestPageAndContextHooks, +} from './mocha-utils'; // eslint-disable-line import/extensions +import { Page, Metrics } from '../lib/cjs/puppeteer/common/Page.js'; +import { JSHandle } from '../lib/cjs/puppeteer/common/JSHandle.js'; + +describe('Page', function () { + setupTestBrowserHooks(); + setupTestPageAndContextHooks(); + describe('Page.close', function () { + it('should reject all promises when page is closed', async () => { + const { context } = getTestState(); + + const newPage = await context.newPage(); + let error = null; + await Promise.all([ + newPage + .evaluate(() => new Promise(() => {})) + .catch((error_) => (error = error_)), + newPage.close(), + ]); + expect(error.message).toContain('Protocol error'); + }); + it('should not be visible in browser.pages', async () => { + const { browser } = getTestState(); + + const newPage = await browser.newPage(); + expect(await browser.pages()).toContain(newPage); + await newPage.close(); + expect(await browser.pages()).not.toContain(newPage); + }); + it('should run beforeunload if asked for', async () => { + const { context, server, isChrome } = getTestState(); + + const newPage = await context.newPage(); + await newPage.goto(server.PREFIX + '/beforeunload.html'); + // We have to interact with a page so that 'beforeunload' handlers + // fire. + await newPage.click('body'); + const pageClosingPromise = newPage.close({ runBeforeUnload: true }); + const dialog = await waitEvent(newPage, 'dialog'); + expect(dialog.type()).toBe('beforeunload'); + expect(dialog.defaultValue()).toBe(''); + if (isChrome) expect(dialog.message()).toBe(''); + else + expect(dialog.message()).toBe( + 'This page is asking you to confirm that you want to leave - data you have entered may not be saved.' + ); + await dialog.accept(); + await pageClosingPromise; + }); + it('should *not* run beforeunload by default', async () => { + const { context, server } = getTestState(); + + const newPage = await context.newPage(); + await newPage.goto(server.PREFIX + '/beforeunload.html'); + // We have to interact with a page so that 'beforeunload' handlers + // fire. + await newPage.click('body'); + await newPage.close(); + }); + it('should set the page close state', async () => { + const { context } = getTestState(); + + const newPage = await context.newPage(); + expect(newPage.isClosed()).toBe(false); + await newPage.close(); + expect(newPage.isClosed()).toBe(true); + }); + it('should terminate network waiters', async () => { + const { context, server } = getTestState(); + + const newPage = await context.newPage(); + const results = await Promise.all([ + newPage.waitForRequest(server.EMPTY_PAGE).catch((error) => error), + newPage.waitForResponse(server.EMPTY_PAGE).catch((error) => error), + newPage.close(), + ]); + for (let i = 0; i < 2; i++) { + const message = results[i].message; + expect(message).toContain('Target closed'); + expect(message).not.toContain('Timeout'); + } + }); + }); + + describe('Page.Events.Load', function () { + it('should fire when expected', async () => { + const { page } = getTestState(); + + await Promise.all([ + page.goto('about:blank'), + utils.waitEvent(page, 'load'), + ]); + }); + }); + + // This test fails on Firefox on CI consistently but cannot be replicated + // locally. Skipping for now to unblock the Mitt release and given FF support + // isn't fully done yet but raising an issue to ask the FF folks to have a + // look at this. + describe('removing and adding event handlers', () => { + it('should correctly fire event handlers as they are added and then removed', async () => { + const { page, server } = getTestState(); + + const handler = sinon.spy(); + page.on('response', handler); + await page.goto(server.EMPTY_PAGE); + expect(handler.callCount).toBe(1); + page.off('response', handler); + await page.goto(server.EMPTY_PAGE); + // Still one because we removed the handler. + expect(handler.callCount).toBe(1); + page.on('response', handler); + await page.goto(server.EMPTY_PAGE); + // Two now because we added the handler back. + expect(handler.callCount).toBe(2); + }); + }); + + describe('Page.Events.error', function () { + it('should throw when page crashes', async () => { + const { page } = getTestState(); + + let error = null; + page.on('error', (err) => (error = err)); + page.goto('chrome://crash').catch(() => {}); + await waitEvent(page, 'error'); + expect(error.message).toBe('Page crashed!'); + }); + }); + + describe('Page.Events.Popup', function () { + it('should work', async () => { + const { page } = getTestState(); + + const [popup] = await Promise.all([ + new Promise<Page>((x) => page.once('popup', x)), + page.evaluate(() => window.open('about:blank')), + ]); + expect(await page.evaluate(() => !!window.opener)).toBe(false); + expect(await popup.evaluate(() => !!window.opener)).toBe(true); + }); + it('should work with noopener', async () => { + const { page } = getTestState(); + + const [popup] = await Promise.all([ + new Promise<Page>((x) => page.once('popup', x)), + page.evaluate(() => window.open('about:blank', null, 'noopener')), + ]); + expect(await page.evaluate(() => !!window.opener)).toBe(false); + expect(await popup.evaluate(() => !!window.opener)).toBe(false); + }); + it('should work with clicking target=_blank', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.setContent('<a target=_blank href="/one-style.html">yo</a>'); + const [popup] = await Promise.all([ + new Promise<Page>((x) => page.once('popup', x)), + page.click('a'), + ]); + expect(await page.evaluate(() => !!window.opener)).toBe(false); + expect(await popup.evaluate(() => !!window.opener)).toBe(true); + }); + it('should work with fake-clicking target=_blank and rel=noopener', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.setContent( + '<a target=_blank rel=noopener href="/one-style.html">yo</a>' + ); + const [popup] = await Promise.all([ + new Promise<Page>((x) => page.once('popup', x)), + page.$eval('a', (a: HTMLAnchorElement) => a.click()), + ]); + expect(await page.evaluate(() => !!window.opener)).toBe(false); + expect(await popup.evaluate(() => !!window.opener)).toBe(false); + }); + it('should work with clicking target=_blank and rel=noopener', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.setContent( + '<a target=_blank rel=noopener href="/one-style.html">yo</a>' + ); + const [popup] = await Promise.all([ + new Promise<Page>((x) => page.once('popup', x)), + page.click('a'), + ]); + expect(await page.evaluate(() => !!window.opener)).toBe(false); + expect(await popup.evaluate(() => !!window.opener)).toBe(false); + }); + }); + + describe('BrowserContext.overridePermissions', function () { + function getPermission(page, name) { + return page.evaluate( + (name) => + navigator.permissions.query({ name }).then((result) => result.state), + name + ); + } + + it('should be prompt by default', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + expect(await getPermission(page, 'geolocation')).toBe('prompt'); + }); + it('should deny permission when not listed', async () => { + const { page, server, context } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + await context.overridePermissions(server.EMPTY_PAGE, []); + expect(await getPermission(page, 'geolocation')).toBe('denied'); + }); + it('should fail when bad permission is given', async () => { + const { page, server, context } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + let error = null; + await context + .overridePermissions(server.EMPTY_PAGE, ['foo']) + .catch((error_) => (error = error_)); + expect(error.message).toBe('Unknown permission: foo'); + }); + it('should grant permission when listed', async () => { + const { page, server, context } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + await context.overridePermissions(server.EMPTY_PAGE, ['geolocation']); + expect(await getPermission(page, 'geolocation')).toBe('granted'); + }); + it('should reset permissions', async () => { + const { page, server, context } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + await context.overridePermissions(server.EMPTY_PAGE, ['geolocation']); + expect(await getPermission(page, 'geolocation')).toBe('granted'); + await context.clearPermissionOverrides(); + expect(await getPermission(page, 'geolocation')).toBe('prompt'); + }); + it('should trigger permission onchange', async () => { + const { page, server, context } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.evaluate(() => { + globalThis.events = []; + return navigator.permissions + .query({ name: 'geolocation' }) + .then(function (result) { + globalThis.events.push(result.state); + result.onchange = function () { + globalThis.events.push(result.state); + }; + }); + }); + expect(await page.evaluate(() => globalThis.events)).toEqual(['prompt']); + await context.overridePermissions(server.EMPTY_PAGE, []); + expect(await page.evaluate(() => globalThis.events)).toEqual([ + 'prompt', + 'denied', + ]); + await context.overridePermissions(server.EMPTY_PAGE, ['geolocation']); + expect(await page.evaluate(() => globalThis.events)).toEqual([ + 'prompt', + 'denied', + 'granted', + ]); + await context.clearPermissionOverrides(); + expect(await page.evaluate(() => globalThis.events)).toEqual([ + 'prompt', + 'denied', + 'granted', + 'prompt', + ]); + }); + it( + 'should isolate permissions between browser contexs', + async () => { + const { page, server, context, browser } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + const otherContext = await browser.createIncognitoBrowserContext(); + const otherPage = await otherContext.newPage(); + await otherPage.goto(server.EMPTY_PAGE); + expect(await getPermission(page, 'geolocation')).toBe('prompt'); + expect(await getPermission(otherPage, 'geolocation')).toBe('prompt'); + + await context.overridePermissions(server.EMPTY_PAGE, []); + await otherContext.overridePermissions(server.EMPTY_PAGE, [ + 'geolocation', + ]); + expect(await getPermission(page, 'geolocation')).toBe('denied'); + expect(await getPermission(otherPage, 'geolocation')).toBe('granted'); + + await context.clearPermissionOverrides(); + expect(await getPermission(page, 'geolocation')).toBe('prompt'); + expect(await getPermission(otherPage, 'geolocation')).toBe('granted'); + + await otherContext.close(); + } + ); + }); + + describe('Page.setGeolocation', function () { + it('should work', async () => { + const { page, server, context } = getTestState(); + + await context.overridePermissions(server.PREFIX, ['geolocation']); + await page.goto(server.EMPTY_PAGE); + await page.setGeolocation({ longitude: 10, latitude: 10 }); + const geolocation = await page.evaluate( + () => + new Promise((resolve) => + navigator.geolocation.getCurrentPosition((position) => { + resolve({ + latitude: position.coords.latitude, + longitude: position.coords.longitude, + }); + }) + ) + ); + expect(geolocation).toEqual({ + latitude: 10, + longitude: 10, + }); + }); + it('should throw when invalid longitude', async () => { + const { page } = getTestState(); + + let error = null; + try { + await page.setGeolocation({ longitude: 200, latitude: 10 }); + } catch (error_) { + error = error_; + } + expect(error.message).toContain('Invalid longitude "200"'); + }); + }); + + describe('Page.setOfflineMode', function () { + it('should work', async () => { + const { page, server } = getTestState(); + + await page.setOfflineMode(true); + let error = null; + await page.goto(server.EMPTY_PAGE).catch((error_) => (error = error_)); + expect(error).toBeTruthy(); + await page.setOfflineMode(false); + const response = await page.reload(); + expect(response.status()).toBe(200); + }); + it('should emulate navigator.onLine', async () => { + const { page } = getTestState(); + + expect(await page.evaluate(() => window.navigator.onLine)).toBe(true); + await page.setOfflineMode(true); + expect(await page.evaluate(() => window.navigator.onLine)).toBe(false); + await page.setOfflineMode(false); + expect(await page.evaluate(() => window.navigator.onLine)).toBe(true); + }); + }); + + describe('ExecutionContext.queryObjects', function () { + it('should work', async () => { + const { page } = getTestState(); + + // Instantiate an object + await page.evaluate(() => (globalThis.set = new Set(['hello', 'world']))); + const prototypeHandle = await page.evaluateHandle(() => Set.prototype); + const objectsHandle = await page.queryObjects(prototypeHandle); + const count = await page.evaluate( + (objects: JSHandle[]) => objects.length, + objectsHandle + ); + expect(count).toBe(1); + const values = await page.evaluate( + (objects) => Array.from(objects[0].values()), + objectsHandle + ); + expect(values).toEqual(['hello', 'world']); + }); + it('should work for non-blank page', async () => { + const { page, server } = getTestState(); + + // Instantiate an object + await page.goto(server.EMPTY_PAGE); + await page.evaluate(() => (globalThis.set = new Set(['hello', 'world']))); + const prototypeHandle = await page.evaluateHandle(() => Set.prototype); + const objectsHandle = await page.queryObjects(prototypeHandle); + const count = await page.evaluate( + (objects: JSHandle[]) => objects.length, + objectsHandle + ); + expect(count).toBe(1); + }); + it('should fail for disposed handles', async () => { + const { page } = getTestState(); + + const prototypeHandle = await page.evaluateHandle( + () => HTMLBodyElement.prototype + ); + await prototypeHandle.dispose(); + let error = null; + await page + .queryObjects(prototypeHandle) + .catch((error_) => (error = error_)); + expect(error.message).toBe('Prototype JSHandle is disposed!'); + }); + it('should fail primitive values as prototypes', async () => { + const { page } = getTestState(); + + const prototypeHandle = await page.evaluateHandle(() => 42); + let error = null; + await page + .queryObjects(prototypeHandle) + .catch((error_) => (error = error_)); + expect(error.message).toBe( + 'Prototype JSHandle must not be referencing primitive value' + ); + }); + }); + + describe('Page.Events.Console', function () { + it('should work', async () => { + const { page } = getTestState(); + + let message = null; + page.once('console', (m) => (message = m)); + await Promise.all([ + page.evaluate(() => console.log('hello', 5, { foo: 'bar' })), + waitEvent(page, 'console'), + ]); + expect(message.text()).toEqual('hello 5 JSHandle@object'); + expect(message.type()).toEqual('log'); + expect(message.args()).toHaveLength(3); + expect(message.location()).toEqual({ + url: expect.any(String), + lineNumber: expect.any(Number), + columnNumber: expect.any(Number), + }); + + expect(await message.args()[0].jsonValue()).toEqual('hello'); + expect(await message.args()[1].jsonValue()).toEqual(5); + expect(await message.args()[2].jsonValue()).toEqual({ foo: 'bar' }); + }); + it('should work for different console API calls', async () => { + const { page } = getTestState(); + + const messages = []; + page.on('console', (msg) => messages.push(msg)); + // All console events will be reported before `page.evaluate` is finished. + await page.evaluate(() => { + // A pair of time/timeEnd generates only one Console API call. + console.time('calling console.time'); + console.timeEnd('calling console.time'); + console.trace('calling console.trace'); + console.dir('calling console.dir'); + console.warn('calling console.warn'); + console.error('calling console.error'); + console.log(Promise.resolve('should not wait until resolved!')); + }); + expect(messages.map((msg) => msg.type())).toEqual([ + 'timeEnd', + 'trace', + 'dir', + 'warning', + 'error', + 'log', + ]); + expect(messages[0].text()).toContain('calling console.time'); + expect(messages.slice(1).map((msg) => msg.text())).toEqual([ + 'calling console.trace', + 'calling console.dir', + 'calling console.warn', + 'calling console.error', + 'JSHandle@promise', + ]); + }); + it('should not fail for window object', async () => { + const { page } = getTestState(); + + let message = null; + page.once('console', (msg) => (message = msg)); + await Promise.all([ + page.evaluate(() => console.error(window)), + waitEvent(page, 'console'), + ]); + expect(message.text()).toBe('JSHandle@object'); + }); + it('should trigger correct Log', async () => { + const { page, server, isChrome } = getTestState(); + + await page.goto('about:blank'); + const [message] = await Promise.all([ + waitEvent(page, 'console'), + page.evaluate( + async (url: string) => fetch(url).catch(() => {}), + server.EMPTY_PAGE + ), + ]); + expect(message.text()).toContain('Access-Control-Allow-Origin'); + if (isChrome) expect(message.type()).toEqual('error'); + else expect(message.type()).toEqual('warn'); + }); + it('should have location when fetch fails', async () => { + const { page, server } = getTestState(); + + // The point of this test is to make sure that we report console messages from + // Log domain: https://vanilla.aslushnikov.com/?Log.entryAdded + await page.goto(server.EMPTY_PAGE); + const [message] = await Promise.all([ + waitEvent(page, 'console'), + page.setContent(`<script>fetch('http://wat');</script>`), + ]); + expect(message.text()).toContain(`ERR_NAME_NOT_RESOLVED`); + expect(message.type()).toEqual('error'); + expect(message.location()).toEqual({ + url: 'http://wat/', + lineNumber: undefined, + }); + }); + it('should have location and stack trace for console API calls', async () => { + const { page, server, isChrome } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + const [message] = await Promise.all([ + waitEvent(page, 'console'), + page.goto(server.PREFIX + '/consolelog.html'), + ]); + expect(message.text()).toBe('yellow'); + expect(message.type()).toBe('log'); + expect(message.location()).toEqual({ + url: server.PREFIX + '/consolelog.html', + lineNumber: 8, + columnNumber: isChrome ? 16 : 8, // console.|log vs |console.log + }); + expect(message.stackTrace()).toEqual([ + { + url: server.PREFIX + '/consolelog.html', + lineNumber: 8, + columnNumber: isChrome ? 16 : 8, // console.|log vs |console.log + }, + { + url: server.PREFIX + '/consolelog.html', + lineNumber: 11, + columnNumber: 8, + }, + { + url: server.PREFIX + '/consolelog.html', + lineNumber: 13, + columnNumber: 6, + }, + ]); + }); + // @see https://github.com/puppeteer/puppeteer/issues/3865 + it('should not throw when there are console messages in detached iframes', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.evaluate(async () => { + // 1. Create a popup that Puppeteer is not connected to. + const win = window.open( + window.location.href, + 'Title', + 'toolbar=no,location=no,directories=no,status=no,menubar=no,scrollbars=yes,resizable=yes,width=780,height=200,top=0,left=0' + ); + await new Promise((x) => (win.onload = x)); + // 2. In this popup, create an iframe that console.logs a message. + win.document.body.innerHTML = `<iframe src='/consolelog.html'></iframe>`; + const frame = win.document.querySelector('iframe'); + await new Promise((x) => (frame.onload = x)); + // 3. After that, remove the iframe. + frame.remove(); + }); + const popupTarget = page + .browserContext() + .targets() + .find((target) => target !== page.target()); + // 4. Connect to the popup and make sure it doesn't throw. + await popupTarget.page(); + }); + }); + + describe('Page.Events.DOMContentLoaded', function () { + it('should fire when expected', async () => { + const { page } = getTestState(); + + page.goto('about:blank'); + await waitEvent(page, 'domcontentloaded'); + }); + }); + + describe('Page.metrics', function () { + it('should get metrics from a page', async () => { + const { page } = getTestState(); + + await page.goto('about:blank'); + const metrics = await page.metrics(); + checkMetrics(metrics); + }); + it('metrics event fired on console.timeStamp', async () => { + const { page } = getTestState(); + + const metricsPromise = new Promise<{ metrics: Metrics; title: string }>( + (fulfill) => page.once('metrics', fulfill) + ); + await page.evaluate(() => console.timeStamp('test42')); + const metrics = await metricsPromise; + expect(metrics.title).toBe('test42'); + checkMetrics(metrics.metrics); + }); + function checkMetrics(metrics) { + const metricsToCheck = new Set([ + 'Timestamp', + 'Documents', + 'Frames', + 'JSEventListeners', + 'Nodes', + 'LayoutCount', + 'RecalcStyleCount', + 'LayoutDuration', + 'RecalcStyleDuration', + 'ScriptDuration', + 'TaskDuration', + 'JSHeapUsedSize', + 'JSHeapTotalSize', + ]); + for (const name in metrics) { + expect(metricsToCheck.has(name)).toBeTruthy(); + expect(metrics[name]).toBeGreaterThanOrEqual(0); + metricsToCheck.delete(name); + } + expect(metricsToCheck.size).toBe(0); + } + }); + + describe('Page.waitForRequest', function () { + it('should work', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + const [request] = await Promise.all([ + page.waitForRequest(server.PREFIX + '/digits/2.png'), + page.evaluate(() => { + fetch('/digits/1.png'); + fetch('/digits/2.png'); + fetch('/digits/3.png'); + }), + ]); + expect(request.url()).toBe(server.PREFIX + '/digits/2.png'); + }); + it('should work with predicate', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + const [request] = await Promise.all([ + page.waitForRequest( + (request) => request.url() === server.PREFIX + '/digits/2.png' + ), + page.evaluate(() => { + fetch('/digits/1.png'); + fetch('/digits/2.png'); + fetch('/digits/3.png'); + }), + ]); + expect(request.url()).toBe(server.PREFIX + '/digits/2.png'); + }); + it('should respect timeout', async () => { + const { page, puppeteer } = getTestState(); + + let error = null; + await page + .waitForRequest(() => false, { timeout: 1 }) + .catch((error_) => (error = error_)); + expect(error).toBeInstanceOf(puppeteer.errors.TimeoutError); + }); + it('should respect default timeout', async () => { + const { page, puppeteer } = getTestState(); + + let error = null; + page.setDefaultTimeout(1); + await page + .waitForRequest(() => false) + .catch((error_) => (error = error_)); + expect(error).toBeInstanceOf(puppeteer.errors.TimeoutError); + }); + it('should work with no timeout', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + const [request] = await Promise.all([ + page.waitForRequest(server.PREFIX + '/digits/2.png', { timeout: 0 }), + page.evaluate(() => + setTimeout(() => { + fetch('/digits/1.png'); + fetch('/digits/2.png'); + fetch('/digits/3.png'); + }, 50) + ), + ]); + expect(request.url()).toBe(server.PREFIX + '/digits/2.png'); + }); + }); + + describe('Page.waitForResponse', function () { + it('should work', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + const [response] = await Promise.all([ + page.waitForResponse(server.PREFIX + '/digits/2.png'), + page.evaluate(() => { + fetch('/digits/1.png'); + fetch('/digits/2.png'); + fetch('/digits/3.png'); + }), + ]); + expect(response.url()).toBe(server.PREFIX + '/digits/2.png'); + }); + it('should respect timeout', async () => { + const { page, puppeteer } = getTestState(); + + let error = null; + await page + .waitForResponse(() => false, { timeout: 1 }) + .catch((error_) => (error = error_)); + expect(error).toBeInstanceOf(puppeteer.errors.TimeoutError); + }); + it('should respect default timeout', async () => { + const { page, puppeteer } = getTestState(); + + let error = null; + page.setDefaultTimeout(1); + await page + .waitForResponse(() => false) + .catch((error_) => (error = error_)); + expect(error).toBeInstanceOf(puppeteer.errors.TimeoutError); + }); + it('should work with predicate', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + const [response] = await Promise.all([ + page.waitForResponse( + (response) => response.url() === server.PREFIX + '/digits/2.png' + ), + page.evaluate(() => { + fetch('/digits/1.png'); + fetch('/digits/2.png'); + fetch('/digits/3.png'); + }), + ]); + expect(response.url()).toBe(server.PREFIX + '/digits/2.png'); + }); + it('should work with no timeout', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + const [response] = await Promise.all([ + page.waitForResponse(server.PREFIX + '/digits/2.png', { timeout: 0 }), + page.evaluate(() => + setTimeout(() => { + fetch('/digits/1.png'); + fetch('/digits/2.png'); + fetch('/digits/3.png'); + }, 50) + ), + ]); + expect(response.url()).toBe(server.PREFIX + '/digits/2.png'); + }); + }); + + describe('Page.exposeFunction', function () { + it('should work', async () => { + const { page } = getTestState(); + + await page.exposeFunction('compute', function (a, b) { + return a * b; + }); + const result = await page.evaluate(async function () { + return await globalThis.compute(9, 4); + }); + expect(result).toBe(36); + }); + it('should throw exception in page context', async () => { + const { page } = getTestState(); + + await page.exposeFunction('woof', function () { + throw new Error('WOOF WOOF'); + }); + const { message, stack } = await page.evaluate(async () => { + try { + await globalThis.woof(); + } catch (error) { + return { message: error.message, stack: error.stack }; + } + }); + expect(message).toBe('WOOF WOOF'); + expect(stack).toContain(__filename); + }); + it('should support throwing "null"', async () => { + const { page } = getTestState(); + + await page.exposeFunction('woof', function () { + throw null; + }); + const thrown = await page.evaluate(async () => { + try { + await globalThis.woof(); + } catch (error) { + return error; + } + }); + expect(thrown).toBe(null); + }); + it('should be callable from-inside evaluateOnNewDocument', async () => { + const { page } = getTestState(); + + let called = false; + await page.exposeFunction('woof', function () { + called = true; + }); + await page.evaluateOnNewDocument(() => globalThis.woof()); + await page.reload(); + expect(called).toBe(true); + }); + it('should survive navigation', async () => { + const { page, server } = getTestState(); + + await page.exposeFunction('compute', function (a, b) { + return a * b; + }); + + await page.goto(server.EMPTY_PAGE); + const result = await page.evaluate(async function () { + return await globalThis.compute(9, 4); + }); + expect(result).toBe(36); + }); + it('should await returned promise', async () => { + const { page } = getTestState(); + + await page.exposeFunction('compute', function (a, b) { + return Promise.resolve(a * b); + }); + + const result = await page.evaluate(async function () { + return await globalThis.compute(3, 5); + }); + expect(result).toBe(15); + }); + it('should work on frames', async () => { + const { page, server } = getTestState(); + + await page.exposeFunction('compute', function (a, b) { + return Promise.resolve(a * b); + }); + + await page.goto(server.PREFIX + '/frames/nested-frames.html'); + const frame = page.frames()[1]; + const result = await frame.evaluate(async function () { + return await globalThis.compute(3, 5); + }); + expect(result).toBe(15); + }); + it('should work on frames before navigation', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/frames/nested-frames.html'); + await page.exposeFunction('compute', function (a, b) { + return Promise.resolve(a * b); + }); + + const frame = page.frames()[1]; + const result = await frame.evaluate(async function () { + return await globalThis.compute(3, 5); + }); + expect(result).toBe(15); + }); + it('should work with complex objects', async () => { + const { page } = getTestState(); + + await page.exposeFunction('complexObject', function (a, b) { + return { x: a.x + b.x }; + }); + const result = await page.evaluate<() => Promise<{ x: number }>>( + async () => globalThis.complexObject({ x: 5 }, { x: 2 }) + ); + expect(result.x).toBe(7); + }); + }); + + describe('Page.Events.PageError', function () { + it('should fire', async () => { + const { page, server } = getTestState(); + + let error = null; + page.once('pageerror', (e) => (error = e)); + await Promise.all([ + page.goto(server.PREFIX + '/error.html'), + waitEvent(page, 'pageerror'), + ]); + expect(error.message).toContain('Fancy'); + }); + }); + + describe('Page.setUserAgent', function () { + it('should work', async () => { + const { page, server } = getTestState(); + + expect(await page.evaluate(() => navigator.userAgent)).toContain( + 'Mozilla' + ); + await page.setUserAgent('foobar'); + const [request] = await Promise.all([ + server.waitForRequest('/empty.html'), + page.goto(server.EMPTY_PAGE), + ]); + expect(request.headers['user-agent']).toBe('foobar'); + }); + it('should work for subframes', async () => { + const { page, server } = getTestState(); + + expect(await page.evaluate(() => navigator.userAgent)).toContain( + 'Mozilla' + ); + await page.setUserAgent('foobar'); + const [request] = await Promise.all([ + server.waitForRequest('/empty.html'), + utils.attachFrame(page, 'frame1', server.EMPTY_PAGE), + ]); + expect(request.headers['user-agent']).toBe('foobar'); + }); + it('should emulate device user-agent', async () => { + const { page, server, puppeteer } = getTestState(); + + await page.goto(server.PREFIX + '/mobile.html'); + expect(await page.evaluate(() => navigator.userAgent)).not.toContain( + 'iPhone' + ); + await page.setUserAgent(puppeteer.devices['iPhone 6'].userAgent); + expect(await page.evaluate(() => navigator.userAgent)).toContain( + 'iPhone' + ); + }); + }); + + describe('Page.setContent', function () { + const expectedOutput = + '<html><head></head><body><div>hello</div></body></html>'; + it('should work', async () => { + const { page } = getTestState(); + + await page.setContent('<div>hello</div>'); + const result = await page.content(); + expect(result).toBe(expectedOutput); + }); + it('should work with doctype', async () => { + const { page } = getTestState(); + + const doctype = '<!DOCTYPE html>'; + await page.setContent(`${doctype}<div>hello</div>`); + const result = await page.content(); + expect(result).toBe(`${doctype}${expectedOutput}`); + }); + it('should work with HTML 4 doctype', async () => { + const { page } = getTestState(); + + const doctype = + '<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN" ' + + '"http://www.w3.org/TR/html4/strict.dtd">'; + await page.setContent(`${doctype}<div>hello</div>`); + const result = await page.content(); + expect(result).toBe(`${doctype}${expectedOutput}`); + }); + it('should respect timeout', async () => { + const { page, server, puppeteer } = getTestState(); + + const imgPath = '/img.png'; + // stall for image + server.setRoute(imgPath, () => {}); + let error = null; + await page + .setContent(`<img src="${server.PREFIX + imgPath}"></img>`, { + timeout: 1, + }) + .catch((error_) => (error = error_)); + expect(error).toBeInstanceOf(puppeteer.errors.TimeoutError); + }); + it('should respect default navigation timeout', async () => { + const { page, server, puppeteer } = getTestState(); + + page.setDefaultNavigationTimeout(1); + const imgPath = '/img.png'; + // stall for image + server.setRoute(imgPath, () => {}); + let error = null; + await page + .setContent(`<img src="${server.PREFIX + imgPath}"></img>`) + .catch((error_) => (error = error_)); + expect(error).toBeInstanceOf(puppeteer.errors.TimeoutError); + }); + it('should await resources to load', async () => { + const { page, server } = getTestState(); + + const imgPath = '/img.png'; + let imgResponse = null; + server.setRoute(imgPath, (req, res) => (imgResponse = res)); + let loaded = false; + const contentPromise = page + .setContent(`<img src="${server.PREFIX + imgPath}"></img>`) + .then(() => (loaded = true)); + await server.waitForRequest(imgPath); + expect(loaded).toBe(false); + imgResponse.end(); + await contentPromise; + }); + it('should work fast enough', async () => { + const { page } = getTestState(); + + for (let i = 0; i < 20; ++i) await page.setContent('<div>yo</div>'); + }); + it('should work with tricky content', async () => { + const { page } = getTestState(); + + await page.setContent('<div>hello world</div>' + '\x7F'); + expect(await page.$eval('div', (div) => div.textContent)).toBe( + 'hello world' + ); + }); + it('should work with accents', async () => { + const { page } = getTestState(); + + await page.setContent('<div>aberración</div>'); + expect(await page.$eval('div', (div) => div.textContent)).toBe( + 'aberración' + ); + }); + it('should work with emojis', async () => { + const { page } = getTestState(); + + await page.setContent('<div>🐥</div>'); + expect(await page.$eval('div', (div) => div.textContent)).toBe('🐥'); + }); + it('should work with newline', async () => { + const { page } = getTestState(); + + await page.setContent('<div>\n</div>'); + expect(await page.$eval('div', (div) => div.textContent)).toBe('\n'); + }); + }); + + describe('Page.setBypassCSP', function () { + it('should bypass CSP meta tag', async () => { + const { page, server } = getTestState(); + + // Make sure CSP prohibits addScriptTag. + await page.goto(server.PREFIX + '/csp.html'); + await page + .addScriptTag({ content: 'window.__injected = 42;' }) + .catch((error) => void error); + expect(await page.evaluate(() => globalThis.__injected)).toBe(undefined); + + // By-pass CSP and try one more time. + await page.setBypassCSP(true); + await page.reload(); + await page.addScriptTag({ content: 'window.__injected = 42;' }); + expect(await page.evaluate(() => globalThis.__injected)).toBe(42); + }); + + it('should bypass CSP header', async () => { + const { page, server } = getTestState(); + + // Make sure CSP prohibits addScriptTag. + server.setCSP('/empty.html', 'default-src "self"'); + await page.goto(server.EMPTY_PAGE); + await page + .addScriptTag({ content: 'window.__injected = 42;' }) + .catch((error) => void error); + expect(await page.evaluate(() => globalThis.__injected)).toBe(undefined); + + // By-pass CSP and try one more time. + await page.setBypassCSP(true); + await page.reload(); + await page.addScriptTag({ content: 'window.__injected = 42;' }); + expect(await page.evaluate(() => globalThis.__injected)).toBe(42); + }); + + it('should bypass after cross-process navigation', async () => { + const { page, server } = getTestState(); + + await page.setBypassCSP(true); + await page.goto(server.PREFIX + '/csp.html'); + await page.addScriptTag({ content: 'window.__injected = 42;' }); + expect(await page.evaluate(() => globalThis.__injected)).toBe(42); + + await page.goto(server.CROSS_PROCESS_PREFIX + '/csp.html'); + await page.addScriptTag({ content: 'window.__injected = 42;' }); + expect(await page.evaluate(() => globalThis.__injected)).toBe(42); + }); + it('should bypass CSP in iframes as well', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + { + // Make sure CSP prohibits addScriptTag in an iframe. + const frame = await utils.attachFrame( + page, + 'frame1', + server.PREFIX + '/csp.html' + ); + await frame + .addScriptTag({ content: 'window.__injected = 42;' }) + .catch((error) => void error); + expect(await frame.evaluate(() => globalThis.__injected)).toBe( + undefined + ); + } + + // By-pass CSP and try one more time. + await page.setBypassCSP(true); + await page.reload(); + + { + const frame = await utils.attachFrame( + page, + 'frame1', + server.PREFIX + '/csp.html' + ); + await frame + .addScriptTag({ content: 'window.__injected = 42;' }) + .catch((error) => void error); + expect(await frame.evaluate(() => globalThis.__injected)).toBe(42); + } + }); + }); + + describe('Page.addScriptTag', function () { + it('should throw an error if no options are provided', async () => { + const { page } = getTestState(); + + let error = null; + try { + // @ts-expect-error purposefully passing bad options + await page.addScriptTag('/injectedfile.js'); + } catch (error_) { + error = error_; + } + expect(error.message).toBe( + 'Provide an object with a `url`, `path` or `content` property' + ); + }); + + it('should work with a url', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + const scriptHandle = await page.addScriptTag({ url: '/injectedfile.js' }); + expect(scriptHandle.asElement()).not.toBeNull(); + expect(await page.evaluate(() => globalThis.__injected)).toBe(42); + }); + + it('should work with a url and type=module', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.addScriptTag({ url: '/es6/es6import.js', type: 'module' }); + expect(await page.evaluate(() => globalThis.__es6injected)).toBe(42); + }); + + it('should work with a path and type=module', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.addScriptTag({ + path: path.join(__dirname, 'assets/es6/es6pathimport.js'), + type: 'module', + }); + await page.waitForFunction('window.__es6injected'); + expect(await page.evaluate(() => globalThis.__es6injected)).toBe(42); + }); + + it('should work with a content and type=module', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.addScriptTag({ + content: `import num from '/es6/es6module.js';window.__es6injected = num;`, + type: 'module', + }); + await page.waitForFunction('window.__es6injected'); + expect(await page.evaluate(() => globalThis.__es6injected)).toBe(42); + }); + + it('should throw an error if loading from url fail', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + let error = null; + try { + await page.addScriptTag({ url: '/nonexistfile.js' }); + } catch (error_) { + error = error_; + } + expect(error.message).toBe('Loading script from /nonexistfile.js failed'); + }); + + it('should work with a path', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + const scriptHandle = await page.addScriptTag({ + path: path.join(__dirname, 'assets/injectedfile.js'), + }); + expect(scriptHandle.asElement()).not.toBeNull(); + expect(await page.evaluate(() => globalThis.__injected)).toBe(42); + }); + + it('should include sourcemap when path is provided', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.addScriptTag({ + path: path.join(__dirname, 'assets/injectedfile.js'), + }); + const result = await page.evaluate( + () => globalThis.__injectedError.stack + ); + expect(result).toContain(path.join('assets', 'injectedfile.js')); + }); + + it('should work with content', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + const scriptHandle = await page.addScriptTag({ + content: 'window.__injected = 35;', + }); + expect(scriptHandle.asElement()).not.toBeNull(); + expect(await page.evaluate(() => globalThis.__injected)).toBe(35); + }); + + // @see https://github.com/puppeteer/puppeteer/issues/4840 + xit('should throw when added with content to the CSP page', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/csp.html'); + let error = null; + await page + .addScriptTag({ content: 'window.__injected = 35;' }) + .catch((error_) => (error = error_)); + expect(error).toBeTruthy(); + }); + + it('should throw when added with URL to the CSP page', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/csp.html'); + let error = null; + await page + .addScriptTag({ url: server.CROSS_PROCESS_PREFIX + '/injectedfile.js' }) + .catch((error_) => (error = error_)); + expect(error).toBeTruthy(); + }); + }); + + describe('Page.addStyleTag', function () { + it('should throw an error if no options are provided', async () => { + const { page } = getTestState(); + + let error = null; + try { + // @ts-expect-error purposefully passing bad input + await page.addStyleTag('/injectedstyle.css'); + } catch (error_) { + error = error_; + } + expect(error.message).toBe( + 'Provide an object with a `url`, `path` or `content` property' + ); + }); + + it('should work with a url', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + const styleHandle = await page.addStyleTag({ url: '/injectedstyle.css' }); + expect(styleHandle.asElement()).not.toBeNull(); + expect( + await page.evaluate( + `window.getComputedStyle(document.querySelector('body')).getPropertyValue('background-color')` + ) + ).toBe('rgb(255, 0, 0)'); + }); + + it('should throw an error if loading from url fail', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + let error = null; + try { + await page.addStyleTag({ url: '/nonexistfile.js' }); + } catch (error_) { + error = error_; + } + expect(error.message).toBe('Loading style from /nonexistfile.js failed'); + }); + + it('should work with a path', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + const styleHandle = await page.addStyleTag({ + path: path.join(__dirname, 'assets/injectedstyle.css'), + }); + expect(styleHandle.asElement()).not.toBeNull(); + expect( + await page.evaluate( + `window.getComputedStyle(document.querySelector('body')).getPropertyValue('background-color')` + ) + ).toBe('rgb(255, 0, 0)'); + }); + + it('should include sourcemap when path is provided', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.addStyleTag({ + path: path.join(__dirname, 'assets/injectedstyle.css'), + }); + const styleHandle = await page.$('style'); + const styleContent = await page.evaluate( + (style: HTMLStyleElement) => style.innerHTML, + styleHandle + ); + expect(styleContent).toContain(path.join('assets', 'injectedstyle.css')); + }); + + it('should work with content', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + const styleHandle = await page.addStyleTag({ + content: 'body { background-color: green; }', + }); + expect(styleHandle.asElement()).not.toBeNull(); + expect( + await page.evaluate( + `window.getComputedStyle(document.querySelector('body')).getPropertyValue('background-color')` + ) + ).toBe('rgb(0, 128, 0)'); + }); + + it( + 'should throw when added with content to the CSP page', + async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/csp.html'); + let error = null; + await page + .addStyleTag({ content: 'body { background-color: green; }' }) + .catch((error_) => (error = error_)); + expect(error).toBeTruthy(); + } + ); + + it('should throw when added with URL to the CSP page', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/csp.html'); + let error = null; + await page + .addStyleTag({ + url: server.CROSS_PROCESS_PREFIX + '/injectedstyle.css', + }) + .catch((error_) => (error = error_)); + expect(error).toBeTruthy(); + }); + }); + + describe('Page.url', function () { + it('should work', async () => { + const { page, server } = getTestState(); + + expect(page.url()).toBe('about:blank'); + await page.goto(server.EMPTY_PAGE); + expect(page.url()).toBe(server.EMPTY_PAGE); + }); + }); + + describe('Page.setJavaScriptEnabled', function () { + it('should work', async () => { + const { page } = getTestState(); + + await page.setJavaScriptEnabled(false); + await page.goto( + 'data:text/html, <script>var something = "forbidden"</script>' + ); + let error = null; + await page.evaluate('something').catch((error_) => (error = error_)); + expect(error.message).toContain('something is not defined'); + + await page.setJavaScriptEnabled(true); + await page.goto( + 'data:text/html, <script>var something = "forbidden"</script>' + ); + expect(await page.evaluate('something')).toBe('forbidden'); + }); + }); + + describe('Page.setCacheEnabled', function () { + it('should enable or disable the cache based on the state passed', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/cached/one-style.html'); + const [cachedRequest] = await Promise.all([ + server.waitForRequest('/cached/one-style.html'), + page.reload(), + ]); + // Rely on "if-modified-since" caching in our test server. + expect(cachedRequest.headers['if-modified-since']).not.toBe(undefined); + + await page.setCacheEnabled(false); + const [nonCachedRequest] = await Promise.all([ + server.waitForRequest('/cached/one-style.html'), + page.reload(), + ]); + expect(nonCachedRequest.headers['if-modified-since']).toBe(undefined); + }); + it( + 'should stay disabled when toggling request interception on/off', + async () => { + const { page, server } = getTestState(); + + await page.setCacheEnabled(false); + await page.setRequestInterception(true); + await page.setRequestInterception(false); + + await page.goto(server.PREFIX + '/cached/one-style.html'); + const [nonCachedRequest] = await Promise.all([ + server.waitForRequest('/cached/one-style.html'), + page.reload(), + ]); + expect(nonCachedRequest.headers['if-modified-since']).toBe(undefined); + } + ); + }); + + describe('printing to PDF', function () { + it('can print to PDF and save to file', async () => { + // Printing to pdf is currently only supported in headless + const { isHeadless, page } = getTestState(); + + if (!isHeadless) return; + + const outputFile = __dirname + '/assets/output.pdf'; + await page.pdf({ path: outputFile }); + expect(fs.readFileSync(outputFile).byteLength).toBeGreaterThan(0); + fs.unlinkSync(outputFile); + }); + }); + + describe('Page.title', function () { + it('should return the page title', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/title.html'); + expect(await page.title()).toBe('Woof-Woof'); + }); + }); + + describe('Page.select', function () { + it('should select single option', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/input/select.html'); + await page.select('select', 'blue'); + expect(await page.evaluate(() => globalThis.result.onInput)).toEqual([ + 'blue', + ]); + expect(await page.evaluate(() => globalThis.result.onChange)).toEqual([ + 'blue', + ]); + }); + it('should select only first option', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/input/select.html'); + await page.select('select', 'blue', 'green', 'red'); + expect(await page.evaluate(() => globalThis.result.onInput)).toEqual([ + 'blue', + ]); + expect(await page.evaluate(() => globalThis.result.onChange)).toEqual([ + 'blue', + ]); + }); + it('should not throw when select causes navigation', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/input/select.html'); + await page.$eval('select', (select) => + select.addEventListener( + 'input', + () => ((window as any).location = '/empty.html') + ) + ); + await Promise.all([ + page.select('select', 'blue'), + page.waitForNavigation(), + ]); + expect(page.url()).toContain('empty.html'); + }); + it('should select multiple options', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/input/select.html'); + await page.evaluate(() => globalThis.makeMultiple()); + await page.select('select', 'blue', 'green', 'red'); + expect(await page.evaluate(() => globalThis.result.onInput)).toEqual([ + 'blue', + 'green', + 'red', + ]); + expect(await page.evaluate(() => globalThis.result.onChange)).toEqual([ + 'blue', + 'green', + 'red', + ]); + }); + it('should respect event bubbling', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/input/select.html'); + await page.select('select', 'blue'); + expect( + await page.evaluate(() => globalThis.result.onBubblingInput) + ).toEqual(['blue']); + expect( + await page.evaluate(() => globalThis.result.onBubblingChange) + ).toEqual(['blue']); + }); + it('should throw when element is not a <select>', async () => { + const { page, server } = getTestState(); + + let error = null; + await page.goto(server.PREFIX + '/input/select.html'); + await page.select('body', '').catch((error_) => (error = error_)); + expect(error.message).toContain('Element is not a <select> element.'); + }); + it('should return [] on no matched values', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/input/select.html'); + const result = await page.select('select', '42', 'abc'); + expect(result).toEqual([]); + }); + it('should return an array of matched values', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/input/select.html'); + await page.evaluate(() => globalThis.makeMultiple()); + const result = await page.select('select', 'blue', 'black', 'magenta'); + expect( + result.reduce( + (accumulator, current) => + ['blue', 'black', 'magenta'].includes(current) && accumulator, + true + ) + ).toEqual(true); + }); + it('should return an array of one element when multiple is not set', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/input/select.html'); + const result = await page.select( + 'select', + '42', + 'blue', + 'black', + 'magenta' + ); + expect(result.length).toEqual(1); + }); + it('should return [] on no values', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/input/select.html'); + const result = await page.select('select'); + expect(result).toEqual([]); + }); + it('should deselect all options when passed no values for a multiple select', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/input/select.html'); + await page.evaluate(() => globalThis.makeMultiple()); + await page.select('select', 'blue', 'black', 'magenta'); + await page.select('select'); + expect( + await page.$eval('select', (select: HTMLSelectElement) => + Array.from(select.options).every( + (option: HTMLOptionElement) => !option.selected + ) + ) + ).toEqual(true); + }); + it('should deselect all options when passed no values for a select without multiple', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/input/select.html'); + await page.select('select', 'blue', 'black', 'magenta'); + await page.select('select'); + expect( + await page.$eval('select', (select: HTMLSelectElement) => + Array.from(select.options).every( + (option: HTMLOptionElement) => !option.selected + ) + ) + ).toEqual(true); + }); + it('should throw if passed in non-strings', async () => { + const { page } = getTestState(); + + await page.setContent('<select><option value="12"/></select>'); + let error = null; + try { + // @ts-expect-error purposefully passing bad input + await page.select('select', 12); + } catch (error_) { + error = error_; + } + expect(error.message).toContain('Values must be strings'); + }); + // @see https://github.com/puppeteer/puppeteer/issues/3327 + it( + 'should work when re-defining top-level Event class', + async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/input/select.html'); + await page.evaluate(() => (window.Event = null)); + await page.select('select', 'blue'); + expect(await page.evaluate(() => globalThis.result.onInput)).toEqual([ + 'blue', + ]); + expect(await page.evaluate(() => globalThis.result.onChange)).toEqual([ + 'blue', + ]); + } + ); + }); + + describe('Page.Events.Close', function () { + itFailsFirefox('should work with window.close', async () => { + const { page, context } = getTestState(); + + const newPagePromise = new Promise<Page>((fulfill) => + context.once('targetcreated', (target) => fulfill(target.page())) + ); + await page.evaluate( + () => (window['newPage'] = window.open('about:blank')) + ); + const newPage = await newPagePromise; + const closedPromise = new Promise((x) => newPage.on('close', x)); + await page.evaluate(() => window['newPage'].close()); + await closedPromise; + }); + it('should work with page.close', async () => { + const { context } = getTestState(); + + const newPage = await context.newPage(); + const closedPromise = new Promise((x) => newPage.on('close', x)); + await newPage.close(); + await closedPromise; + }); + }); + + describe('Page.browser', function () { + it('should return the correct browser instance', async () => { + const { page, browser } = getTestState(); + + expect(page.browser()).toBe(browser); + }); + }); + + describe('Page.browserContext', function () { + it('should return the correct browser instance', async () => { + const { page, context } = getTestState(); + + expect(page.browserContext()).toBe(context); + }); + }); +}); diff --git a/remote/test/puppeteer/test/queryselector.spec.ts b/remote/test/puppeteer/test/queryselector.spec.ts new file mode 100644 index 0000000000..7a147ddd01 --- /dev/null +++ b/remote/test/puppeteer/test/queryselector.spec.ts @@ -0,0 +1,507 @@ +/** + * 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. + */ +import expect from 'expect'; +import { + getTestState, + setupTestBrowserHooks, + setupTestPageAndContextHooks, +} from './mocha-utils'; // eslint-disable-line import/extensions +import { CustomQueryHandler } from '../lib/cjs/puppeteer/common/QueryHandler.js'; + +describe('querySelector', function () { + setupTestBrowserHooks(); + setupTestPageAndContextHooks(); + describe('Page.$eval', function () { + it('should work', async () => { + const { page } = getTestState(); + + await page.setContent('<section id="testAttribute">43543</section>'); + const idAttribute = await page.$eval('section', (e) => e.id); + expect(idAttribute).toBe('testAttribute'); + }); + it('should accept arguments', async () => { + const { page } = getTestState(); + + await page.setContent('<section>hello</section>'); + const text = await page.$eval( + 'section', + (e, suffix) => e.textContent + suffix, + ' world!' + ); + expect(text).toBe('hello world!'); + }); + it('should accept ElementHandles as arguments', async () => { + const { page } = getTestState(); + + await page.setContent('<section>hello</section><div> world</div>'); + const divHandle = await page.$('div'); + const text = await page.$eval( + 'section', + (e, div: HTMLElement) => e.textContent + div.textContent, + divHandle + ); + expect(text).toBe('hello world'); + }); + it('should throw error if no element is found', async () => { + const { page } = getTestState(); + + let error = null; + await page + .$eval('section', (e) => e.id) + .catch((error_) => (error = error_)); + expect(error.message).toContain( + 'failed to find element matching selector "section"' + ); + }); + }); + + describe('pierceHandler', function () { + beforeEach(async () => { + const { page } = getTestState(); + await page.setContent( + `<script> + const div = document.createElement('div'); + const shadowRoot = div.attachShadow({mode: 'open'}); + const div1 = document.createElement('div'); + div1.textContent = 'Hello'; + div1.className = 'foo'; + const div2 = document.createElement('div'); + div2.textContent = 'World'; + div2.className = 'foo'; + shadowRoot.appendChild(div1); + shadowRoot.appendChild(div2); + document.documentElement.appendChild(div); + </script>` + ); + }); + it('should find first element in shadow', async () => { + const { page } = getTestState(); + const div = await page.$('pierce/.foo'); + const text = await div.evaluate( + (element: Element) => element.textContent + ); + expect(text).toBe('Hello'); + }); + it('should find all elements in shadow', async () => { + const { page } = getTestState(); + const divs = await page.$$('pierce/.foo'); + const text = await Promise.all( + divs.map((div) => + div.evaluate((element: Element) => element.textContent) + ) + ); + expect(text.join(' ')).toBe('Hello World'); + }); + }); + + // The tests for $$eval are repeated later in this file in the test group 'QueryAll'. + // This is done to also test a query handler where QueryAll returns an Element[] + // as opposed to NodeListOf<Element>. + describe('Page.$$eval', function () { + it('should work', async () => { + const { page } = getTestState(); + + await page.setContent( + '<div>hello</div><div>beautiful</div><div>world!</div>' + ); + const divsCount = await page.$$eval('div', (divs) => divs.length); + expect(divsCount).toBe(3); + }); + it('should accept extra arguments', async () => { + const { page } = getTestState(); + await page.setContent( + '<div>hello</div><div>beautiful</div><div>world!</div>' + ); + const divsCountPlus5 = await page.$$eval( + 'div', + (divs, two: number, three: number) => divs.length + two + three, + 2, + 3 + ); + expect(divsCountPlus5).toBe(8); + }); + it('should accept ElementHandles as arguments', async () => { + const { page } = getTestState(); + await page.setContent( + '<section>2</section><section>2</section><section>1</section><div>3</div>' + ); + const divHandle = await page.$('div'); + const sum = await page.$$eval( + 'section', + (sections, div: HTMLElement) => + sections.reduce( + (acc, section) => acc + Number(section.textContent), + 0 + ) + Number(div.textContent), + divHandle + ); + expect(sum).toBe(8); + }); + it('should handle many elements', async () => { + const { page } = getTestState(); + await page.evaluate( + ` + for (var i = 0; i <= 1000; i++) { + const section = document.createElement('section'); + section.textContent = i; + document.body.appendChild(section); + } + ` + ); + const sum = await page.$$eval('section', (sections) => + sections.reduce((acc, section) => acc + Number(section.textContent), 0) + ); + expect(sum).toBe(500500); + }); + }); + + describe('Page.$', function () { + it('should query existing element', async () => { + const { page } = getTestState(); + + await page.setContent('<section>test</section>'); + const element = await page.$('section'); + expect(element).toBeTruthy(); + }); + it('should return null for non-existing element', async () => { + const { page } = getTestState(); + + const element = await page.$('non-existing-element'); + expect(element).toBe(null); + }); + }); + + describe('Page.$$', function () { + it('should query existing elements', async () => { + const { page } = getTestState(); + + await page.setContent('<div>A</div><br/><div>B</div>'); + const elements = await page.$$('div'); + expect(elements.length).toBe(2); + const promises = elements.map((element) => + page.evaluate((e: HTMLElement) => e.textContent, element) + ); + expect(await Promise.all(promises)).toEqual(['A', 'B']); + }); + it('should return empty array if nothing is found', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + const elements = await page.$$('div'); + expect(elements.length).toBe(0); + }); + }); + + describe('Path.$x', function () { + it('should query existing element', async () => { + const { page } = getTestState(); + + await page.setContent('<section>test</section>'); + const elements = await page.$x('/html/body/section'); + expect(elements[0]).toBeTruthy(); + expect(elements.length).toBe(1); + }); + it('should return empty array for non-existing element', async () => { + const { page } = getTestState(); + + const element = await page.$x('/html/body/non-existing-element'); + expect(element).toEqual([]); + }); + it('should return multiple elements', async () => { + const { page } = getTestState(); + + await page.setContent('<div></div><div></div>'); + const elements = await page.$x('/html/body/div'); + expect(elements.length).toBe(2); + }); + }); + + describe('ElementHandle.$', function () { + it('should query existing element', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/playground.html'); + await page.setContent( + '<html><body><div class="second"><div class="inner">A</div></div></body></html>' + ); + const html = await page.$('html'); + const second = await html.$('.second'); + const inner = await second.$('.inner'); + const content = await page.evaluate( + (e: HTMLElement) => e.textContent, + inner + ); + expect(content).toBe('A'); + }); + + it('should return null for non-existing element', async () => { + const { page } = getTestState(); + + await page.setContent( + '<html><body><div class="second"><div class="inner">B</div></div></body></html>' + ); + const html = await page.$('html'); + const second = await html.$('.third'); + expect(second).toBe(null); + }); + }); + describe('ElementHandle.$eval', function () { + it('should work', async () => { + const { page } = getTestState(); + + await page.setContent( + '<html><body><div class="tweet"><div class="like">100</div><div class="retweets">10</div></div></body></html>' + ); + const tweet = await page.$('.tweet'); + const content = await tweet.$eval( + '.like', + (node: HTMLElement) => node.innerText + ); + expect(content).toBe('100'); + }); + + it('should retrieve content from subtree', async () => { + const { page } = getTestState(); + + const htmlContent = + '<div class="a">not-a-child-div</div><div id="myId"><div class="a">a-child-div</div></div>'; + await page.setContent(htmlContent); + const elementHandle = await page.$('#myId'); + const content = await elementHandle.$eval( + '.a', + (node: HTMLElement) => node.innerText + ); + expect(content).toBe('a-child-div'); + }); + + it('should throw in case of missing selector', async () => { + const { page } = getTestState(); + + const htmlContent = + '<div class="a">not-a-child-div</div><div id="myId"></div>'; + await page.setContent(htmlContent); + const elementHandle = await page.$('#myId'); + const errorMessage = await elementHandle + .$eval('.a', (node: HTMLElement) => node.innerText) + .catch((error) => error.message); + expect(errorMessage).toBe( + `Error: failed to find element matching selector ".a"` + ); + }); + }); + describe('ElementHandle.$$eval', function () { + it('should work', async () => { + const { page } = getTestState(); + + await page.setContent( + '<html><body><div class="tweet"><div class="like">100</div><div class="like">10</div></div></body></html>' + ); + const tweet = await page.$('.tweet'); + const content = await tweet.$$eval('.like', (nodes: HTMLElement[]) => + nodes.map((n) => n.innerText) + ); + expect(content).toEqual(['100', '10']); + }); + + it('should retrieve content from subtree', async () => { + const { page } = getTestState(); + + const htmlContent = + '<div class="a">not-a-child-div</div><div id="myId"><div class="a">a1-child-div</div><div class="a">a2-child-div</div></div>'; + await page.setContent(htmlContent); + const elementHandle = await page.$('#myId'); + const content = await elementHandle.$$eval('.a', (nodes: HTMLElement[]) => + nodes.map((n) => n.innerText) + ); + expect(content).toEqual(['a1-child-div', 'a2-child-div']); + }); + + it('should not throw in case of missing selector', async () => { + const { page } = getTestState(); + + const htmlContent = + '<div class="a">not-a-child-div</div><div id="myId"></div>'; + await page.setContent(htmlContent); + const elementHandle = await page.$('#myId'); + const nodesLength = await elementHandle.$$eval( + '.a', + (nodes) => nodes.length + ); + expect(nodesLength).toBe(0); + }); + }); + + describe('ElementHandle.$$', function () { + it('should query existing elements', async () => { + const { page } = getTestState(); + + await page.setContent( + '<html><body><div>A</div><br/><div>B</div></body></html>' + ); + const html = await page.$('html'); + const elements = await html.$$('div'); + expect(elements.length).toBe(2); + const promises = elements.map((element) => + page.evaluate((e: HTMLElement) => e.textContent, element) + ); + expect(await Promise.all(promises)).toEqual(['A', 'B']); + }); + + it('should return empty array for non-existing elements', async () => { + const { page } = getTestState(); + + await page.setContent( + '<html><body><span>A</span><br/><span>B</span></body></html>' + ); + const html = await page.$('html'); + const elements = await html.$$('div'); + expect(elements.length).toBe(0); + }); + }); + + describe('ElementHandle.$x', function () { + it('should query existing element', async () => { + const { page, server } = getTestState(); + + await page.goto(server.PREFIX + '/playground.html'); + await page.setContent( + '<html><body><div class="second"><div class="inner">A</div></div></body></html>' + ); + const html = await page.$('html'); + const second = await html.$x(`./body/div[contains(@class, 'second')]`); + const inner = await second[0].$x(`./div[contains(@class, 'inner')]`); + const content = await page.evaluate( + (e: HTMLElement) => e.textContent, + inner[0] + ); + expect(content).toBe('A'); + }); + + it('should return null for non-existing element', async () => { + const { page } = getTestState(); + + await page.setContent( + '<html><body><div class="second"><div class="inner">B</div></div></body></html>' + ); + const html = await page.$('html'); + const second = await html.$x(`/div[contains(@class, 'third')]`); + expect(second).toEqual([]); + }); + }); + + // This is the same tests for `$$eval` and `$$` as above, but with a queryAll + // handler that returns an array instead of a list of nodes. + describe('QueryAll', function () { + const handler: CustomQueryHandler = { + queryAll: (element: Element, selector: string) => + Array.from(element.querySelectorAll(selector)), + }; + before(() => { + const { puppeteer } = getTestState(); + puppeteer.registerCustomQueryHandler('allArray', handler); + }); + + it('should have registered handler', async () => { + const { puppeteer } = getTestState(); + expect( + puppeteer.customQueryHandlerNames().includes('allArray') + ).toBeTruthy(); + }); + it('$$ should query existing elements', async () => { + const { page } = getTestState(); + + await page.setContent( + '<html><body><div>A</div><br/><div>B</div></body></html>' + ); + const html = await page.$('html'); + const elements = await html.$$('allArray/div'); + expect(elements.length).toBe(2); + const promises = elements.map((element) => + page.evaluate((e: HTMLElement) => e.textContent, element) + ); + expect(await Promise.all(promises)).toEqual(['A', 'B']); + }); + + it('$$ should return empty array for non-existing elements', async () => { + const { page } = getTestState(); + + await page.setContent( + '<html><body><span>A</span><br/><span>B</span></body></html>' + ); + const html = await page.$('html'); + const elements = await html.$$('allArray/div'); + expect(elements.length).toBe(0); + }); + it('$$eval should work', async () => { + const { page } = getTestState(); + + await page.setContent( + '<div>hello</div><div>beautiful</div><div>world!</div>' + ); + const divsCount = await page.$$eval( + 'allArray/div', + (divs) => divs.length + ); + expect(divsCount).toBe(3); + }); + it('$$eval should accept extra arguments', async () => { + const { page } = getTestState(); + await page.setContent( + '<div>hello</div><div>beautiful</div><div>world!</div>' + ); + const divsCountPlus5 = await page.$$eval( + 'allArray/div', + (divs, two: number, three: number) => divs.length + two + three, + 2, + 3 + ); + expect(divsCountPlus5).toBe(8); + }); + it('$$eval should accept ElementHandles as arguments', async () => { + const { page } = getTestState(); + await page.setContent( + '<section>2</section><section>2</section><section>1</section><div>3</div>' + ); + const divHandle = await page.$('div'); + const sum = await page.$$eval( + 'allArray/section', + (sections, div: HTMLElement) => + sections.reduce( + (acc, section) => acc + Number(section.textContent), + 0 + ) + Number(div.textContent), + divHandle + ); + expect(sum).toBe(8); + }); + it('$$eval should handle many elements', async () => { + const { page } = getTestState(); + await page.evaluate( + ` + for (var i = 0; i <= 1000; i++) { + const section = document.createElement('section'); + section.textContent = i; + document.body.appendChild(section); + } + ` + ); + const sum = await page.$$eval('allArray/section', (sections) => + sections.reduce((acc, section) => acc + Number(section.textContent), 0) + ); + expect(sum).toBe(500500); + }); + }); +}); diff --git a/remote/test/puppeteer/test/requestinterception.spec.ts b/remote/test/puppeteer/test/requestinterception.spec.ts new file mode 100644 index 0000000000..462eb714c7 --- /dev/null +++ b/remote/test/puppeteer/test/requestinterception.spec.ts @@ -0,0 +1,703 @@ +/** + * 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. + */ + +import fs from 'fs'; +import path from 'path'; +import utils from './utils.js'; +import expect from 'expect'; +import { + getTestState, + setupTestBrowserHooks, + setupTestPageAndContextHooks, +} from './mocha-utils'; // eslint-disable-line import/extensions + +describe('request interception', function () { + setupTestBrowserHooks(); + setupTestPageAndContextHooks(); + describe('Page.setRequestInterception', function () { + it('should intercept', async () => { + const { page, server } = getTestState(); + + await page.setRequestInterception(true); + page.on('request', (request) => { + if (utils.isFavicon(request)) { + request.continue(); + return; + } + expect(request.url()).toContain('empty.html'); + expect(request.headers()['user-agent']).toBeTruthy(); + expect(request.method()).toBe('GET'); + expect(request.postData()).toBe(undefined); + expect(request.isNavigationRequest()).toBe(true); + expect(request.resourceType()).toBe('document'); + expect(request.frame() === page.mainFrame()).toBe(true); + expect(request.frame().url()).toBe('about:blank'); + request.continue(); + }); + const response = await page.goto(server.EMPTY_PAGE); + expect(response.ok()).toBe(true); + expect(response.remoteAddress().port).toBe(server.PORT); + }); + it('should work when POST is redirected with 302', async () => { + const { page, server } = getTestState(); + + server.setRedirect('/rredirect', '/empty.html'); + await page.goto(server.EMPTY_PAGE); + await page.setRequestInterception(true); + page.on('request', (request) => request.continue()); + await page.setContent(` + <form action='/rredirect' method='post'> + <input type="hidden" id="foo" name="foo" value="FOOBAR"> + </form> + `); + await Promise.all([ + page.$eval('form', (form: HTMLFormElement) => form.submit()), + page.waitForNavigation(), + ]); + }); + // @see https://github.com/puppeteer/puppeteer/issues/3973 + it('should work when header manipulation headers with redirect', async () => { + const { page, server } = getTestState(); + + server.setRedirect('/rrredirect', '/empty.html'); + await page.setRequestInterception(true); + page.on('request', (request) => { + const headers = Object.assign({}, request.headers(), { + foo: 'bar', + }); + request.continue({ headers }); + }); + await page.goto(server.PREFIX + '/rrredirect'); + }); + // @see https://github.com/puppeteer/puppeteer/issues/4743 + it('should be able to remove headers', async () => { + const { page, server } = getTestState(); + + await page.setRequestInterception(true); + page.on('request', (request) => { + const headers = Object.assign({}, request.headers(), { + foo: 'bar', + origin: undefined, // remove "origin" header + }); + request.continue({ headers }); + }); + + const [serverRequest] = await Promise.all([ + server.waitForRequest('/empty.html'), + page.goto(server.PREFIX + '/empty.html'), + ]); + + expect(serverRequest.headers.origin).toBe(undefined); + }); + it('should contain referer header', async () => { + const { page, server } = getTestState(); + + await page.setRequestInterception(true); + const requests = []; + page.on('request', (request) => { + if (!utils.isFavicon(request)) requests.push(request); + request.continue(); + }); + await page.goto(server.PREFIX + '/one-style.html'); + expect(requests[1].url()).toContain('/one-style.css'); + expect(requests[1].headers().referer).toContain('/one-style.html'); + }); + it('should properly return navigation response when URL has cookies', async () => { + const { page, server } = getTestState(); + + // Setup cookie. + await page.goto(server.EMPTY_PAGE); + await page.setCookie({ name: 'foo', value: 'bar' }); + + // Setup request interception. + await page.setRequestInterception(true); + page.on('request', (request) => request.continue()); + const response = await page.reload(); + expect(response.status()).toBe(200); + }); + it('should stop intercepting', async () => { + const { page, server } = getTestState(); + + await page.setRequestInterception(true); + page.once('request', (request) => request.continue()); + await page.goto(server.EMPTY_PAGE); + await page.setRequestInterception(false); + await page.goto(server.EMPTY_PAGE); + }); + it('should show custom HTTP headers', async () => { + const { page, server } = getTestState(); + + await page.setExtraHTTPHeaders({ + foo: 'bar', + }); + await page.setRequestInterception(true); + page.on('request', (request) => { + expect(request.headers()['foo']).toBe('bar'); + request.continue(); + }); + const response = await page.goto(server.EMPTY_PAGE); + expect(response.ok()).toBe(true); + }); + // @see https://github.com/puppeteer/puppeteer/issues/4337 + it('should work with redirect inside sync XHR', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + server.setRedirect('/logo.png', '/pptr.png'); + await page.setRequestInterception(true); + page.on('request', (request) => request.continue()); + const status = await page.evaluate(async () => { + const request = new XMLHttpRequest(); + request.open('GET', '/logo.png', false); // `false` makes the request synchronous + request.send(null); + return request.status; + }); + expect(status).toBe(200); + }); + it('should work with custom referer headers', async () => { + const { page, server } = getTestState(); + + await page.setExtraHTTPHeaders({ referer: server.EMPTY_PAGE }); + await page.setRequestInterception(true); + page.on('request', (request) => { + expect(request.headers()['referer']).toBe(server.EMPTY_PAGE); + request.continue(); + }); + const response = await page.goto(server.EMPTY_PAGE); + expect(response.ok()).toBe(true); + }); + it('should be abortable', async () => { + const { page, server } = getTestState(); + + await page.setRequestInterception(true); + page.on('request', (request) => { + if (request.url().endsWith('.css')) request.abort(); + else request.continue(); + }); + let failedRequests = 0; + page.on('requestfailed', () => ++failedRequests); + const response = await page.goto(server.PREFIX + '/one-style.html'); + expect(response.ok()).toBe(true); + expect(response.request().failure()).toBe(null); + expect(failedRequests).toBe(1); + }); + it('should be abortable with custom error codes', async () => { + const { page, server } = getTestState(); + + await page.setRequestInterception(true); + page.on('request', (request) => { + request.abort('internetdisconnected'); + }); + let failedRequest = null; + page.on('requestfailed', (request) => (failedRequest = request)); + await page.goto(server.EMPTY_PAGE).catch(() => {}); + expect(failedRequest).toBeTruthy(); + expect(failedRequest.failure().errorText).toBe( + 'net::ERR_INTERNET_DISCONNECTED' + ); + }); + it('should send referer', async () => { + const { page, server } = getTestState(); + + await page.setExtraHTTPHeaders({ + referer: 'http://google.com/', + }); + await page.setRequestInterception(true); + page.on('request', (request) => request.continue()); + const [request] = await Promise.all([ + server.waitForRequest('/grid.html'), + page.goto(server.PREFIX + '/grid.html'), + ]); + expect(request.headers['referer']).toBe('http://google.com/'); + }); + it('should fail navigation when aborting main resource', async () => { + const { page, server, isChrome } = getTestState(); + + await page.setRequestInterception(true); + page.on('request', (request) => request.abort()); + let error = null; + await page.goto(server.EMPTY_PAGE).catch((error_) => (error = error_)); + expect(error).toBeTruthy(); + if (isChrome) expect(error.message).toContain('net::ERR_FAILED'); + else expect(error.message).toContain('NS_ERROR_FAILURE'); + }); + it('should work with redirects', async () => { + const { page, server } = getTestState(); + + await page.setRequestInterception(true); + const requests = []; + page.on('request', (request) => { + request.continue(); + requests.push(request); + }); + server.setRedirect( + '/non-existing-page.html', + '/non-existing-page-2.html' + ); + server.setRedirect( + '/non-existing-page-2.html', + '/non-existing-page-3.html' + ); + server.setRedirect( + '/non-existing-page-3.html', + '/non-existing-page-4.html' + ); + server.setRedirect('/non-existing-page-4.html', '/empty.html'); + const response = await page.goto( + server.PREFIX + '/non-existing-page.html' + ); + expect(response.status()).toBe(200); + expect(response.url()).toContain('empty.html'); + expect(requests.length).toBe(5); + expect(requests[2].resourceType()).toBe('document'); + // Check redirect chain + const redirectChain = response.request().redirectChain(); + expect(redirectChain.length).toBe(4); + expect(redirectChain[0].url()).toContain('/non-existing-page.html'); + expect(redirectChain[2].url()).toContain('/non-existing-page-3.html'); + for (let i = 0; i < redirectChain.length; ++i) { + const request = redirectChain[i]; + expect(request.isNavigationRequest()).toBe(true); + expect(request.redirectChain().indexOf(request)).toBe(i); + } + }); + it('should work with redirects for subresources', async () => { + const { page, server } = getTestState(); + + await page.setRequestInterception(true); + const requests = []; + page.on('request', (request) => { + request.continue(); + if (!utils.isFavicon(request)) requests.push(request); + }); + server.setRedirect('/one-style.css', '/two-style.css'); + server.setRedirect('/two-style.css', '/three-style.css'); + server.setRedirect('/three-style.css', '/four-style.css'); + server.setRoute('/four-style.css', (req, res) => + res.end('body {box-sizing: border-box; }') + ); + + const response = await page.goto(server.PREFIX + '/one-style.html'); + expect(response.status()).toBe(200); + expect(response.url()).toContain('one-style.html'); + expect(requests.length).toBe(5); + expect(requests[0].resourceType()).toBe('document'); + expect(requests[1].resourceType()).toBe('stylesheet'); + // Check redirect chain + const redirectChain = requests[1].redirectChain(); + expect(redirectChain.length).toBe(3); + expect(redirectChain[0].url()).toContain('/one-style.css'); + expect(redirectChain[2].url()).toContain('/three-style.css'); + }); + it('should be able to abort redirects', async () => { + const { page, server, isChrome } = getTestState(); + + await page.setRequestInterception(true); + server.setRedirect('/non-existing.json', '/non-existing-2.json'); + server.setRedirect('/non-existing-2.json', '/simple.html'); + page.on('request', (request) => { + if (request.url().includes('non-existing-2')) request.abort(); + else request.continue(); + }); + await page.goto(server.EMPTY_PAGE); + const result = await page.evaluate(async () => { + try { + await fetch('/non-existing.json'); + } catch (error) { + return error.message; + } + }); + if (isChrome) expect(result).toContain('Failed to fetch'); + else expect(result).toContain('NetworkError'); + }); + it('should work with equal requests', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + let responseCount = 1; + server.setRoute('/zzz', (req, res) => res.end(responseCount++ * 11 + '')); + await page.setRequestInterception(true); + + let spinner = false; + // Cancel 2nd request. + page.on('request', (request) => { + if (utils.isFavicon(request)) { + request.continue(); + return; + } + spinner ? request.abort() : request.continue(); + spinner = !spinner; + }); + const results = await page.evaluate(() => + Promise.all([ + fetch('/zzz') + .then((response) => response.text()) + .catch(() => 'FAILED'), + fetch('/zzz') + .then((response) => response.text()) + .catch(() => 'FAILED'), + fetch('/zzz') + .then((response) => response.text()) + .catch(() => 'FAILED'), + ]) + ); + expect(results).toEqual(['11', 'FAILED', '22']); + }); + it('should navigate to dataURL and fire dataURL requests', async () => { + const { page } = getTestState(); + + await page.setRequestInterception(true); + const requests = []; + page.on('request', (request) => { + requests.push(request); + request.continue(); + }); + const dataURL = 'data:text/html,<div>yo</div>'; + const response = await page.goto(dataURL); + expect(response.status()).toBe(200); + expect(requests.length).toBe(1); + expect(requests[0].url()).toBe(dataURL); + }); + it('should be able to fetch dataURL and fire dataURL requests', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.setRequestInterception(true); + const requests = []; + page.on('request', (request) => { + requests.push(request); + request.continue(); + }); + const dataURL = 'data:text/html,<div>yo</div>'; + const text = await page.evaluate( + (url: string) => fetch(url).then((r) => r.text()), + dataURL + ); + expect(text).toBe('<div>yo</div>'); + expect(requests.length).toBe(1); + expect(requests[0].url()).toBe(dataURL); + }); + it('should navigate to URL with hash and fire requests without hash', async () => { + const { page, server } = getTestState(); + + await page.setRequestInterception(true); + const requests = []; + page.on('request', (request) => { + requests.push(request); + request.continue(); + }); + const response = await page.goto(server.EMPTY_PAGE + '#hash'); + expect(response.status()).toBe(200); + expect(response.url()).toBe(server.EMPTY_PAGE); + expect(requests.length).toBe(1); + expect(requests[0].url()).toBe(server.EMPTY_PAGE); + }); + it('should work with encoded server', async () => { + const { page, server } = getTestState(); + + // The requestWillBeSent will report encoded URL, whereas interception will + // report URL as-is. @see crbug.com/759388 + await page.setRequestInterception(true); + page.on('request', (request) => request.continue()); + const response = await page.goto( + server.PREFIX + '/some nonexisting page' + ); + expect(response.status()).toBe(404); + }); + it('should work with badly encoded server', async () => { + const { page, server } = getTestState(); + + await page.setRequestInterception(true); + server.setRoute('/malformed?rnd=%911', (req, res) => res.end()); + page.on('request', (request) => request.continue()); + const response = await page.goto(server.PREFIX + '/malformed?rnd=%911'); + expect(response.status()).toBe(200); + }); + it('should work with encoded server - 2', async () => { + const { page, server } = getTestState(); + + // The requestWillBeSent will report URL as-is, whereas interception will + // report encoded URL for stylesheet. @see crbug.com/759388 + await page.setRequestInterception(true); + const requests = []; + page.on('request', (request) => { + request.continue(); + requests.push(request); + }); + const response = await page.goto( + `data:text/html,<link rel="stylesheet" href="${server.PREFIX}/fonts?helvetica|arial"/>` + ); + expect(response.status()).toBe(200); + expect(requests.length).toBe(2); + expect(requests[1].response().status()).toBe(404); + }); + it('should not throw "Invalid Interception Id" if the request was cancelled', async () => { + const { page, server } = getTestState(); + + await page.setContent('<iframe></iframe>'); + await page.setRequestInterception(true); + let request = null; + page.on('request', async (r) => (request = r)); + page.$eval( + 'iframe', + (frame: HTMLIFrameElement, url: string) => (frame.src = url), + server.EMPTY_PAGE + ), + // Wait for request interception. + await utils.waitEvent(page, 'request'); + // Delete frame to cause request to be canceled. + await page.$eval('iframe', (frame) => frame.remove()); + let error = null; + await request.continue().catch((error_) => (error = error_)); + expect(error).toBe(null); + }); + it('should throw if interception is not enabled', async () => { + const { page, server } = getTestState(); + + let error = null; + page.on('request', async (request) => { + try { + await request.continue(); + } catch (error_) { + error = error_; + } + }); + await page.goto(server.EMPTY_PAGE); + expect(error.message).toContain('Request Interception is not enabled'); + }); + it('should work with file URLs', async () => { + const { page } = getTestState(); + + await page.setRequestInterception(true); + const urls = new Set(); + page.on('request', (request) => { + urls.add(request.url().split('/').pop()); + request.continue(); + }); + await page.goto( + pathToFileURL(path.join(__dirname, 'assets', 'one-style.html')) + ); + expect(urls.size).toBe(2); + expect(urls.has('one-style.html')).toBe(true); + expect(urls.has('one-style.css')).toBe(true); + }); + }); + + describe('Request.continue', function () { + it('should work', async () => { + const { page, server } = getTestState(); + + await page.setRequestInterception(true); + page.on('request', (request) => request.continue()); + await page.goto(server.EMPTY_PAGE); + }); + it('should amend HTTP headers', async () => { + const { page, server } = getTestState(); + + await page.setRequestInterception(true); + page.on('request', (request) => { + const headers = Object.assign({}, request.headers()); + headers['FOO'] = 'bar'; + request.continue({ headers }); + }); + await page.goto(server.EMPTY_PAGE); + const [request] = await Promise.all([ + server.waitForRequest('/sleep.zzz'), + page.evaluate(() => fetch('/sleep.zzz')), + ]); + expect(request.headers['foo']).toBe('bar'); + }); + it('should redirect in a way non-observable to page', async () => { + const { page, server } = getTestState(); + + await page.setRequestInterception(true); + page.on('request', (request) => { + const redirectURL = request.url().includes('/empty.html') + ? server.PREFIX + '/consolelog.html' + : undefined; + request.continue({ url: redirectURL }); + }); + let consoleMessage = null; + page.on('console', (msg) => (consoleMessage = msg)); + await page.goto(server.EMPTY_PAGE); + expect(page.url()).toBe(server.EMPTY_PAGE); + expect(consoleMessage.text()).toBe('yellow'); + }); + it('should amend method', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + + await page.setRequestInterception(true); + page.on('request', (request) => { + request.continue({ method: 'POST' }); + }); + const [request] = await Promise.all([ + server.waitForRequest('/sleep.zzz'), + page.evaluate(() => fetch('/sleep.zzz')), + ]); + expect(request.method).toBe('POST'); + }); + it('should amend post data', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + + await page.setRequestInterception(true); + page.on('request', (request) => { + request.continue({ postData: 'doggo' }); + }); + const [serverRequest] = await Promise.all([ + server.waitForRequest('/sleep.zzz'), + page.evaluate(() => + fetch('/sleep.zzz', { method: 'POST', body: 'birdy' }) + ), + ]); + expect(await serverRequest.postBody).toBe('doggo'); + }); + it('should amend both post data and method on navigation', async () => { + const { page, server } = getTestState(); + + await page.setRequestInterception(true); + page.on('request', (request) => { + request.continue({ method: 'POST', postData: 'doggo' }); + }); + const [serverRequest] = await Promise.all([ + server.waitForRequest('/empty.html'), + page.goto(server.EMPTY_PAGE), + ]); + expect(serverRequest.method).toBe('POST'); + expect(await serverRequest.postBody).toBe('doggo'); + }); + }); + + describe('Request.respond', function () { + it('should work', async () => { + const { page, server } = getTestState(); + + await page.setRequestInterception(true); + page.on('request', (request) => { + request.respond({ + status: 201, + headers: { + foo: 'bar', + }, + body: 'Yo, page!', + }); + }); + const response = await page.goto(server.EMPTY_PAGE); + expect(response.status()).toBe(201); + expect(response.headers().foo).toBe('bar'); + expect(await page.evaluate(() => document.body.textContent)).toBe( + 'Yo, page!' + ); + }); + it('should work with status code 422', async () => { + const { page, server } = getTestState(); + + await page.setRequestInterception(true); + page.on('request', (request) => { + request.respond({ + status: 422, + body: 'Yo, page!', + }); + }); + const response = await page.goto(server.EMPTY_PAGE); + expect(response.status()).toBe(422); + expect(response.statusText()).toBe('Unprocessable Entity'); + expect(await page.evaluate(() => document.body.textContent)).toBe( + 'Yo, page!' + ); + }); + it('should redirect', async () => { + const { page, server } = getTestState(); + + await page.setRequestInterception(true); + page.on('request', (request) => { + if (!request.url().includes('rrredirect')) { + request.continue(); + return; + } + request.respond({ + status: 302, + headers: { + location: server.EMPTY_PAGE, + }, + }); + }); + const response = await page.goto(server.PREFIX + '/rrredirect'); + expect(response.request().redirectChain().length).toBe(1); + expect(response.request().redirectChain()[0].url()).toBe( + server.PREFIX + '/rrredirect' + ); + expect(response.url()).toBe(server.EMPTY_PAGE); + }); + it('should allow mocking binary responses', async () => { + const { page, server } = getTestState(); + + await page.setRequestInterception(true); + page.on('request', (request) => { + const imageBuffer = fs.readFileSync( + path.join(__dirname, 'assets', 'pptr.png') + ); + request.respond({ + contentType: 'image/png', + body: imageBuffer, + }); + }); + await page.evaluate((PREFIX) => { + const img = document.createElement('img'); + img.src = PREFIX + '/does-not-exist.png'; + document.body.appendChild(img); + return new Promise((fulfill) => (img.onload = fulfill)); + }, server.PREFIX); + const img = await page.$('img'); + expect(await img.screenshot()).toBeGolden('mock-binary-response.png'); + }); + it('should stringify intercepted request response headers', async () => { + const { page, server } = getTestState(); + + await page.setRequestInterception(true); + page.on('request', (request) => { + request.respond({ + status: 200, + headers: { + foo: true, + }, + body: 'Yo, page!', + }); + }); + const response = await page.goto(server.EMPTY_PAGE); + expect(response.status()).toBe(200); + const headers = response.headers(); + expect(headers.foo).toBe('true'); + expect(await page.evaluate(() => document.body.textContent)).toBe( + 'Yo, page!' + ); + }); + }); +}); + +/** + * @param {string} path + * @returns {string} + */ +function pathToFileURL(path) { + let pathName = path.replace(/\\/g, '/'); + // Windows drive letter must be prefixed with a slash. + if (!pathName.startsWith('/')) pathName = '/' + pathName; + return 'file://' + pathName; +} diff --git a/remote/test/puppeteer/test/run_static_server.js b/remote/test/puppeteer/test/run_static_server.js new file mode 100755 index 0000000000..6779e8816a --- /dev/null +++ b/remote/test/puppeteer/test/run_static_server.js @@ -0,0 +1,33 @@ +#!/usr/bin/env node +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +const path = require('path'); +const { TestServer } = require('../utils/testserver/'); + +const port = 8907; +const httpsPort = 8908; +const assetsPath = path.join(__dirname, 'assets'); +const cachedPath = path.join(__dirname, 'assets', 'cached'); + +Promise.all([ + TestServer.create(assetsPath, port), + TestServer.createHTTPS(assetsPath, httpsPort), +]).then(([server, httpsServer]) => { + server.enableHTTPCache(cachedPath); + httpsServer.enableHTTPCache(cachedPath); + console.log(`HTTP: server is running on http://localhost:${port}`); + console.log(`HTTPS: server is running on https://localhost:${httpsPort}`); +}); diff --git a/remote/test/puppeteer/test/screenshot.spec.ts b/remote/test/puppeteer/test/screenshot.spec.ts new file mode 100644 index 0000000000..de33b9c94f --- /dev/null +++ b/remote/test/puppeteer/test/screenshot.spec.ts @@ -0,0 +1,323 @@ +/** + * 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. + */ + +import expect from 'expect'; +import { + getTestState, + setupTestBrowserHooks, + setupTestPageAndContextHooks, +} from './mocha-utils'; // eslint-disable-line import/extensions + +describe('Screenshots', function () { + setupTestBrowserHooks(); + setupTestPageAndContextHooks(); + + describe('Page.screenshot', function () { + it('should work', async () => { + const { page, server } = getTestState(); + + await page.setViewport({ width: 500, height: 500 }); + await page.goto(server.PREFIX + '/grid.html'); + const screenshot = await page.screenshot(); + expect(screenshot).toBeGolden('screenshot-sanity.png'); + }); + it('should clip rect', async () => { + const { page, server } = getTestState(); + + await page.setViewport({ width: 500, height: 500 }); + await page.goto(server.PREFIX + '/grid.html'); + const screenshot = await page.screenshot({ + clip: { + x: 50, + y: 100, + width: 150, + height: 100, + }, + }); + expect(screenshot).toBeGolden('screenshot-clip-rect.png'); + }); + it('should clip elements to the viewport', async () => { + const { page, server } = getTestState(); + + await page.setViewport({ width: 500, height: 500 }); + await page.goto(server.PREFIX + '/grid.html'); + const screenshot = await page.screenshot({ + clip: { + x: 50, + y: 600, + width: 100, + height: 100, + }, + }); + expect(screenshot).toBeGolden('screenshot-offscreen-clip.png'); + }); + it('should run in parallel', async () => { + const { page, server } = getTestState(); + + await page.setViewport({ width: 500, height: 500 }); + await page.goto(server.PREFIX + '/grid.html'); + const promises = []; + for (let i = 0; i < 3; ++i) { + promises.push( + page.screenshot({ + clip: { + x: 50 * i, + y: 0, + width: 50, + height: 50, + }, + }) + ); + } + const screenshots = await Promise.all(promises); + expect(screenshots[1]).toBeGolden('grid-cell-1.png'); + }); + it('should take fullPage screenshots', async () => { + const { page, server } = getTestState(); + + await page.setViewport({ width: 500, height: 500 }); + await page.goto(server.PREFIX + '/grid.html'); + const screenshot = await page.screenshot({ + fullPage: true, + }); + expect(screenshot).toBeGolden('screenshot-grid-fullpage.png'); + }); + it('should run in parallel in multiple pages', async () => { + const { server, context } = getTestState(); + + const N = 2; + const pages = await Promise.all( + Array(N) + .fill(0) + .map(async () => { + const page = await context.newPage(); + await page.goto(server.PREFIX + '/grid.html'); + return page; + }) + ); + const promises = []; + for (let i = 0; i < N; ++i) + promises.push( + pages[i].screenshot({ + clip: { x: 50 * i, y: 0, width: 50, height: 50 }, + }) + ); + const screenshots = await Promise.all(promises); + for (let i = 0; i < N; ++i) + expect(screenshots[i]).toBeGolden(`grid-cell-${i}.png`); + await Promise.all(pages.map((page) => page.close())); + }); + it('should allow transparency', async () => { + const { page, server } = getTestState(); + + await page.setViewport({ width: 100, height: 100 }); + await page.goto(server.EMPTY_PAGE); + const screenshot = await page.screenshot({ omitBackground: true }); + expect(screenshot).toBeGolden('transparent.png'); + }); + it('should render white background on jpeg file', async () => { + const { page, server } = getTestState(); + + await page.setViewport({ width: 100, height: 100 }); + await page.goto(server.EMPTY_PAGE); + const screenshot = await page.screenshot({ + omitBackground: true, + type: 'jpeg', + }); + expect(screenshot).toBeGolden('white.jpg'); + }); + it('should work with odd clip size on Retina displays', async () => { + const { page } = getTestState(); + + const screenshot = await page.screenshot({ + clip: { + x: 0, + y: 0, + width: 11, + height: 11, + }, + }); + expect(screenshot).toBeGolden('screenshot-clip-odd-size.png'); + }); + it('should return base64', async () => { + const { page, server } = getTestState(); + + await page.setViewport({ width: 500, height: 500 }); + await page.goto(server.PREFIX + '/grid.html'); + const screenshot = await page.screenshot({ + encoding: 'base64', + }); + // TODO (@jackfranklin): improve the screenshot types. + // - if we pass encoding: 'base64', it returns a string + // - else it returns a buffer. + // If we can fix that we can avoid this "as string" here. + expect(Buffer.from(screenshot as string, 'base64')).toBeGolden( + 'screenshot-sanity.png' + ); + }); + }); + + describe('ElementHandle.screenshot', function () { + it('should work', async () => { + const { page, server } = getTestState(); + + await page.setViewport({ width: 500, height: 500 }); + await page.goto(server.PREFIX + '/grid.html'); + await page.evaluate(() => window.scrollBy(50, 100)); + const elementHandle = await page.$('.box:nth-of-type(3)'); + const screenshot = await elementHandle.screenshot(); + expect(screenshot).toBeGolden('screenshot-element-bounding-box.png'); + }); + it('should take into account padding and border', async () => { + const { page } = getTestState(); + + await page.setViewport({ width: 500, height: 500 }); + await page.setContent(` + something above + <style>div { + border: 2px solid blue; + background: green; + width: 50px; + height: 50px; + } + </style> + <div></div> + `); + const elementHandle = await page.$('div'); + const screenshot = await elementHandle.screenshot(); + expect(screenshot).toBeGolden('screenshot-element-padding-border.png'); + }); + it('should capture full element when larger than viewport', async () => { + const { page } = getTestState(); + + await page.setViewport({ width: 500, height: 500 }); + + await page.setContent(` + something above + <style> + div.to-screenshot { + border: 1px solid blue; + width: 600px; + height: 600px; + margin-left: 50px; + } + ::-webkit-scrollbar{ + display: none; + } + </style> + <div class="to-screenshot"></div> + `); + const elementHandle = await page.$('div.to-screenshot'); + const screenshot = await elementHandle.screenshot(); + expect(screenshot).toBeGolden( + 'screenshot-element-larger-than-viewport.png' + ); + + expect( + await page.evaluate(() => ({ + w: window.innerWidth, + h: window.innerHeight, + })) + ).toEqual({ w: 500, h: 500 }); + }); + it('should scroll element into view', async () => { + const { page } = getTestState(); + + await page.setViewport({ width: 500, height: 500 }); + await page.setContent(` + something above + <style>div.above { + border: 2px solid blue; + background: red; + height: 1500px; + } + div.to-screenshot { + border: 2px solid blue; + background: green; + width: 50px; + height: 50px; + } + </style> + <div class="above"></div> + <div class="to-screenshot"></div> + `); + const elementHandle = await page.$('div.to-screenshot'); + const screenshot = await elementHandle.screenshot(); + expect(screenshot).toBeGolden( + 'screenshot-element-scrolled-into-view.png' + ); + }); + it('should work with a rotated element', async () => { + const { page } = getTestState(); + + await page.setViewport({ width: 500, height: 500 }); + await page.setContent(`<div style="position:absolute; + top: 100px; + left: 100px; + width: 100px; + height: 100px; + background: green; + transform: rotateZ(200deg);"> </div>`); + const elementHandle = await page.$('div'); + const screenshot = await elementHandle.screenshot(); + expect(screenshot).toBeGolden('screenshot-element-rotate.png'); + }); + it('should fail to screenshot a detached element', async () => { + const { page } = getTestState(); + + await page.setContent('<h1>remove this</h1>'); + const elementHandle = await page.$('h1'); + await page.evaluate( + (element: HTMLElement) => element.remove(), + elementHandle + ); + const screenshotError = await elementHandle + .screenshot() + .catch((error) => error); + expect(screenshotError.message).toBe( + 'Node is either not visible or not an HTMLElement' + ); + }); + it('should not hang with zero width/height element', async () => { + const { page } = getTestState(); + + await page.setContent('<div style="width: 50px; height: 0"></div>'); + const div = await page.$('div'); + const error = await div.screenshot().catch((error_) => error_); + expect(error.message).toBe('Node has 0 height.'); + }); + it('should work for an element with fractional dimensions', async () => { + const { page } = getTestState(); + + await page.setContent( + '<div style="width:48.51px;height:19.8px;border:1px solid black;"></div>' + ); + const elementHandle = await page.$('div'); + const screenshot = await elementHandle.screenshot(); + expect(screenshot).toBeGolden('screenshot-element-fractional.png'); + }); + it('should work for an element with an offset', async () => { + const { page } = getTestState(); + + await page.setContent( + '<div style="position:absolute; top: 10.3px; left: 20.4px;width:50.3px;height:20.2px;border:1px solid black;"></div>' + ); + const elementHandle = await page.$('div'); + const screenshot = await elementHandle.screenshot(); + expect(screenshot).toBeGolden('screenshot-element-fractional-offset.png'); + }); + }); +}); diff --git a/remote/test/puppeteer/test/target.spec.ts b/remote/test/puppeteer/test/target.spec.ts new file mode 100644 index 0000000000..72cfe1d835 --- /dev/null +++ b/remote/test/puppeteer/test/target.spec.ts @@ -0,0 +1,294 @@ +/** + * 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. + */ + +import utils from './utils.js'; +const { waitEvent } = utils; +import expect from 'expect'; +import { + getTestState, + setupTestBrowserHooks, + setupTestPageAndContextHooks, +} from './mocha-utils'; // eslint-disable-line import/extensions +import { Target } from '../lib/cjs/puppeteer/common/Target.js'; + +describe('Target', function () { + setupTestBrowserHooks(); + setupTestPageAndContextHooks(); + + it('Browser.targets should return all of the targets', async () => { + const { browser } = getTestState(); + + // The pages will be the testing page and the original newtab page + const targets = browser.targets(); + expect( + targets.some( + (target) => target.type() === 'page' && target.url() === 'about:blank' + ) + ).toBeTruthy(); + expect(targets.some((target) => target.type() === 'browser')).toBeTruthy(); + }); + it('Browser.pages should return all of the pages', async () => { + const { page, context } = getTestState(); + + // The pages will be the testing page + const allPages = await context.pages(); + expect(allPages.length).toBe(1); + expect(allPages).toContain(page); + expect(allPages[0]).not.toBe(allPages[1]); + }); + it('should contain browser target', async () => { + const { browser } = getTestState(); + + const targets = browser.targets(); + const browserTarget = targets.find((target) => target.type() === 'browser'); + expect(browserTarget).toBeTruthy(); + }); + it('should be able to use the default page in the browser', async () => { + const { page, browser } = getTestState(); + + // The pages will be the testing page and the original newtab page + const allPages = await browser.pages(); + const originalPage = allPages.find((p) => p !== page); + expect( + await originalPage.evaluate(() => ['Hello', 'world'].join(' ')) + ).toBe('Hello world'); + expect(await originalPage.$('body')).toBeTruthy(); + }); + it( + 'should report when a new page is created and closed', + async () => { + const { page, server, context } = getTestState(); + + const [otherPage] = await Promise.all([ + context + .waitForTarget( + (target) => + target.url() === server.CROSS_PROCESS_PREFIX + '/empty.html' + ) + .then((target) => target.page()), + page.evaluate( + (url: string) => window.open(url), + server.CROSS_PROCESS_PREFIX + '/empty.html' + ), + ]); + expect(otherPage.url()).toContain(server.CROSS_PROCESS_PREFIX); + expect(await otherPage.evaluate(() => ['Hello', 'world'].join(' '))).toBe( + 'Hello world' + ); + expect(await otherPage.$('body')).toBeTruthy(); + + let allPages = await context.pages(); + expect(allPages).toContain(page); + expect(allPages).toContain(otherPage); + + const closePagePromise = new Promise((fulfill) => + context.once('targetdestroyed', (target) => fulfill(target.page())) + ); + await otherPage.close(); + expect(await closePagePromise).toBe(otherPage); + + allPages = await Promise.all( + context.targets().map((target) => target.page()) + ); + expect(allPages).toContain(page); + expect(allPages).not.toContain(otherPage); + } + ); + it( + 'should report when a service worker is created and destroyed', + async () => { + const { page, server, context } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + const createdTarget = new Promise<Target>((fulfill) => + context.once('targetcreated', (target) => fulfill(target)) + ); + + await page.goto(server.PREFIX + '/serviceworkers/empty/sw.html'); + + expect((await createdTarget).type()).toBe('service_worker'); + expect((await createdTarget).url()).toBe( + server.PREFIX + '/serviceworkers/empty/sw.js' + ); + + const destroyedTarget = new Promise((fulfill) => + context.once('targetdestroyed', (target) => fulfill(target)) + ); + await page.evaluate(() => + globalThis.registrationPromise.then((registration) => + registration.unregister() + ) + ); + expect(await destroyedTarget).toBe(await createdTarget); + } + ); + it('should create a worker from a service worker', async () => { + const { page, server, context } = getTestState(); + + await page.goto(server.PREFIX + '/serviceworkers/empty/sw.html'); + + const target = await context.waitForTarget( + (target) => target.type() === 'service_worker' + ); + const worker = await target.worker(); + expect(await worker.evaluate(() => self.toString())).toBe( + '[object ServiceWorkerGlobalScope]' + ); + }); + it('should create a worker from a shared worker', async () => { + const { page, server, context } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + await page.evaluate(() => { + new SharedWorker('data:text/javascript,console.log("hi")'); + }); + const target = await context.waitForTarget( + (target) => target.type() === 'shared_worker' + ); + const worker = await target.worker(); + expect(await worker.evaluate(() => self.toString())).toBe( + '[object SharedWorkerGlobalScope]' + ); + }); + it('should report when a target url changes', async () => { + const { page, server, context } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + let changedTarget = new Promise<Target>((fulfill) => + context.once('targetchanged', (target) => fulfill(target)) + ); + await page.goto(server.CROSS_PROCESS_PREFIX + '/'); + expect((await changedTarget).url()).toBe(server.CROSS_PROCESS_PREFIX + '/'); + + changedTarget = new Promise((fulfill) => + context.once('targetchanged', (target) => fulfill(target)) + ); + await page.goto(server.EMPTY_PAGE); + expect((await changedTarget).url()).toBe(server.EMPTY_PAGE); + }); + it('should not report uninitialized pages', async () => { + const { context } = getTestState(); + + let targetChanged = false; + const listener = () => (targetChanged = true); + context.on('targetchanged', listener); + const targetPromise = new Promise<Target>((fulfill) => + context.once('targetcreated', (target) => fulfill(target)) + ); + const newPagePromise = context.newPage(); + const target = await targetPromise; + expect(target.url()).toBe('about:blank'); + + const newPage = await newPagePromise; + const targetPromise2 = new Promise<Target>((fulfill) => + context.once('targetcreated', (target) => fulfill(target)) + ); + const evaluatePromise = newPage.evaluate(() => window.open('about:blank')); + const target2 = await targetPromise2; + expect(target2.url()).toBe('about:blank'); + await evaluatePromise; + await newPage.close(); + expect(targetChanged).toBe(false); + context.removeListener('targetchanged', listener); + }); + it( + 'should not crash while redirecting if original request was missed', + async () => { + const { page, server, context } = getTestState(); + + let serverResponse = null; + server.setRoute('/one-style.css', (req, res) => (serverResponse = res)); + // Open a new page. Use window.open to connect to the page later. + await Promise.all([ + page.evaluate( + (url: string) => window.open(url), + server.PREFIX + '/one-style.html' + ), + server.waitForRequest('/one-style.css'), + ]); + // Connect to the opened page. + const target = await context.waitForTarget((target) => + target.url().includes('one-style.html') + ); + const newPage = await target.page(); + // Issue a redirect. + serverResponse.writeHead(302, { location: '/injectedstyle.css' }); + serverResponse.end(); + // Wait for the new page to load. + await waitEvent(newPage, 'load'); + // Cleanup. + await newPage.close(); + } + ); + it('should have an opener', async () => { + const { page, server, context } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + const [createdTarget] = await Promise.all([ + new Promise<Target>((fulfill) => + context.once('targetcreated', (target) => fulfill(target)) + ), + page.goto(server.PREFIX + '/popup/window-open.html'), + ]); + expect((await createdTarget.page()).url()).toBe( + server.PREFIX + '/popup/popup.html' + ); + expect(createdTarget.opener()).toBe(page.target()); + expect(page.target().opener()).toBe(null); + }); + + describe('Browser.waitForTarget', () => { + it('should wait for a target', async () => { + const { browser, puppeteer, server } = getTestState(); + + let resolved = false; + const targetPromise = browser.waitForTarget( + (target) => target.url() === server.EMPTY_PAGE + ); + targetPromise + .then(() => (resolved = true)) + .catch((error) => { + resolved = true; + if (error instanceof puppeteer.errors.TimeoutError) { + console.error(error); + } else throw error; + }); + const page = await browser.newPage(); + expect(resolved).toBe(false); + await page.goto(server.EMPTY_PAGE); + try { + const target = await targetPromise; + expect(await target.page()).toBe(page); + } catch (error) { + if (error instanceof puppeteer.errors.TimeoutError) { + console.error(error); + } else throw error; + } + await page.close(); + }); + it('should timeout waiting for a non-existent target', async () => { + const { browser, server, puppeteer } = getTestState(); + + let error = null; + await browser + .waitForTarget((target) => target.url() === server.EMPTY_PAGE, { + timeout: 1, + }) + .catch((error_) => (error = error_)); + expect(error).toBeInstanceOf(puppeteer.errors.TimeoutError); + }); + }); +}); diff --git a/remote/test/puppeteer/test/touchscreen.spec.ts b/remote/test/puppeteer/test/touchscreen.spec.ts new file mode 100644 index 0000000000..b7fc67bfa9 --- /dev/null +++ b/remote/test/puppeteer/test/touchscreen.spec.ts @@ -0,0 +1,48 @@ +/** + * 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. + */ + +import expect from 'expect'; +import { + getTestState, + setupTestBrowserHooks, + setupTestPageAndContextHooks, +} from './mocha-utils'; // eslint-disable-line import/extensions + +describe('Touchscreen', function () { + setupTestBrowserHooks(); + setupTestPageAndContextHooks(); + + it('should tap the button', async () => { + const { puppeteer, page, server } = getTestState(); + const iPhone = puppeteer.devices['iPhone 6']; + await page.emulate(iPhone); + await page.goto(server.PREFIX + '/input/button.html'); + await page.tap('button'); + expect(await page.evaluate(() => globalThis.result)).toBe('Clicked'); + }); + it('should report touches', async () => { + const { puppeteer, page, server } = getTestState(); + const iPhone = puppeteer.devices['iPhone 6']; + await page.emulate(iPhone); + await page.goto(server.PREFIX + '/input/touches.html'); + const button = await page.$('button'); + await button.tap(); + expect(await page.evaluate(() => globalThis.getResult())).toEqual([ + 'Touchstart: 0', + 'Touchend: 0', + ]); + }); +}); diff --git a/remote/test/puppeteer/test/tracing.spec.ts b/remote/test/puppeteer/test/tracing.spec.ts new file mode 100644 index 0000000000..5e06f12b4c --- /dev/null +++ b/remote/test/puppeteer/test/tracing.spec.ts @@ -0,0 +1,133 @@ +/** + * 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. + */ + +import fs from 'fs'; +import path from 'path'; +import expect from 'expect'; +import { getTestState, describeChromeOnly } from './mocha-utils'; // eslint-disable-line import/extensions + +describeChromeOnly('Tracing', function () { + let outputFile; + let browser; + let page; + + /* we manually manage the browser here as we want a new browser for each + * individual test, which isn't the default behaviour of getTestState() + */ + + beforeEach(async () => { + const { defaultBrowserOptions, puppeteer } = getTestState(); + browser = await puppeteer.launch(defaultBrowserOptions); + page = await browser.newPage(); + outputFile = path.join(__dirname, 'assets', 'trace.json'); + }); + + afterEach(async () => { + await browser.close(); + browser = null; + page = null; + if (fs.existsSync(outputFile)) { + fs.unlinkSync(outputFile); + outputFile = null; + } + }); + it('should output a trace', async () => { + const { server } = getTestState(); + + await page.tracing.start({ screenshots: true, path: outputFile }); + await page.goto(server.PREFIX + '/grid.html'); + await page.tracing.stop(); + expect(fs.existsSync(outputFile)).toBe(true); + }); + + it('should run with custom categories if provided', async () => { + await page.tracing.start({ + path: outputFile, + categories: ['disabled-by-default-v8.cpu_profiler.hires'], + }); + await page.tracing.stop(); + + const traceJson = JSON.parse( + fs.readFileSync(outputFile, { encoding: 'utf8' }) + ); + expect(traceJson.metadata['trace-config']).toContain( + 'disabled-by-default-v8.cpu_profiler.hires' + ); + }); + it('should throw if tracing on two pages', async () => { + await page.tracing.start({ path: outputFile }); + const newPage = await browser.newPage(); + let error = null; + await newPage.tracing + .start({ path: outputFile }) + .catch((error_) => (error = error_)); + await newPage.close(); + expect(error).toBeTruthy(); + await page.tracing.stop(); + }); + it('should return a buffer', async () => { + const { server } = getTestState(); + + await page.tracing.start({ screenshots: true, path: outputFile }); + await page.goto(server.PREFIX + '/grid.html'); + const trace = await page.tracing.stop(); + const buf = fs.readFileSync(outputFile); + expect(trace.toString()).toEqual(buf.toString()); + }); + it('should work without options', async () => { + const { server } = getTestState(); + + await page.tracing.start(); + await page.goto(server.PREFIX + '/grid.html'); + const trace = await page.tracing.stop(); + expect(trace).toBeTruthy(); + }); + + it('should return null in case of Buffer error', async () => { + const { server } = getTestState(); + + await page.tracing.start({ screenshots: true }); + await page.goto(server.PREFIX + '/grid.html'); + const oldBufferConcat = Buffer.concat; + Buffer.concat = () => { + throw 'error'; + }; + const trace = await page.tracing.stop(); + expect(trace).toEqual(null); + Buffer.concat = oldBufferConcat; + }); + + it('should support a buffer without a path', async () => { + const { server } = getTestState(); + + await page.tracing.start({ screenshots: true }); + await page.goto(server.PREFIX + '/grid.html'); + const trace = await page.tracing.stop(); + expect(trace.toString()).toContain('screenshot'); + }); + + it('should properly fail if readProtocolStream errors out', async () => { + await page.tracing.start({ path: __dirname }); + + let error: Error = null; + try { + await page.tracing.stop(); + } catch (error_) { + error = error_; + } + expect(error).toBeDefined(); + }); +}); diff --git a/remote/test/puppeteer/test/tsconfig.json b/remote/test/puppeteer/test/tsconfig.json new file mode 100644 index 0000000000..8b1f1e866c --- /dev/null +++ b/remote/test/puppeteer/test/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["*.ts", "*.js"] +} diff --git a/remote/test/puppeteer/test/tsconfig.test.json b/remote/test/puppeteer/test/tsconfig.test.json new file mode 100644 index 0000000000..3432441200 --- /dev/null +++ b/remote/test/puppeteer/test/tsconfig.test.json @@ -0,0 +1,6 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "module": "CommonJS" + } +} diff --git a/remote/test/puppeteer/test/utils.js b/remote/test/puppeteer/test/utils.js new file mode 100644 index 0000000000..935b44d98e --- /dev/null +++ b/remote/test/puppeteer/test/utils.js @@ -0,0 +1,135 @@ +/** + * 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. + */ + +// TODO (@jackfranklin): convert to TS and enable type checking. + +// @ts-nocheck +const fs = require('fs'); +const path = require('path'); +const expect = require('expect'); +const GoldenUtils = require('./golden-utils'); +const PROJECT_ROOT = fs.existsSync(path.join(__dirname, '..', 'package.json')) + ? path.join(__dirname, '..') + : path.join(__dirname, '..', '..'); + +const utils = (module.exports = { + extendExpectWithToBeGolden: function (goldenDir, outputDir) { + expect.extend({ + toBeGolden: (testScreenshot, goldenFilePath) => { + const result = GoldenUtils.compare( + goldenDir, + outputDir, + testScreenshot, + goldenFilePath + ); + + return { + message: () => result.message, + pass: result.pass, + }; + }, + }); + }, + + /** + * @returns {string} + */ + projectRoot: function () { + return PROJECT_ROOT; + }, + + /** + * @param {!Page} page + * @param {string} frameId + * @param {string} url + * @returns {!Frame} + */ + attachFrame: async function (page, frameId, url) { + const handle = await page.evaluateHandle(attachFrame, frameId, url); + return await handle.asElement().contentFrame(); + + async function attachFrame(frameId, url) { + const frame = document.createElement('iframe'); + frame.src = url; + frame.id = frameId; + document.body.appendChild(frame); + await new Promise((x) => (frame.onload = x)); + return frame; + } + }, + + isFavicon: function (request) { + return request.url().includes('favicon.ico'); + }, + + /** + * @param {!Page} page + * @param {string} frameId + */ + detachFrame: async function (page, frameId) { + await page.evaluate(detachFrame, frameId); + + function detachFrame(frameId) { + const frame = document.getElementById(frameId); + frame.remove(); + } + }, + + /** + * @param {!Page} page + * @param {string} frameId + * @param {string} url + */ + navigateFrame: async function (page, frameId, url) { + await page.evaluate(navigateFrame, frameId, url); + + function navigateFrame(frameId, url) { + const frame = document.getElementById(frameId); + frame.src = url; + return new Promise((x) => (frame.onload = x)); + } + }, + + /** + * @param {!Frame} frame + * @param {string=} indentation + * @returns {Array<string>} + */ + dumpFrames: function (frame, indentation) { + indentation = indentation || ''; + let description = frame.url().replace(/:\d{4}\//, ':<PORT>/'); + if (frame.name()) description += ' (' + frame.name() + ')'; + const result = [indentation + description]; + for (const child of frame.childFrames()) + result.push(...utils.dumpFrames(child, ' ' + indentation)); + return result; + }, + + /** + * @param {!EventEmitter} emitter + * @param {string} eventName + * @returns {!Promise<!Object>} + */ + waitEvent: function (emitter, eventName, predicate = () => true) { + return new Promise((fulfill) => { + emitter.on(eventName, function listener(event) { + if (!predicate(event)) return; + emitter.removeListener(eventName, listener); + fulfill(event); + }); + }); + }, +}); diff --git a/remote/test/puppeteer/test/waittask.spec.ts b/remote/test/puppeteer/test/waittask.spec.ts new file mode 100644 index 0000000000..c65b682674 --- /dev/null +++ b/remote/test/puppeteer/test/waittask.spec.ts @@ -0,0 +1,774 @@ +/** + * 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. + */ + +import utils from './utils.js'; +import sinon from 'sinon'; +import expect from 'expect'; +import { + getTestState, + setupTestBrowserHooks, + setupTestPageAndContextHooks, +} from './mocha-utils'; // eslint-disable-line import/extensions + +describe('waittask specs', function () { + setupTestBrowserHooks(); + setupTestPageAndContextHooks(); + + describe('Page.waitFor', function () { + /* This method is deprecated but we don't want the warnings showing up in + * tests. Until we remove this method we still want to ensure we don't break + * it. + */ + beforeEach(() => sinon.stub(console, 'warn').callsFake(() => {})); + + it('should wait for selector', async () => { + const { page, server } = getTestState(); + + let found = false; + const waitFor = page.waitFor('div').then(() => (found = true)); + await page.goto(server.EMPTY_PAGE); + expect(found).toBe(false); + await page.goto(server.PREFIX + '/grid.html'); + await waitFor; + expect(found).toBe(true); + }); + + it('should wait for an xpath', async () => { + const { page, server } = getTestState(); + + let found = false; + const waitFor = page.waitFor('//div').then(() => (found = true)); + await page.goto(server.EMPTY_PAGE); + expect(found).toBe(false); + await page.goto(server.PREFIX + '/grid.html'); + await waitFor; + expect(found).toBe(true); + }); + it('should not allow you to select an element with single slash xpath', async () => { + const { page } = getTestState(); + + await page.setContent(`<div>some text</div>`); + let error = null; + await page.waitFor('/html/body/div').catch((error_) => (error = error_)); + expect(error).toBeTruthy(); + }); + it('should timeout', async () => { + const { page } = getTestState(); + + const startTime = Date.now(); + const timeout = 42; + await page.waitFor(timeout); + expect(Date.now() - startTime).not.toBeLessThan(timeout / 2); + }); + it('should work with multiline body', async () => { + const { page } = getTestState(); + + const result = await page.waitForFunction(` + (() => true)() + `); + expect(await result.jsonValue()).toBe(true); + }); + it('should wait for predicate', async () => { + const { page } = getTestState(); + + await Promise.all([ + page.waitFor(() => window.innerWidth < 100), + page.setViewport({ width: 10, height: 10 }), + ]); + }); + it('should throw when unknown type', async () => { + const { page } = getTestState(); + + let error = null; + // @ts-expect-error purposefully passing bad type for test + await page.waitFor({ foo: 'bar' }).catch((error_) => (error = error_)); + expect(error.message).toContain('Unsupported target type'); + }); + it('should wait for predicate with arguments', async () => { + const { page } = getTestState(); + + await page.waitFor((arg1, arg2) => arg1 !== arg2, {}, 1, 2); + }); + + it('should log a deprecation warning', async () => { + const { page } = getTestState(); + + await page.waitFor(() => true); + + const consoleWarnStub = console.warn as sinon.SinonSpy; + + expect(consoleWarnStub.calledOnce).toBe(true); + expect( + consoleWarnStub.firstCall.calledWith( + 'waitFor is deprecated and will be removed in a future release. See https://github.com/puppeteer/puppeteer/issues/6214 for details and how to migrate your code.' + ) + ).toBe(true); + expect((console.warn as sinon.SinonSpy).calledOnce).toBe(true); + }); + }); + + describe('Frame.waitForFunction', function () { + it('should accept a string', async () => { + const { page } = getTestState(); + + const watchdog = page.waitForFunction('window.__FOO === 1'); + await page.evaluate(() => (globalThis.__FOO = 1)); + await watchdog; + }); + it('should work when resolved right before execution context disposal', async () => { + const { page } = getTestState(); + + await page.evaluateOnNewDocument(() => (globalThis.__RELOADED = true)); + await page.waitForFunction(() => { + if (!globalThis.__RELOADED) window.location.reload(); + return true; + }); + }); + it('should poll on interval', async () => { + const { page } = getTestState(); + + let success = false; + const startTime = Date.now(); + const polling = 100; + const watchdog = page + .waitForFunction(() => globalThis.__FOO === 'hit', { polling }) + .then(() => (success = true)); + await page.evaluate(() => (globalThis.__FOO = 'hit')); + expect(success).toBe(false); + await page.evaluate(() => + document.body.appendChild(document.createElement('div')) + ); + await watchdog; + expect(Date.now() - startTime).not.toBeLessThan(polling / 2); + }); + it('should poll on interval async', async () => { + const { page } = getTestState(); + let success = false; + const startTime = Date.now(); + const polling = 100; + const watchdog = page + .waitForFunction(async () => globalThis.__FOO === 'hit', { polling }) + .then(() => (success = true)); + await page.evaluate(async () => (globalThis.__FOO = 'hit')); + expect(success).toBe(false); + await page.evaluate(async () => + document.body.appendChild(document.createElement('div')) + ); + await watchdog; + expect(Date.now() - startTime).not.toBeLessThan(polling / 2); + }); + it('should poll on mutation', async () => { + const { page } = getTestState(); + + let success = false; + const watchdog = page + .waitForFunction(() => globalThis.__FOO === 'hit', { + polling: 'mutation', + }) + .then(() => (success = true)); + await page.evaluate(() => (globalThis.__FOO = 'hit')); + expect(success).toBe(false); + await page.evaluate(() => + document.body.appendChild(document.createElement('div')) + ); + await watchdog; + }); + it('should poll on mutation async', async () => { + const { page } = getTestState(); + + let success = false; + const watchdog = page + .waitForFunction(async () => globalThis.__FOO === 'hit', { + polling: 'mutation', + }) + .then(() => (success = true)); + await page.evaluate(async () => (globalThis.__FOO = 'hit')); + expect(success).toBe(false); + await page.evaluate(async () => + document.body.appendChild(document.createElement('div')) + ); + await watchdog; + }); + it('should poll on raf', async () => { + const { page } = getTestState(); + + const watchdog = page.waitForFunction(() => globalThis.__FOO === 'hit', { + polling: 'raf', + }); + await page.evaluate(() => (globalThis.__FOO = 'hit')); + await watchdog; + }); + it('should poll on raf async', async () => { + const { page } = getTestState(); + + const watchdog = page.waitForFunction( + async () => globalThis.__FOO === 'hit', + { + polling: 'raf', + } + ); + await page.evaluate(async () => (globalThis.__FOO = 'hit')); + await watchdog; + }); + it('should work with strict CSP policy', async () => { + const { page, server } = getTestState(); + + server.setCSP('/empty.html', 'script-src ' + server.PREFIX); + await page.goto(server.EMPTY_PAGE); + let error = null; + await Promise.all([ + page + .waitForFunction(() => globalThis.__FOO === 'hit', { polling: 'raf' }) + .catch((error_) => (error = error_)), + page.evaluate(() => (globalThis.__FOO = 'hit')), + ]); + expect(error).toBe(null); + }); + it('should throw on bad polling value', async () => { + const { page } = getTestState(); + + let error = null; + try { + await page.waitForFunction(() => !!document.body, { + polling: 'unknown', + }); + } catch (error_) { + error = error_; + } + expect(error).toBeTruthy(); + expect(error.message).toContain('polling'); + }); + it('should throw negative polling interval', async () => { + const { page } = getTestState(); + + let error = null; + try { + await page.waitForFunction(() => !!document.body, { polling: -10 }); + } catch (error_) { + error = error_; + } + expect(error).toBeTruthy(); + expect(error.message).toContain('Cannot poll with non-positive interval'); + }); + it('should return the success value as a JSHandle', async () => { + const { page } = getTestState(); + + expect(await (await page.waitForFunction(() => 5)).jsonValue()).toBe(5); + }); + it('should return the window as a success value', async () => { + const { page } = getTestState(); + + expect(await page.waitForFunction(() => window)).toBeTruthy(); + }); + it('should accept ElementHandle arguments', async () => { + const { page } = getTestState(); + + await page.setContent('<div></div>'); + const div = await page.$('div'); + let resolved = false; + const waitForFunction = page + .waitForFunction((element) => !element.parentElement, {}, div) + .then(() => (resolved = true)); + expect(resolved).toBe(false); + await page.evaluate((element: HTMLElement) => element.remove(), div); + await waitForFunction; + }); + it('should respect timeout', async () => { + const { page, puppeteer } = getTestState(); + + let error = null; + await page + .waitForFunction('false', { timeout: 10 }) + .catch((error_) => (error = error_)); + expect(error).toBeTruthy(); + expect(error.message).toContain('waiting for function failed: timeout'); + expect(error).toBeInstanceOf(puppeteer.errors.TimeoutError); + }); + it('should respect default timeout', async () => { + const { page, puppeteer } = getTestState(); + + page.setDefaultTimeout(1); + let error = null; + await page.waitForFunction('false').catch((error_) => (error = error_)); + expect(error).toBeInstanceOf(puppeteer.errors.TimeoutError); + expect(error.message).toContain('waiting for function failed: timeout'); + }); + it('should disable timeout when its set to 0', async () => { + const { page } = getTestState(); + + const watchdog = page.waitForFunction( + () => { + globalThis.__counter = (globalThis.__counter || 0) + 1; + return globalThis.__injected; + }, + { timeout: 0, polling: 10 } + ); + await page.waitForFunction(() => globalThis.__counter > 10); + await page.evaluate(() => (globalThis.__injected = true)); + await watchdog; + }); + it('should survive cross-process navigation', async () => { + const { page, server } = getTestState(); + + let fooFound = false; + const waitForFunction = page + .waitForFunction('globalThis.__FOO === 1') + .then(() => (fooFound = true)); + await page.goto(server.EMPTY_PAGE); + expect(fooFound).toBe(false); + await page.reload(); + expect(fooFound).toBe(false); + await page.goto(server.CROSS_PROCESS_PREFIX + '/grid.html'); + expect(fooFound).toBe(false); + await page.evaluate(() => (globalThis.__FOO = 1)); + await waitForFunction; + expect(fooFound).toBe(true); + }); + it('should survive navigations', async () => { + const { page, server } = getTestState(); + + const watchdog = page.waitForFunction(() => globalThis.__done); + await page.goto(server.EMPTY_PAGE); + await page.goto(server.PREFIX + '/consolelog.html'); + await page.evaluate(() => (globalThis.__done = true)); + await watchdog; + }); + }); + + describe('Page.waitForTimeout', () => { + it('waits for the given timeout before resolving', async () => { + const { page, server } = getTestState(); + await page.goto(server.EMPTY_PAGE); + const startTime = Date.now(); + await page.waitForTimeout(1000); + const endTime = Date.now(); + /* In a perfect world endTime - startTime would be exactly 1000 but we + * expect some fluctuations and for it to be off by a little bit. So to + * avoid a flaky test we'll make sure it waited for roughly 1 second by + * ensuring 900 < endTime - startTime < 1100 + */ + expect(endTime - startTime).toBeGreaterThan(900); + expect(endTime - startTime).toBeLessThan(1100); + }); + }); + + describe('Frame.waitForTimeout', () => { + it('waits for the given timeout before resolving', async () => { + const { page, server } = getTestState(); + await page.goto(server.EMPTY_PAGE); + const frame = page.mainFrame(); + const startTime = Date.now(); + await frame.waitForTimeout(1000); + const endTime = Date.now(); + /* In a perfect world endTime - startTime would be exactly 1000 but we + * expect some fluctuations and for it to be off by a little bit. So to + * avoid a flaky test we'll make sure it waited for roughly 1 second by + * ensuring 900 < endTime - startTime < 1100 + */ + expect(endTime - startTime).toBeGreaterThan(900); + expect(endTime - startTime).toBeLessThan(1100); + }); + }); + + describe('Frame.waitForSelector', function () { + const addElement = (tag) => + document.body.appendChild(document.createElement(tag)); + + it('should immediately resolve promise if node exists', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + const frame = page.mainFrame(); + await frame.waitForSelector('*'); + await frame.evaluate(addElement, 'div'); + await frame.waitForSelector('div'); + }); + + it('should work with removed MutationObserver', async () => { + const { page } = getTestState(); + + await page.evaluate(() => delete window.MutationObserver); + const [handle] = await Promise.all([ + page.waitForSelector('.zombo'), + page.setContent(`<div class='zombo'>anything</div>`), + ]); + expect( + await page.evaluate((x: HTMLElement) => x.textContent, handle) + ).toBe('anything'); + }); + + it('should resolve promise when node is added', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + const frame = page.mainFrame(); + const watchdog = frame.waitForSelector('div'); + await frame.evaluate(addElement, 'br'); + await frame.evaluate(addElement, 'div'); + const eHandle = await watchdog; + const tagName = await eHandle + .getProperty('tagName') + .then((e) => e.jsonValue()); + expect(tagName).toBe('DIV'); + }); + + it('should work when node is added through innerHTML', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + const watchdog = page.waitForSelector('h3 div'); + await page.evaluate(addElement, 'span'); + await page.evaluate( + () => + (document.querySelector('span').innerHTML = '<h3><div></div></h3>') + ); + await watchdog; + }); + + it( + 'Page.waitForSelector is shortcut for main frame', + async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + await utils.attachFrame(page, 'frame1', server.EMPTY_PAGE); + const otherFrame = page.frames()[1]; + const watchdog = page.waitForSelector('div'); + await otherFrame.evaluate(addElement, 'div'); + await page.evaluate(addElement, 'div'); + const eHandle = await watchdog; + expect(eHandle.executionContext().frame()).toBe(page.mainFrame()); + } + ); + + it('should run in specified frame', async () => { + const { page, server } = getTestState(); + + await utils.attachFrame(page, 'frame1', server.EMPTY_PAGE); + await utils.attachFrame(page, 'frame2', server.EMPTY_PAGE); + const frame1 = page.frames()[1]; + const frame2 = page.frames()[2]; + const waitForSelectorPromise = frame2.waitForSelector('div'); + await frame1.evaluate(addElement, 'div'); + await frame2.evaluate(addElement, 'div'); + const eHandle = await waitForSelectorPromise; + expect(eHandle.executionContext().frame()).toBe(frame2); + }); + + it('should throw when frame is detached', async () => { + const { page, server } = getTestState(); + + await utils.attachFrame(page, 'frame1', server.EMPTY_PAGE); + const frame = page.frames()[1]; + let waitError = null; + const waitPromise = frame + .waitForSelector('.box') + .catch((error) => (waitError = error)); + await utils.detachFrame(page, 'frame1'); + await waitPromise; + expect(waitError).toBeTruthy(); + expect(waitError.message).toContain( + 'waitForFunction failed: frame got detached.' + ); + }); + it('should survive cross-process navigation', async () => { + const { page, server } = getTestState(); + + let boxFound = false; + const waitForSelector = page + .waitForSelector('.box') + .then(() => (boxFound = true)); + await page.goto(server.EMPTY_PAGE); + expect(boxFound).toBe(false); + await page.reload(); + expect(boxFound).toBe(false); + await page.goto(server.CROSS_PROCESS_PREFIX + '/grid.html'); + await waitForSelector; + expect(boxFound).toBe(true); + }); + it('should wait for visible', async () => { + const { page } = getTestState(); + + let divFound = false; + const waitForSelector = page + .waitForSelector('div', { visible: true }) + .then(() => (divFound = true)); + await page.setContent( + `<div style='display: none; visibility: hidden;'>1</div>` + ); + expect(divFound).toBe(false); + await page.evaluate(() => + document.querySelector('div').style.removeProperty('display') + ); + expect(divFound).toBe(false); + await page.evaluate(() => + document.querySelector('div').style.removeProperty('visibility') + ); + expect(await waitForSelector).toBe(true); + expect(divFound).toBe(true); + }); + it('should wait for visible recursively', async () => { + const { page } = getTestState(); + + let divVisible = false; + const waitForSelector = page + .waitForSelector('div#inner', { visible: true }) + .then(() => (divVisible = true)); + await page.setContent( + `<div style='display: none; visibility: hidden;'><div id="inner">hi</div></div>` + ); + expect(divVisible).toBe(false); + await page.evaluate(() => + document.querySelector('div').style.removeProperty('display') + ); + expect(divVisible).toBe(false); + await page.evaluate(() => + document.querySelector('div').style.removeProperty('visibility') + ); + expect(await waitForSelector).toBe(true); + expect(divVisible).toBe(true); + }); + it('hidden should wait for visibility: hidden', async () => { + const { page } = getTestState(); + + let divHidden = false; + await page.setContent(`<div style='display: block;'></div>`); + const waitForSelector = page + .waitForSelector('div', { hidden: true }) + .then(() => (divHidden = true)); + await page.waitForSelector('div'); // do a round trip + expect(divHidden).toBe(false); + await page.evaluate(() => + document.querySelector('div').style.setProperty('visibility', 'hidden') + ); + expect(await waitForSelector).toBe(true); + expect(divHidden).toBe(true); + }); + it('hidden should wait for display: none', async () => { + const { page } = getTestState(); + + let divHidden = false; + await page.setContent(`<div style='display: block;'></div>`); + const waitForSelector = page + .waitForSelector('div', { hidden: true }) + .then(() => (divHidden = true)); + await page.waitForSelector('div'); // do a round trip + expect(divHidden).toBe(false); + await page.evaluate(() => + document.querySelector('div').style.setProperty('display', 'none') + ); + expect(await waitForSelector).toBe(true); + expect(divHidden).toBe(true); + }); + it('hidden should wait for removal', async () => { + const { page } = getTestState(); + + await page.setContent(`<div></div>`); + let divRemoved = false; + const waitForSelector = page + .waitForSelector('div', { hidden: true }) + .then(() => (divRemoved = true)); + await page.waitForSelector('div'); // do a round trip + expect(divRemoved).toBe(false); + await page.evaluate(() => document.querySelector('div').remove()); + expect(await waitForSelector).toBe(true); + expect(divRemoved).toBe(true); + }); + it('should return null if waiting to hide non-existing element', async () => { + const { page } = getTestState(); + + const handle = await page.waitForSelector('non-existing', { + hidden: true, + }); + expect(handle).toBe(null); + }); + it('should respect timeout', async () => { + const { page, puppeteer } = getTestState(); + + let error = null; + await page + .waitForSelector('div', { timeout: 10 }) + .catch((error_) => (error = error_)); + expect(error).toBeTruthy(); + expect(error.message).toContain( + 'waiting for selector `div` failed: timeout' + ); + expect(error).toBeInstanceOf(puppeteer.errors.TimeoutError); + }); + it('should have an error message specifically for awaiting an element to be hidden', async () => { + const { page } = getTestState(); + + await page.setContent(`<div></div>`); + let error = null; + await page + .waitForSelector('div', { hidden: true, timeout: 10 }) + .catch((error_) => (error = error_)); + expect(error).toBeTruthy(); + expect(error.message).toContain( + 'waiting for selector `div` to be hidden failed: timeout' + ); + }); + + it('should respond to node attribute mutation', async () => { + const { page } = getTestState(); + + let divFound = false; + const waitForSelector = page + .waitForSelector('.zombo') + .then(() => (divFound = true)); + await page.setContent(`<div class='notZombo'></div>`); + expect(divFound).toBe(false); + await page.evaluate( + () => (document.querySelector('div').className = 'zombo') + ); + expect(await waitForSelector).toBe(true); + }); + it('should return the element handle', async () => { + const { page } = getTestState(); + + const waitForSelector = page.waitForSelector('.zombo'); + await page.setContent(`<div class='zombo'>anything</div>`); + expect( + await page.evaluate( + (x: HTMLElement) => x.textContent, + await waitForSelector + ) + ).toBe('anything'); + }); + it('should have correct stack trace for timeout', async () => { + const { page } = getTestState(); + + let error; + await page + .waitForSelector('.zombo', { timeout: 10 }) + .catch((error_) => (error = error_)); + expect(error.stack).toContain('waiting for selector `.zombo` failed'); + // The extension is ts here as Mocha maps back via sourcemaps. + expect(error.stack).toContain('waittask.spec.ts'); + }); + }); + + describe('Frame.waitForXPath', function () { + const addElement = (tag) => + document.body.appendChild(document.createElement(tag)); + + it('should support some fancy xpath', async () => { + const { page } = getTestState(); + + await page.setContent(`<p>red herring</p><p>hello world </p>`); + const waitForXPath = page.waitForXPath( + '//p[normalize-space(.)="hello world"]' + ); + expect( + await page.evaluate( + (x: HTMLElement) => x.textContent, + await waitForXPath + ) + ).toBe('hello world '); + }); + it('should respect timeout', async () => { + const { page, puppeteer } = getTestState(); + + let error = null; + await page + .waitForXPath('//div', { timeout: 10 }) + .catch((error_) => (error = error_)); + expect(error).toBeTruthy(); + expect(error.message).toContain( + 'waiting for XPath `//div` failed: timeout' + ); + expect(error).toBeInstanceOf(puppeteer.errors.TimeoutError); + }); + it('should run in specified frame', async () => { + const { page, server } = getTestState(); + + await utils.attachFrame(page, 'frame1', server.EMPTY_PAGE); + await utils.attachFrame(page, 'frame2', server.EMPTY_PAGE); + const frame1 = page.frames()[1]; + const frame2 = page.frames()[2]; + const waitForXPathPromise = frame2.waitForXPath('//div'); + await frame1.evaluate(addElement, 'div'); + await frame2.evaluate(addElement, 'div'); + const eHandle = await waitForXPathPromise; + expect(eHandle.executionContext().frame()).toBe(frame2); + }); + it('should throw when frame is detached', async () => { + const { page, server } = getTestState(); + + await utils.attachFrame(page, 'frame1', server.EMPTY_PAGE); + const frame = page.frames()[1]; + let waitError = null; + const waitPromise = frame + .waitForXPath('//*[@class="box"]') + .catch((error) => (waitError = error)); + await utils.detachFrame(page, 'frame1'); + await waitPromise; + expect(waitError).toBeTruthy(); + expect(waitError.message).toContain( + 'waitForFunction failed: frame got detached.' + ); + }); + it('hidden should wait for display: none', async () => { + const { page } = getTestState(); + + let divHidden = false; + await page.setContent(`<div style='display: block;'></div>`); + const waitForXPath = page + .waitForXPath('//div', { hidden: true }) + .then(() => (divHidden = true)); + await page.waitForXPath('//div'); // do a round trip + expect(divHidden).toBe(false); + await page.evaluate(() => + document.querySelector('div').style.setProperty('display', 'none') + ); + expect(await waitForXPath).toBe(true); + expect(divHidden).toBe(true); + }); + it('should return the element handle', async () => { + const { page } = getTestState(); + + const waitForXPath = page.waitForXPath('//*[@class="zombo"]'); + await page.setContent(`<div class='zombo'>anything</div>`); + expect( + await page.evaluate( + (x: HTMLElement) => x.textContent, + await waitForXPath + ) + ).toBe('anything'); + }); + it('should allow you to select a text node', async () => { + const { page } = getTestState(); + + await page.setContent(`<div>some text</div>`); + const text = await page.waitForXPath('//div/text()'); + expect(await (await text.getProperty('nodeType')).jsonValue()).toBe( + 3 /* Node.TEXT_NODE */ + ); + }); + it('should allow you to select an element with single slash', async () => { + const { page } = getTestState(); + + await page.setContent(`<div>some text</div>`); + const waitForXPath = page.waitForXPath('/html/body/div'); + expect( + await page.evaluate( + (x: HTMLElement) => x.textContent, + await waitForXPath + ) + ).toBe('some text'); + }); + }); +}); diff --git a/remote/test/puppeteer/test/worker.spec.ts b/remote/test/puppeteer/test/worker.spec.ts new file mode 100644 index 0000000000..2c5827361b --- /dev/null +++ b/remote/test/puppeteer/test/worker.spec.ts @@ -0,0 +1,126 @@ +/** + * Copyright 2020 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. + */ + +import expect from 'expect'; +import { + getTestState, + setupTestBrowserHooks, + setupTestPageAndContextHooks, + describeFailsFirefox, +} from './mocha-utils'; // eslint-disable-line import/extensions +import utils from './utils.js'; +import { WebWorker } from '../lib/cjs/puppeteer/common/WebWorker.js'; +import { ConsoleMessage } from '../lib/cjs/puppeteer/common/ConsoleMessage.js'; +const { waitEvent } = utils; + +describeFailsFirefox('Workers', function () { + setupTestBrowserHooks(); + setupTestPageAndContextHooks(); + it('Page.workers', async () => { + const { page, server } = getTestState(); + + await Promise.all([ + new Promise((x) => page.once('workercreated', x)), + page.goto(server.PREFIX + '/worker/worker.html'), + ]); + const worker = page.workers()[0]; + expect(worker.url()).toContain('worker.js'); + + expect(await worker.evaluate(() => globalThis.workerFunction())).toBe( + 'worker function result' + ); + + await page.goto(server.EMPTY_PAGE); + expect(page.workers().length).toBe(0); + }); + it('should emit created and destroyed events', async () => { + const { page } = getTestState(); + + const workerCreatedPromise = new Promise<WebWorker>((x) => + page.once('workercreated', x) + ); + const workerObj = await page.evaluateHandle( + () => new Worker('data:text/javascript,1') + ); + const worker = await workerCreatedPromise; + const workerThisObj = await worker.evaluateHandle(() => this); + const workerDestroyedPromise = new Promise((x) => + page.once('workerdestroyed', x) + ); + await page.evaluate( + (workerObj: Worker) => workerObj.terminate(), + workerObj + ); + expect(await workerDestroyedPromise).toBe(worker); + const error = await workerThisObj + .getProperty('self') + .catch((error) => error); + expect(error.message).toContain('Most likely the worker has been closed.'); + }); + it('should report console logs', async () => { + const { page } = getTestState(); + + const [message] = await Promise.all([ + waitEvent(page, 'console'), + page.evaluate(() => new Worker(`data:text/javascript,console.log(1)`)), + ]); + expect(message.text()).toBe('1'); + expect(message.location()).toEqual({ + url: 'data:text/javascript,console.log(1)', + lineNumber: 0, + columnNumber: 8, + }); + }); + it('should have JSHandles for console logs', async () => { + const { page } = getTestState(); + + const logPromise = new Promise<ConsoleMessage>((x) => + page.on('console', x) + ); + await page.evaluate( + () => new Worker(`data:text/javascript,console.log(1,2,3,this)`) + ); + const log = await logPromise; + expect(log.text()).toBe('1 2 3 JSHandle@object'); + expect(log.args().length).toBe(4); + expect(await (await log.args()[3].getProperty('origin')).jsonValue()).toBe( + 'null' + ); + }); + it('should have an execution context', async () => { + const { page } = getTestState(); + + const workerCreatedPromise = new Promise<WebWorker>((x) => + page.once('workercreated', x) + ); + await page.evaluate( + () => new Worker(`data:text/javascript,console.log(1)`) + ); + const worker = await workerCreatedPromise; + expect(await (await worker.executionContext()).evaluate('1+1')).toBe(2); + }); + it('should report errors', async () => { + const { page } = getTestState(); + + const errorPromise = new Promise<Error>((x) => page.on('pageerror', x)); + await page.evaluate( + () => + new Worker(`data:text/javascript, throw new Error('this is my error');`) + ); + const errorLog = await errorPromise; + expect(errorLog.message).toContain('this is my error'); + }); +}); diff --git a/remote/test/puppeteer/tsconfig-esm.json b/remote/test/puppeteer/tsconfig-esm.json new file mode 100644 index 0000000000..06d8e25d98 --- /dev/null +++ b/remote/test/puppeteer/tsconfig-esm.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./lib/esm", + "module": "ES2015", + }, +} diff --git a/remote/test/puppeteer/tsconfig.base.json b/remote/test/puppeteer/tsconfig.base.json new file mode 100644 index 0000000000..97d1cd068a --- /dev/null +++ b/remote/test/puppeteer/tsconfig.base.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "esModuleInterop": true, + "allowJs": true, + "checkJs": true, + "target": "ESNext", + "moduleResolution": "node", + "module": "ESNext", + "declaration": true, + "declarationMap": true, + "resolveJsonModule": true + } +} diff --git a/remote/test/puppeteer/tsconfig.json b/remote/test/puppeteer/tsconfig.json new file mode 100644 index 0000000000..69717ed6d9 --- /dev/null +++ b/remote/test/puppeteer/tsconfig.json @@ -0,0 +1,11 @@ +/** + * This configuration only exists for the API Extractor tool. See the details in + * CONTRIBUTING.md that describes our TypeScript setup. +*/ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["src"] +} diff --git a/remote/test/puppeteer/typescript-if-required.js b/remote/test/puppeteer/typescript-if-required.js new file mode 100644 index 0000000000..96e6b541a7 --- /dev/null +++ b/remote/test/puppeteer/typescript-if-required.js @@ -0,0 +1,61 @@ +/** + * Copyright 2020 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 child_process = require('child_process'); +const path = require('path'); +const fs = require('fs'); +const { promisify } = require('util'); + +const exec = promisify(child_process.exec); +const fsAccess = promisify(fs.access); + +const fileExists = async (filePath) => + fsAccess(filePath) + .then(() => true) + .catch(() => false); +/* + + * Now Puppeteer is built with TypeScript, we need to ensure that + * locally we have the generated output before trying to install. + * + * For users installing puppeteer this is fine, they will have the + * generated lib/ directory as we ship it when we publish to npm. + * + * However, if you're cloning the repo to contribute, you won't have the + * generated lib/ directory so this script checks if we need to run + * TypeScript first to ensure the output exists and is in the right + * place. + */ +async function compileTypeScript() { + return exec('npm run tsc').catch((error) => { + console.error('Error running TypeScript', error); + process.exit(1); + }); +} + +async function compileTypeScriptIfRequired() { + const libPath = path.join(__dirname, 'lib'); + const libExists = await fileExists(libPath); + if (libExists) return; + + console.log('Puppeteer:', 'Compiling TypeScript...'); + await compileTypeScript(); +} + +// It's being run as node typescript-if-required.js, not require('..') +if (require.main === module) compileTypeScriptIfRequired(); + +module.exports = compileTypeScriptIfRequired; diff --git a/remote/test/puppeteer/utils/ESTreeWalker.js b/remote/test/puppeteer/utils/ESTreeWalker.js new file mode 100644 index 0000000000..1c6c6d4782 --- /dev/null +++ b/remote/test/puppeteer/utils/ESTreeWalker.js @@ -0,0 +1,135 @@ +// Copyright (c) 2014 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/** + * @unrestricted + */ +class ESTreeWalker { + /** + * @param {function(!ESTree.Node):(!Object|undefined)} beforeVisit + * @param {function(!ESTree.Node)=} afterVisit + */ + constructor(beforeVisit, afterVisit) { + this._beforeVisit = beforeVisit; + this._afterVisit = afterVisit || new Function(); + } + + /** + * @param {!ESTree.Node} ast + */ + walk(ast) { + this._innerWalk(ast, null); + } + + /** + * @param {!ESTree.Node} node + * @param {?ESTree.Node} parent + */ + _innerWalk(node, parent) { + if (!node) return; + node.parent = parent; + + if (this._beforeVisit.call(null, node) === ESTreeWalker.SkipSubtree) { + this._afterVisit.call(null, node); + return; + } + + const walkOrder = ESTreeWalker._walkOrder[node.type]; + if (!walkOrder) return; + + if (node.type === 'TemplateLiteral') { + const templateLiteral = /** @type {!ESTree.TemplateLiteralNode} */ (node); + const expressionsLength = templateLiteral.expressions.length; + for (let i = 0; i < expressionsLength; ++i) { + this._innerWalk(templateLiteral.quasis[i], templateLiteral); + this._innerWalk(templateLiteral.expressions[i], templateLiteral); + } + this._innerWalk( + templateLiteral.quasis[expressionsLength], + templateLiteral + ); + } else { + for (let i = 0; i < walkOrder.length; ++i) { + const entity = node[walkOrder[i]]; + if (Array.isArray(entity)) this._walkArray(entity, node); + else this._innerWalk(entity, node); + } + } + + this._afterVisit.call(null, node); + } + + /** + * @param {!Array.<!ESTree.Node>} nodeArray + * @param {?ESTree.Node} parentNode + */ + _walkArray(nodeArray, parentNode) { + for (let i = 0; i < nodeArray.length; ++i) + this._innerWalk(nodeArray[i], parentNode); + } +} + +/** @typedef {!Object} ESTreeWalker.SkipSubtree */ +ESTreeWalker.SkipSubtree = {}; + +/** @enum {!Array.<string>} */ +ESTreeWalker._walkOrder = { + AwaitExpression: ['argument'], + ArrayExpression: ['elements'], + ArrowFunctionExpression: ['params', 'body'], + AssignmentExpression: ['left', 'right'], + AssignmentPattern: ['left', 'right'], + BinaryExpression: ['left', 'right'], + BlockStatement: ['body'], + BreakStatement: ['label'], + CallExpression: ['callee', 'arguments'], + CatchClause: ['param', 'body'], + ClassBody: ['body'], + ClassDeclaration: ['id', 'superClass', 'body'], + ClassExpression: ['id', 'superClass', 'body'], + ConditionalExpression: ['test', 'consequent', 'alternate'], + ContinueStatement: ['label'], + DebuggerStatement: [], + DoWhileStatement: ['body', 'test'], + EmptyStatement: [], + ExpressionStatement: ['expression'], + ForInStatement: ['left', 'right', 'body'], + ForOfStatement: ['left', 'right', 'body'], + ForStatement: ['init', 'test', 'update', 'body'], + FunctionDeclaration: ['id', 'params', 'body'], + FunctionExpression: ['id', 'params', 'body'], + Identifier: [], + IfStatement: ['test', 'consequent', 'alternate'], + LabeledStatement: ['label', 'body'], + Literal: [], + LogicalExpression: ['left', 'right'], + MemberExpression: ['object', 'property'], + MethodDefinition: ['key', 'value'], + NewExpression: ['callee', 'arguments'], + ObjectExpression: ['properties'], + ObjectPattern: ['properties'], + ParenthesizedExpression: ['expression'], + Program: ['body'], + Property: ['key', 'value'], + ReturnStatement: ['argument'], + SequenceExpression: ['expressions'], + Super: [], + SwitchCase: ['test', 'consequent'], + SwitchStatement: ['discriminant', 'cases'], + TaggedTemplateExpression: ['tag', 'quasi'], + TemplateElement: [], + TemplateLiteral: ['quasis', 'expressions'], + ThisExpression: [], + ThrowStatement: ['argument'], + TryStatement: ['block', 'handler', 'finalizer'], + UnaryExpression: ['argument'], + UpdateExpression: ['argument'], + VariableDeclaration: ['declarations'], + VariableDeclarator: ['id', 'init'], + WhileStatement: ['test', 'body'], + WithStatement: ['object', 'body'], + YieldExpression: ['argument'], +}; + +module.exports = ESTreeWalker; diff --git a/remote/test/puppeteer/utils/apply_next_version.js b/remote/test/puppeteer/utils/apply_next_version.js new file mode 100644 index 0000000000..cd62839d2a --- /dev/null +++ b/remote/test/puppeteer/utils/apply_next_version.js @@ -0,0 +1,32 @@ +const path = require('path'); +const fs = require('fs'); +const execSync = require('child_process').execSync; + +// Compare current HEAD to upstream main SHA. +// If they are not equal - refuse to publish since +// we're not tip-of-tree. +const upstream_sha = execSync( + `git ls-remote https://github.com/puppeteer/puppeteer --tags main | cut -f1` +).toString('utf8'); +const current_sha = execSync(`git rev-parse HEAD`).toString('utf8'); +if (upstream_sha.trim() !== current_sha.trim()) { + console.log('REFUSING TO PUBLISH: this is not tip-of-tree!'); + process.exit(1); + return; +} + +const package = require('../package.json'); +let version = package.version; +const dashIndex = version.indexOf('-'); +if (dashIndex !== -1) version = version.substring(0, dashIndex); +version += '-next.' + Date.now(); +console.log('Setting version to ' + version); +package.version = version; +fs.writeFileSync( + path.join(__dirname, '..', 'package.json'), + JSON.stringify(package, undefined, 2) + '\n' +); + +console.log( + 'IMPORTANT: you should update the pinned version of devtools-protocol to match the new revision.' +); diff --git a/remote/test/puppeteer/utils/bisect.js b/remote/test/puppeteer/utils/bisect.js new file mode 100755 index 0000000000..d81fdec8f5 --- /dev/null +++ b/remote/test/puppeteer/utils/bisect.js @@ -0,0 +1,229 @@ +#!/usr/bin/env node +/** + * 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 URL = require('url'); +const debug = require('debug'); +const pptr = require('..'); +const browserFetcher = pptr.createBrowserFetcher(); +const path = require('path'); +const fs = require('fs'); +const { fork } = require('child_process'); + +const COLOR_RESET = '\x1b[0m'; +const COLOR_RED = '\x1b[31m'; +const COLOR_GREEN = '\x1b[32m'; +const COLOR_YELLOW = '\x1b[33m'; + +const argv = require('minimist')(process.argv.slice(2), {}); + +const help = ` +Usage: + node bisect.js --good <revision> --bad <revision> <script> + +Parameters: + --good revision that is known to be GOOD + --bad revision that is known to be BAD + <script> path to the script that returns non-zero code for BAD revisions and 0 for good + +Example: + node utils/bisect.js --good 577361 --bad 599821 simple.js +`; + +if (argv.h || argv.help) { + console.log(help); + process.exit(0); +} + +if (typeof argv.good !== 'number') { + console.log( + COLOR_RED + 'ERROR: expected --good argument to be a number' + COLOR_RESET + ); + console.log(help); + process.exit(1); +} + +if (typeof argv.bad !== 'number') { + console.log( + COLOR_RED + 'ERROR: expected --bad argument to be a number' + COLOR_RESET + ); + console.log(help); + process.exit(1); +} + +const scriptPath = path.resolve(argv._[0]); +if (!fs.existsSync(scriptPath)) { + console.log( + COLOR_RED + + 'ERROR: Expected to be given a path to a script to run' + + COLOR_RESET + ); + console.log(help); + process.exit(1); +} + +(async (scriptPath, good, bad) => { + const span = Math.abs(good - bad); + console.log( + `Bisecting ${COLOR_YELLOW}${span}${COLOR_RESET} revisions in ${COLOR_YELLOW}~${ + span.toString(2).length + }${COLOR_RESET} iterations` + ); + + while (true) { + const middle = Math.round((good + bad) / 2); + const revision = await findDownloadableRevision(middle, good, bad); + if (!revision || revision === good || revision === bad) break; + let info = browserFetcher.revisionInfo(revision); + const shouldRemove = !info.local; + info = await downloadRevision(revision); + const exitCode = await runScript(scriptPath, info); + if (shouldRemove) await browserFetcher.remove(revision); + let outcome; + if (exitCode) { + bad = revision; + outcome = COLOR_RED + 'BAD' + COLOR_RESET; + } else { + good = revision; + outcome = COLOR_GREEN + 'GOOD' + COLOR_RESET; + } + const span = Math.abs(good - bad); + let fromText = ''; + let toText = ''; + if (good < bad) { + fromText = COLOR_GREEN + good + COLOR_RESET; + toText = COLOR_RED + bad + COLOR_RESET; + } else { + fromText = COLOR_RED + bad + COLOR_RESET; + toText = COLOR_GREEN + good + COLOR_RESET; + } + console.log( + `- ${COLOR_YELLOW}r${revision}${COLOR_RESET} was ${outcome}. Bisecting [${fromText}, ${toText}] - ${COLOR_YELLOW}${span}${COLOR_RESET} revisions and ${COLOR_YELLOW}~${ + span.toString(2).length + }${COLOR_RESET} iterations` + ); + } + + const [fromSha, toSha] = await Promise.all([ + revisionToSha(Math.min(good, bad)), + revisionToSha(Math.max(good, bad)), + ]); + console.log( + `RANGE: https://chromium.googlesource.com/chromium/src/+log/${fromSha}..${toSha}` + ); +})(scriptPath, argv.good, argv.bad); + +function runScript(scriptPath, revisionInfo) { + const log = debug('bisect:runscript'); + log('Running script'); + const child = fork(scriptPath, [], { + stdio: ['inherit', 'inherit', 'inherit', 'ipc'], + env: { + ...process.env, + PUPPETEER_EXECUTABLE_PATH: revisionInfo.executablePath, + }, + }); + return new Promise((resolve, reject) => { + child.on('error', (err) => reject(err)); + child.on('exit', (code) => resolve(code)); + }); +} + +async function downloadRevision(revision) { + const log = debug('bisect:download'); + log(`Downloading ${revision}`); + let progressBar = null; + let lastDownloadedBytes = 0; + return await browserFetcher.download( + revision, + (downloadedBytes, totalBytes) => { + if (!progressBar) { + const ProgressBar = require('progress'); + progressBar = new ProgressBar( + `- downloading Chromium r${revision} - ${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`; + } +} + +async function findDownloadableRevision(rev, from, to) { + const log = debug('bisect:findrev'); + const min = Math.min(from, to); + const max = Math.max(from, to); + log(`Looking around ${rev} from [${min}, ${max}]`); + if (await browserFetcher.canDownload(rev)) return rev; + let down = rev; + let up = rev; + while (min <= down || up <= max) { + const [downOk, upOk] = await Promise.all([ + down > min ? probe(--down) : Promise.resolve(false), + up < max ? probe(++up) : Promise.resolve(false), + ]); + if (downOk) return down; + if (upOk) return up; + } + return null; + + async function probe(rev) { + const result = await browserFetcher.canDownload(rev); + log(` ${rev} - ${result ? 'OK' : 'missing'}`); + return result; + } +} + +async function revisionToSha(revision) { + const json = await fetchJSON( + 'https://cr-rev.appspot.com/_ah/api/crrev/v1/redirect/' + revision + ); + return json.git_sha; +} + +function fetchJSON(url) { + return new Promise((resolve, reject) => { + const agent = url.startsWith('https://') + ? require('https') + : require('http'); + const options = URL.parse(url); + options.method = 'GET'; + options.headers = { + 'Content-Type': 'application/json', + }; + const req = agent.request(options, function (res) { + let result = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => (result += chunk)); + res.on('end', () => resolve(JSON.parse(result))); + }); + req.on('error', (err) => reject(err)); + req.end(); + }); +} diff --git a/remote/test/puppeteer/utils/check_availability.js b/remote/test/puppeteer/utils/check_availability.js new file mode 100755 index 0000000000..e24e2b9dc3 --- /dev/null +++ b/remote/test/puppeteer/utils/check_availability.js @@ -0,0 +1,298 @@ +#!/usr/bin/env node +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const assert = require('assert'); +const https = require('https'); +// run `npm run dev-install` if lib dir is missing +const BrowserFetcher = require('../lib/cjs/puppeteer/node/BrowserFetcher.js') + .BrowserFetcher; + +const SUPPORTER_PLATFORMS = ['linux', 'mac', 'win32', 'win64']; +const fetchers = SUPPORTER_PLATFORMS.map( + (platform) => new BrowserFetcher('', { platform }) +); + +const colors = { + reset: '\x1b[0m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', +}; + +class Table { + /** + * @param {!Array<number>} columnWidths + */ + constructor(columnWidths) { + this.widths = columnWidths; + } + + /** + * @param {!Array<string>} values + */ + drawRow(values) { + assert(values.length === this.widths.length); + let row = ''; + for (let i = 0; i < values.length; ++i) + row += padCenter(values[i], this.widths[i]); + console.log(row); + } +} + +const helpMessage = ` +This script checks availability of prebuilt Chromium snapshots. + +Usage: node check_availability.js [<options>] [<browser version(s)>] + +options + -f full mode checks availability of all the platforms, default mode + -r roll mode checks for the most recent stable Chromium roll candidate + -rb roll mode checks for the most recent beta Chromium roll candidate + -rd roll mode checks for the most recent dev Chromium roll candidate + -h show this help + +browser version(s) + <revision> single revision number means checking for this specific revision + <from> <to> checks all the revisions within a given range, inclusively + +Examples + To check Chromium availability of a certain revision + node check_availability.js [revision] + + To find a Chromium roll candidate for current stable Linux version + node check_availability.js -r + use -rb for beta and -rd for dev versions. + + To check Chromium availability from the latest revision in a descending order + node check_availability.js +`; + +function main() { + const args = process.argv.slice(2); + + if (args.length > 3) { + console.log(helpMessage); + return; + } + + if (args.length === 0) { + checkOmahaProxyAvailability(); + return; + } + + if (args[0].startsWith('-')) { + const option = args[0].substring(1); + switch (option) { + case 'f': + break; + case 'r': + checkRollCandidate('stable'); + return; + case 'rb': + checkRollCandidate('beta'); + return; + case 'rd': + checkRollCandidate('dev'); + return; + default: + console.log(helpMessage); + return; + } + args.splice(0, 1); // remove options arg since we are done with options + } + + if (args.length === 1) { + const revision = parseInt(args[0], 10); + checkRangeAvailability({ + fromRevision: revision, + toRevision: revision, + stopWhenAllAvailable: false, + }); + } else { + const fromRevision = parseInt(args[0], 10); + const toRevision = parseInt(args[1], 10); + checkRangeAvailability({ + fromRevision, + toRevision, + stopWhenAllAvailable: false, + }); + } +} + +async function checkOmahaProxyAvailability() { + const latestRevisions = ( + await Promise.all([ + fetch( + 'https://storage.googleapis.com/chromium-browser-snapshots/Mac/LAST_CHANGE' + ), + fetch( + 'https://storage.googleapis.com/chromium-browser-snapshots/Linux_x64/LAST_CHANGE' + ), + fetch( + 'https://storage.googleapis.com/chromium-browser-snapshots/Win/LAST_CHANGE' + ), + fetch( + 'https://storage.googleapis.com/chromium-browser-snapshots/Win_x64/LAST_CHANGE' + ), + ]) + ).map((s) => parseInt(s, 10)); + const from = Math.max(...latestRevisions); + checkRangeAvailability({ + fromRevision: from, + toRevision: 0, + stopWhenAllAvailable: false, + }); +} +async function checkRollCandidate(channel) { + const omahaResponse = await fetch( + `https://omahaproxy.appspot.com/all.json?channel=${channel}&os=linux` + ); + const linuxInfo = JSON.parse(omahaResponse)[0]; + if (!linuxInfo) { + console.error(`no ${channel} linux information available from omahaproxy`); + return; + } + + const linuxRevision = parseInt( + linuxInfo.versions[0].branch_base_position, + 10 + ); + const currentRevision = parseInt( + require('../lib/cjs/puppeteer/revisions').PUPPETEER_REVISIONS.chromium, + 10 + ); + + checkRangeAvailability({ + fromRevision: linuxRevision, + toRevision: currentRevision, + stopWhenAllAvailable: true, + }); +} + +/** + * @param {*} options + */ +async function checkRangeAvailability({ + fromRevision, + toRevision, + stopWhenAllAvailable, +}) { + const table = new Table([10, 7, 7, 7, 7]); + table.drawRow([''].concat(SUPPORTER_PLATFORMS)); + + const inc = fromRevision < toRevision ? 1 : -1; + const revisionToStop = toRevision + inc; // +inc so the range is fully inclusive + for ( + let revision = fromRevision; + revision !== revisionToStop; + revision += inc + ) { + const allAvailable = await checkAndDrawRevisionAvailability( + table, + '', + revision + ); + if (allAvailable && stopWhenAllAvailable) break; + } +} + +/** + * @param {!Table} table + * @param {string} name + * @param {number} revision + * @returns {boolean} + */ +async function checkAndDrawRevisionAvailability(table, name, revision) { + const promises = fetchers.map((fetcher) => fetcher.canDownload(revision)); + const availability = await Promise.all(promises); + const allAvailable = availability.every((e) => !!e); + const values = [ + name + + ' ' + + (allAvailable ? colors.green + revision + colors.reset : revision), + ]; + for (let i = 0; i < availability.length; ++i) { + const decoration = availability[i] ? '+' : '-'; + const color = availability[i] ? colors.green : colors.red; + values.push(color + decoration + colors.reset); + } + table.drawRow(values); + return allAvailable; +} + +/** + * @param {string} url + * @returns {!Promise<?string>} + */ +function fetch(url) { + let resolve; + const promise = new Promise((x) => (resolve = x)); + https + .get(url, (response) => { + if (response.statusCode !== 200) { + resolve(null); + return; + } + let body = ''; + response.on('data', function (chunk) { + body += chunk; + }); + response.on('end', function () { + resolve(body); + }); + }) + .on('error', function (e) { + console.error('Error fetching json: ' + e); + resolve(null); + }); + return promise; +} + +/** + * @param {number} size + * @returns {string} + */ +function spaceString(size) { + return new Array(size).fill(' ').join(''); +} + +/** + * @param {string} text + * @returns {string} + */ +function filterOutColors(text) { + for (const colorName in colors) { + const color = colors[colorName]; + text = text.replace(color, ''); + } + return text; +} + +/** + * @param {string} text + * @param {number} length + * @returns {string} + */ +function padCenter(text, length) { + const printableCharacters = filterOutColors(text); + if (printableCharacters.length >= length) return text; + const left = Math.floor((length - printableCharacters.length) / 2); + const right = Math.ceil((length - printableCharacters.length) / 2); + return spaceString(left) + text + spaceString(right); +} + +main(); diff --git a/remote/test/puppeteer/utils/doclint/.gitignore b/remote/test/puppeteer/utils/doclint/.gitignore new file mode 100644 index 0000000000..ea1472ec1f --- /dev/null +++ b/remote/test/puppeteer/utils/doclint/.gitignore @@ -0,0 +1 @@ +output/ diff --git a/remote/test/puppeteer/utils/doclint/Message.js b/remote/test/puppeteer/utils/doclint/Message.js new file mode 100644 index 0000000000..374b60d44d --- /dev/null +++ b/remote/test/puppeteer/utils/doclint/Message.js @@ -0,0 +1,44 @@ +/** + * 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. + */ + +class Message { + /** + * @param {string} type + * @param {string} text + */ + constructor(type, text) { + this.type = type; + this.text = text; + } + + /** + * @param {string} text + * @returns {!Message} + */ + static error(text) { + return new Message('error', text); + } + + /** + * @param {string} text + * @returns {!Message} + */ + static warning(text) { + return new Message('warning', text); + } +} + +module.exports = Message; diff --git a/remote/test/puppeteer/utils/doclint/README.md b/remote/test/puppeteer/utils/doclint/README.md new file mode 100644 index 0000000000..b3f8c366de --- /dev/null +++ b/remote/test/puppeteer/utils/doclint/README.md @@ -0,0 +1,31 @@ +# DocLint + +**Doclint** is a small program that lints Puppeteer's documentation against +Puppeteer's source code. + +Doclint works in a few steps: + +1. Read sources in `lib/` folder, parse AST trees and extract public API + - note that we run DocLint on the outputted JavaScript in `lib/` rather than the source code in `src/`. We will do this until we have migrated `src/` to be exclusively TypeScript and then we can update DocLint to support TypeScript. +2. Read sources in `docs/` folder, render markdown to HTML, use puppeteer to traverse the HTML + and extract described API +3. Compare one API to another + +Doclint is also responsible for general markdown checks, most notably for the table of contents +relevancy. + +## Running + +```bash +npm run doc +``` + +## Tests + +Doclint has its own set of jasmine tests, located at `utils/doclint/test` folder. + +To execute tests, run: + +```bash +npm run test-doclint +``` diff --git a/remote/test/puppeteer/utils/doclint/Source.js b/remote/test/puppeteer/utils/doclint/Source.js new file mode 100644 index 0000000000..0f7c13234f --- /dev/null +++ b/remote/test/puppeteer/utils/doclint/Source.js @@ -0,0 +1,117 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const path = require('path'); +const util = require('util'); +const fs = require('fs'); + +const readFileAsync = util.promisify(fs.readFile); +const readdirAsync = util.promisify(fs.readdir); +const writeFileAsync = util.promisify(fs.writeFile); + +const PROJECT_DIR = path.join(__dirname, '..', '..'); + +class Source { + /** + * @param {string} filePath + * @param {string} text + */ + constructor(filePath, text) { + this._filePath = filePath; + this._projectPath = path.relative(PROJECT_DIR, filePath); + this._name = path.basename(filePath); + this._text = text; + this._hasUpdatedText = false; + } + + /** + * @returns {string} + */ + filePath() { + return this._filePath; + } + + /** + * @returns {string} + */ + projectPath() { + return this._projectPath; + } + + /** + * @returns {string} + */ + name() { + return this._name; + } + + /** + * @param {string} text + * @returns {boolean} + */ + setText(text) { + if (text === this._text) return false; + this._hasUpdatedText = true; + this._text = text; + return true; + } + + /** + * @returns {string} + */ + text() { + return this._text; + } + + /** + * @returns {boolean} + */ + hasUpdatedText() { + return this._hasUpdatedText; + } + + async save() { + await writeFileAsync(this.filePath(), this.text()); + } + + /** + * @param {string} filePath + * @returns {!Promise<Source>} + */ + static async readFile(filePath) { + filePath = path.resolve(filePath); + const text = await readFileAsync(filePath, { encoding: 'utf8' }); + return new Source(filePath, text); + } + + /** + * @param {string} dirPath + * @param {string=} extension + * @returns {!Promise<!Array<!Source>>} + */ + static async readdir(dirPath, extension = '') { + const fileNames = await readdirAsync(dirPath); + const filePaths = fileNames + .filter((fileName) => fileName.endsWith(extension)) + .map((fileName) => path.join(dirPath, fileName)) + .filter((filePath) => { + const stats = fs.lstatSync(filePath); + return stats.isDirectory() === false; + }); + return Promise.all(filePaths.map((filePath) => Source.readFile(filePath))); + } +} +module.exports = Source; diff --git a/remote/test/puppeteer/utils/doclint/check_public_api/Documentation.js b/remote/test/puppeteer/utils/doclint/check_public_api/Documentation.js new file mode 100644 index 0000000000..9bbaabfc70 --- /dev/null +++ b/remote/test/puppeteer/utils/doclint/check_public_api/Documentation.js @@ -0,0 +1,157 @@ +/** + * 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. + */ + +class Documentation { + /** + * @param {!Array<!Documentation.Class>} classesArray + */ + constructor(classesArray) { + this.classesArray = classesArray; + /** @type {!Map<string, !Documentation.Class>} */ + this.classes = new Map(); + for (const cls of classesArray) this.classes.set(cls.name, cls); + } +} + +Documentation.Class = class { + /** + * @param {string} name + * @param {!Array<!Documentation.Member>} membersArray + * @param {?string=} extendsName + * @param {string=} comment + */ + constructor(name, membersArray, extendsName = null, comment = '') { + this.name = name; + this.membersArray = membersArray; + /** @type {!Map<string, !Documentation.Member>} */ + this.members = new Map(); + /** @type {!Map<string, !Documentation.Member>} */ + this.properties = new Map(); + /** @type {!Array<!Documentation.Member>} */ + this.propertiesArray = []; + /** @type {!Map<string, !Documentation.Member>} */ + this.methods = new Map(); + /** @type {!Array<!Documentation.Member>} */ + this.methodsArray = []; + /** @type {!Map<string, !Documentation.Member>} */ + this.events = new Map(); + /** @type {!Array<!Documentation.Member>} */ + this.eventsArray = []; + this.comment = comment; + this.extends = extendsName; + for (const member of membersArray) { + this.members.set(member.name, member); + if (member.kind === 'method') { + this.methods.set(member.name, member); + this.methodsArray.push(member); + } else if (member.kind === 'property') { + this.properties.set(member.name, member); + this.propertiesArray.push(member); + } else if (member.kind === 'event') { + this.events.set(member.name, member); + this.eventsArray.push(member); + } + } + } +}; + +Documentation.Member = class { + /** + * @param {string} kind + * @param {string} name + * @param {?Documentation.Type} type + * @param {!Array<!Documentation.Member>} argsArray + */ + constructor( + kind, + name, + type, + argsArray, + comment = '', + returnComment = '', + required = true + ) { + this.kind = kind; + this.name = name; + this.type = type; + this.comment = comment; + this.returnComment = returnComment; + this.argsArray = argsArray; + this.required = required; + /** @type {!Map<string, !Documentation.Member>} */ + this.args = new Map(); + for (const arg of argsArray) this.args.set(arg.name, arg); + } + + /** + * @param {string} name + * @param {!Array<!Documentation.Member>} argsArray + * @param {?Documentation.Type} returnType + * @returns {!Documentation.Member} + */ + static createMethod(name, argsArray, returnType, returnComment, comment) { + return new Documentation.Member( + 'method', + name, + returnType, + argsArray, + comment, + returnComment + ); + } + + /** + * @param {string} name + * @param {!Documentation.Type} type + * @param {string=} comment + * @param {boolean=} required + * @returns {!Documentation.Member} + */ + static createProperty(name, type, comment, required) { + return new Documentation.Member( + 'property', + name, + type, + [], + comment, + undefined, + required + ); + } + + /** + * @param {string} name + * @param {?Documentation.Type=} type + * @param {string=} comment + * @returns {!Documentation.Member} + */ + static createEvent(name, type = null, comment) { + return new Documentation.Member('event', name, type, [], comment); + } +}; + +Documentation.Type = class { + /** + * @param {string} name + * @param {!Array<!Documentation.Member>=} properties + */ + constructor(name, properties = []) { + this.name = name; + this.properties = properties; + } +}; + +module.exports = Documentation; diff --git a/remote/test/puppeteer/utils/doclint/check_public_api/JSBuilder.js b/remote/test/puppeteer/utils/doclint/check_public_api/JSBuilder.js new file mode 100644 index 0000000000..0994dffcff --- /dev/null +++ b/remote/test/puppeteer/utils/doclint/check_public_api/JSBuilder.js @@ -0,0 +1,279 @@ +const ts = require('typescript'); +const path = require('path'); +const Documentation = require('./Documentation'); +module.exports = checkSources; + +/** + * @param {!Array<!import('../Source')>} sources + */ +function checkSources(sources) { + // special treatment for Events.js + const classEvents = new Map(); + const eventsSource = sources.find((source) => source.name() === 'Events.js'); + if (eventsSource) { + const { Events } = require(eventsSource.filePath()); + for (const [className, events] of Object.entries(Events)) + classEvents.set( + className, + Array.from(Object.values(events)) + .filter((e) => typeof e === 'string') + .map((e) => Documentation.Member.createEvent(e)) + ); + } + + const excludeClasses = new Set([]); + const program = ts.createProgram({ + options: { + allowJs: true, + target: ts.ScriptTarget.ES2017, + }, + rootNames: sources.map((source) => source.filePath()), + }); + const checker = program.getTypeChecker(); + const sourceFiles = program.getSourceFiles(); + /** @type {!Array<!Documentation.Class>} */ + const classes = []; + /** @type {!Map<string, string>} */ + const inheritance = new Map(); + + const sourceFilesNoNodeModules = sourceFiles.filter( + (x) => !x.fileName.includes('node_modules') + ); + const sourceFileNamesSet = new Set( + sourceFilesNoNodeModules.map((x) => x.fileName) + ); + sourceFilesNoNodeModules.map((x) => { + if (x.fileName.includes('/lib/')) { + const potentialTSSource = x.fileName + .replace('lib', 'src') + .replace('.js', '.ts'); + if (sourceFileNamesSet.has(potentialTSSource)) { + /* Not going to visit this file because we have the TypeScript src code + * which we'll use instead. + */ + return; + } + } + + visit(x); + }); + + const errors = []; + const documentation = new Documentation( + recreateClassesWithInheritance(classes, inheritance) + ); + + return { errors, documentation }; + + /** + * @param {!Array<!Documentation.Class>} classes + * @param {!Map<string, string>} inheritance + * @returns {!Array<!Documentation.Class>} + */ + function recreateClassesWithInheritance(classes, inheritance) { + const classesByName = new Map(classes.map((cls) => [cls.name, cls])); + return classes.map((cls) => { + const membersMap = new Map(); + for (let wp = cls; wp; wp = classesByName.get(inheritance.get(wp.name))) { + for (const member of wp.membersArray) { + // Member was overridden. + const memberId = member.kind + ':' + member.name; + if (membersMap.has(memberId)) continue; + membersMap.set(memberId, member); + } + } + return new Documentation.Class(cls.name, Array.from(membersMap.values())); + }); + } + + /** + * @param {!ts.Node} node + */ + function visit(node) { + if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) { + const symbol = node.name + ? checker.getSymbolAtLocation(node.name) + : node.symbol; + let className = symbol.getName(); + + if (className === '__class') { + let parent = node; + while (parent.parent) parent = parent.parent; + className = path.basename(parent.fileName, '.js'); + } + if (className && !excludeClasses.has(className)) { + classes.push(serializeClass(className, symbol, node)); + const parentClassName = parentClass(node); + if (parentClassName) inheritance.set(className, parentClassName); + excludeClasses.add(className); + } + } + ts.forEachChild(node, visit); + } + + function parentClass(classNode) { + for (const herigateClause of classNode.heritageClauses || []) { + for (const heritageType of herigateClause.types) { + const parentClassName = heritageType.expression.escapedText; + return parentClassName; + } + } + return null; + } + + function serializeSymbol(symbol, circular = []) { + const type = checker.getTypeOfSymbolAtLocation( + symbol, + symbol.valueDeclaration + ); + const name = symbol.getName(); + if (symbol.valueDeclaration && symbol.valueDeclaration.dotDotDotToken) { + try { + const innerType = serializeType(type.typeArguments[0], circular); + innerType.name = '...' + innerType.name; + return Documentation.Member.createProperty('...' + name, innerType); + } catch (error) { + /** + * DocLint struggles with the paramArgs type on CDPSession.send because + * it uses a complex type from the devtools-protocol method. Doclint + * isn't going to be here for much longer so we'll just silence this + * warning than try to add support which would warrant a huge rewrite. + */ + if (name !== 'paramArgs') throw error; + } + } + return Documentation.Member.createProperty( + name, + serializeType(type, circular) + ); + } + + /** + * @param {!ts.ObjectType} type + */ + function isRegularObject(type) { + if (type.isIntersection()) return true; + if (!type.objectFlags) return false; + if (!('aliasSymbol' in type)) return false; + if (type.getConstructSignatures().length) return false; + if (type.getCallSignatures().length) return false; + if (type.isLiteral()) return false; + if (type.isUnion()) return false; + + return true; + } + + /** + * @param {!ts.Type} type + * @returns {!Documentation.Type} + */ + function serializeType(type, circular = []) { + let typeName = checker.typeToString(type); + if ( + typeName === 'any' || + typeName === '{ [x: string]: string; }' || + typeName === '{}' + ) + typeName = 'Object'; + const nextCircular = [typeName].concat(circular); + + if (isRegularObject(type)) { + let properties = undefined; + if (!circular.includes(typeName)) + properties = type + .getProperties() + .map((property) => serializeSymbol(property, nextCircular)); + return new Documentation.Type('Object', properties); + } + if (type.isUnion() && typeName.includes('|')) { + const types = type.types.map((type) => serializeType(type, circular)); + const name = types.map((type) => type.name).join('|'); + const properties = [].concat(...types.map((type) => type.properties)); + return new Documentation.Type( + name.replace(/false\|true/g, 'boolean'), + properties + ); + } + if (type.typeArguments) { + const properties = []; + const innerTypeNames = []; + for (const typeArgument of type.typeArguments) { + const innerType = serializeType(typeArgument, nextCircular); + if (innerType.properties) properties.push(...innerType.properties); + innerTypeNames.push(innerType.name); + } + if ( + innerTypeNames.length === 0 || + (innerTypeNames.length === 1 && innerTypeNames[0] === 'void') + ) + return new Documentation.Type(type.symbol.name); + return new Documentation.Type( + `${type.symbol.name}<${innerTypeNames.join(', ')}>`, + properties + ); + } + return new Documentation.Type(typeName, []); + } + + /** + * @param {!ts.Symbol} symbol + * @returns {boolean} + */ + function symbolHasPrivateModifier(symbol) { + const modifiers = + (symbol.valueDeclaration && symbol.valueDeclaration.modifiers) || []; + return modifiers.some( + (modifier) => modifier.kind === ts.SyntaxKind.PrivateKeyword + ); + } + + /** + * @param {string} className + * @param {!ts.Symbol} symbol + * @returns {} + */ + function serializeClass(className, symbol, node) { + /** @type {!Array<!Documentation.Member>} */ + const members = classEvents.get(className) || []; + + for (const [name, member] of symbol.members || []) { + /* Before TypeScript we denoted private methods with an underscore + * but in TypeScript we use the private keyword + * hence we check for either here. + */ + if (name.startsWith('_') || symbolHasPrivateModifier(member)) continue; + + const memberType = checker.getTypeOfSymbolAtLocation( + member, + member.valueDeclaration + ); + const signature = memberType.getCallSignatures()[0]; + if (signature) members.push(serializeSignature(name, signature)); + else members.push(serializeProperty(name, memberType)); + } + + return new Documentation.Class(className, members); + } + + /** + * @param {string} name + * @param {!ts.Signature} signature + */ + function serializeSignature(name, signature) { + const parameters = signature.parameters.map((s) => serializeSymbol(s)); + const returnType = serializeType(signature.getReturnType()); + return Documentation.Member.createMethod( + name, + parameters, + returnType.name !== 'void' ? returnType : null + ); + } + + /** + * @param {string} name + * @param {!ts.Type} type + */ + function serializeProperty(name, type) { + return Documentation.Member.createProperty(name, serializeType(type)); + } +} diff --git a/remote/test/puppeteer/utils/doclint/check_public_api/MDBuilder.js b/remote/test/puppeteer/utils/doclint/check_public_api/MDBuilder.js new file mode 100644 index 0000000000..46a4e6cfce --- /dev/null +++ b/remote/test/puppeteer/utils/doclint/check_public_api/MDBuilder.js @@ -0,0 +1,402 @@ +/** + * 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 Documentation = require('./Documentation'); +const commonmark = require('commonmark'); + +class MDOutline { + /** + * @param {!Page} page + * @param {string} text + * @returns {!MDOutline} + */ + static async create(page, text) { + // Render markdown as HTML. + const reader = new commonmark.Parser(); + const parsed = reader.parse(text); + const writer = new commonmark.HtmlRenderer(); + const html = writer.render(parsed); + + page.on('console', (msg) => { + console.log(msg.text()); + }); + // Extract headings. + await page.setContent(html); + const { classes, errors } = await page.evaluate(() => { + const classes = []; + const errors = []; + const headers = document.body.querySelectorAll('h3'); + for (let i = 0; i < headers.length; i++) { + const fragment = extractSiblingsIntoFragment( + headers[i], + headers[i + 1] + ); + classes.push(parseClass(fragment)); + } + return { classes, errors }; + + /** + * @param {HTMLLIElement} element + */ + function parseProperty(element) { + const clone = element.cloneNode(true); + const ul = clone.querySelector(':scope > ul'); + const str = parseComment( + extractSiblingsIntoFragment(clone.firstChild, ul) + ); + const name = str + .substring(0, str.indexOf('<')) + .replace(/\`/g, '') + .trim(); + const type = findType(str); + const properties = []; + const comment = str + .substring(str.indexOf('<') + type.length + 2) + .trim(); + // Strings have enum values instead of properties + if (!type.includes('string')) { + for (const childElement of element.querySelectorAll( + ':scope > ul > li' + )) { + const property = parseProperty(childElement); + property.required = property.comment.includes('***required***'); + properties.push(property); + } + } + return { + name, + type, + comment, + properties, + }; + } + + /** + * @param {string} str + * @returns {string} + */ + function findType(str) { + const start = str.indexOf('<') + 1; + let count = 1; + for (let i = start; i < str.length; i++) { + if (str[i] === '<') count++; + if (str[i] === '>') count--; + if (!count) return str.substring(start, i); + } + return 'unknown'; + } + + /** + * @param {DocumentFragment} content + */ + function parseClass(content) { + const members = []; + const headers = content.querySelectorAll('h4'); + const name = content.firstChild.textContent; + let extendsName = null; + let commentStart = content.firstChild.nextSibling; + const extendsElement = content.querySelector('ul'); + if ( + extendsElement && + extendsElement.textContent.trim().startsWith('extends:') + ) { + commentStart = extendsElement.nextSibling; + extendsName = extendsElement.querySelector('a').textContent; + } + const comment = parseComment( + extractSiblingsIntoFragment(commentStart, headers[0]) + ); + for (let i = 0; i < headers.length; i++) { + const fragment = extractSiblingsIntoFragment( + headers[i], + headers[i + 1] + ); + members.push(parseMember(fragment)); + } + return { + name, + comment, + extendsName, + members, + }; + } + + /** + * @param {Node} content + */ + function parseComment(content) { + for (const code of content.querySelectorAll('pre > code')) + code.replaceWith( + '```' + + code.className.substring('language-'.length) + + '\n' + + code.textContent + + '```' + ); + for (const code of content.querySelectorAll('code')) + code.replaceWith('`' + code.textContent + '`'); + for (const strong of content.querySelectorAll('strong')) + strong.replaceWith('**' + parseComment(strong) + '**'); + return content.textContent.trim(); + } + + /** + * @param {string} name + * @param {DocumentFragment} content + */ + function parseMember(content) { + const name = content.firstChild.textContent; + const args = []; + let returnType = null; + + const paramRegex = /^\w+\.[\w$]+\((.*)\)$/; + const matches = paramRegex.exec(name) || ['', '']; + const parameters = matches[1]; + const optionalStartIndex = parameters.indexOf('['); + const optinalParamsStr = + optionalStartIndex !== -1 + ? parameters.substring(optionalStartIndex).replace(/[\[\]]/g, '') + : ''; + const optionalparams = new Set( + optinalParamsStr + .split(',') + .filter((x) => x) + .map((x) => x.trim()) + ); + const ul = content.querySelector('ul'); + for (const element of content.querySelectorAll('h4 + ul > li')) { + if ( + element.matches('li') && + element.textContent.trim().startsWith('<') + ) { + returnType = parseProperty(element); + } else if ( + element.matches('li') && + element.firstChild.matches && + element.firstChild.matches('code') + ) { + const property = parseProperty(element); + property.required = !optionalparams.has(property.name); + args.push(property); + } else if ( + element.matches('li') && + element.firstChild.nodeType === Element.TEXT_NODE && + element.firstChild.textContent.toLowerCase().startsWith('return') + ) { + returnType = parseProperty(element); + const expectedText = 'returns: '; + let actualText = element.firstChild.textContent; + let angleIndex = actualText.indexOf('<'); + let spaceIndex = actualText.indexOf(' '); + angleIndex = angleIndex === -1 ? actualText.length : angleIndex; + spaceIndex = spaceIndex === -1 ? actualText.length : spaceIndex + 1; + actualText = actualText.substring( + 0, + Math.min(angleIndex, spaceIndex) + ); + if (actualText !== expectedText) + errors.push( + `${name} has mistyped 'return' type declaration: expected exactly '${expectedText}', found '${actualText}'.` + ); + } + } + const comment = parseComment( + extractSiblingsIntoFragment(ul ? ul.nextSibling : content) + ); + return { + name, + args, + returnType, + comment, + }; + } + + /** + * @param {!Node} fromInclusive + * @param {!Node} toExclusive + * @returns {!DocumentFragment} + */ + function extractSiblingsIntoFragment(fromInclusive, toExclusive) { + const fragment = document.createDocumentFragment(); + let node = fromInclusive; + while (node && node !== toExclusive) { + const next = node.nextSibling; + fragment.appendChild(node); + node = next; + } + return fragment; + } + }); + return new MDOutline(classes, errors); + } + + constructor(classes, errors) { + this.classes = []; + this.errors = errors; + const classHeading = /^class: (\w+)$/; + const constructorRegex = /^new (\w+)\((.*)\)$/; + const methodRegex = /^(\w+)\.([\w$]+)\((.*)\)$/; + const propertyRegex = /^(\w+)\.(\w+)$/; + const eventRegex = /^event: '(\w+)'$/; + let currentClassName = null; + let currentClassMembers = []; + let currentClassComment = ''; + let currentClassExtends = null; + for (const cls of classes) { + const match = cls.name.match(classHeading); + if (!match) continue; + currentClassName = match[1]; + currentClassComment = cls.comment; + currentClassExtends = cls.extendsName; + for (const member of cls.members) { + if (constructorRegex.test(member.name)) { + const match = member.name.match(constructorRegex); + handleMethod.call(this, member, match[1], 'constructor', match[2]); + } else if (methodRegex.test(member.name)) { + const match = member.name.match(methodRegex); + handleMethod.call(this, member, match[1], match[2], match[3]); + } else if (propertyRegex.test(member.name)) { + const match = member.name.match(propertyRegex); + handleProperty.call(this, member, match[1], match[2]); + } else if (eventRegex.test(member.name)) { + const match = member.name.match(eventRegex); + handleEvent.call(this, member, match[1]); + } + } + flushClassIfNeeded.call(this); + } + + function handleMethod(member, className, methodName, parameters) { + if ( + !currentClassName || + !className || + !methodName || + className.toLowerCase() !== currentClassName.toLowerCase() + ) { + this.errors.push(`Failed to process header as method: ${member.name}`); + return; + } + parameters = parameters.trim().replace(/[\[\]]/g, ''); + if (parameters !== member.args.map((arg) => arg.name).join(', ')) + this.errors.push( + `Heading arguments for "${ + member.name + }" do not match described ones, i.e. "${parameters}" != "${member.args + .map((a) => a.name) + .join(', ')}"` + ); + const args = member.args.map(createPropertyFromJSON); + let returnType = null; + let returnComment = ''; + if (member.returnType) { + const returnProperty = createPropertyFromJSON(member.returnType); + returnType = returnProperty.type; + returnComment = returnProperty.comment; + } + const method = Documentation.Member.createMethod( + methodName, + args, + returnType, + returnComment, + member.comment + ); + currentClassMembers.push(method); + } + + function createPropertyFromJSON(payload) { + const type = new Documentation.Type( + payload.type, + payload.properties.map(createPropertyFromJSON) + ); + const required = payload.required; + return Documentation.Member.createProperty( + payload.name, + type, + payload.comment, + required + ); + } + + function handleProperty(member, className, propertyName) { + if ( + !currentClassName || + !className || + !propertyName || + className.toLowerCase() !== currentClassName.toLowerCase() + ) { + this.errors.push( + `Failed to process header as property: ${member.name}` + ); + return; + } + const type = member.returnType ? member.returnType.type : null; + const properties = member.returnType ? member.returnType.properties : []; + currentClassMembers.push( + createPropertyFromJSON({ + type, + name: propertyName, + properties, + comment: member.comment, + }) + ); + } + + function handleEvent(member, eventName) { + if (!currentClassName || !eventName) { + this.errors.push(`Failed to process header as event: ${member.name}`); + return; + } + currentClassMembers.push( + Documentation.Member.createEvent( + eventName, + member.returnType && createPropertyFromJSON(member.returnType).type, + member.comment + ) + ); + } + + function flushClassIfNeeded() { + if (currentClassName === null) return; + this.classes.push( + new Documentation.Class( + currentClassName, + currentClassMembers, + currentClassExtends, + currentClassComment + ) + ); + currentClassName = null; + currentClassMembers = []; + } + } +} + +/** + * @param {!Page} page + * @param {!Array<!Source>} sources + * @returns {!Promise<{documentation: !Documentation, errors: !Array<string>}>} + */ +module.exports = async function (page, sources) { + const classes = []; + const errors = []; + for (const source of sources) { + const outline = await MDOutline.create(page, source.text()); + classes.push(...outline.classes); + errors.push(...outline.errors); + } + const documentation = new Documentation(classes); + return { documentation, errors }; +}; diff --git a/remote/test/puppeteer/utils/doclint/check_public_api/index.js b/remote/test/puppeteer/utils/doclint/check_public_api/index.js new file mode 100644 index 0000000000..84d5ca0f2a --- /dev/null +++ b/remote/test/puppeteer/utils/doclint/check_public_api/index.js @@ -0,0 +1,977 @@ +/** + * 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 jsBuilder = require('./JSBuilder'); +const mdBuilder = require('./MDBuilder'); +const Documentation = require('./Documentation'); +const Message = require('../Message'); +const { + MODULES_TO_CHECK_FOR_COVERAGE, +} = require('../../../test/coverage-utils'); + +const EXCLUDE_PROPERTIES = new Set([ + 'Browser.create', + 'Headers.fromPayload', + 'Page.create', + 'JSHandle.toString', + 'TimeoutError.name', + /* This isn't an actual property, but a TypeScript generic. + * DocLint incorrectly parses it as a property. + */ + 'ElementHandle.ElementType', +]); + +/** + * @param {!Page} page + * @param {!Array<!Source>} mdSources + * @returns {!Promise<!Array<!Message>>} + */ +module.exports = async function lint(page, mdSources, jsSources) { + const mdResult = await mdBuilder(page, mdSources); + const jsResult = await jsBuilder(jsSources); + const jsDocumentation = filterJSDocumentation(jsResult.documentation); + const mdDocumentation = mdResult.documentation; + + const jsErrors = jsResult.errors; + jsErrors.push(...checkDuplicates(jsDocumentation)); + + const mdErrors = mdResult.errors; + mdErrors.push(...compareDocumentations(mdDocumentation, jsDocumentation)); + mdErrors.push(...checkDuplicates(mdDocumentation)); + mdErrors.push(...checkSorting(mdDocumentation)); + + // Push all errors with proper prefixes + const errors = jsErrors.map((error) => '[JavaScript] ' + error); + errors.push(...mdErrors.map((error) => '[MarkDown] ' + error)); + return errors.map((error) => Message.error(error)); +}; + +/** + * @param {!Documentation} doc + * @returns {!Array<string>} + */ +function checkSorting(doc) { + const errors = []; + for (const cls of doc.classesArray) { + const members = cls.membersArray; + + // Events should go first. + let eventIndex = 0; + for ( + ; + eventIndex < members.length && members[eventIndex].kind === 'event'; + ++eventIndex + ); + for ( + ; + eventIndex < members.length && members[eventIndex].kind !== 'event'; + ++eventIndex + ); + if (eventIndex < members.length) + errors.push( + `Events should go first. Event '${members[eventIndex].name}' in class ${cls.name} breaks order` + ); + + // Constructor should be right after events and before all other members. + const constructorIndex = members.findIndex( + (member) => member.kind === 'method' && member.name === 'constructor' + ); + if (constructorIndex > 0 && members[constructorIndex - 1].kind !== 'event') + errors.push(`Constructor of ${cls.name} should go before other methods`); + + // Events should be sorted alphabetically. + for (let i = 0; i < members.length - 1; ++i) { + const member1 = cls.membersArray[i]; + const member2 = cls.membersArray[i + 1]; + if (member1.kind !== 'event' || member2.kind !== 'event') continue; + if (member1.name > member2.name) + errors.push( + `Event '${member1.name}' in class ${cls.name} breaks alphabetic ordering of events` + ); + } + + // All other members should be sorted alphabetically. + for (let i = 0; i < members.length - 1; ++i) { + const member1 = cls.membersArray[i]; + const member2 = cls.membersArray[i + 1]; + if (member1.kind === 'event' || member2.kind === 'event') continue; + if (member1.kind === 'method' && member1.name === 'constructor') continue; + if (member1.name > member2.name) { + let memberName1 = `${cls.name}.${member1.name}`; + if (member1.kind === 'method') memberName1 += '()'; + let memberName2 = `${cls.name}.${member2.name}`; + if (member2.kind === 'method') memberName2 += '()'; + errors.push( + `Bad alphabetic ordering of ${cls.name} members: ${memberName1} should go after ${memberName2}` + ); + } + } + } + return errors; +} + +/** + * @param {!Documentation} jsDocumentation + * @returns {!Documentation} + */ +function filterJSDocumentation(jsDocumentation) { + const includedClasses = new Set(Object.keys(MODULES_TO_CHECK_FOR_COVERAGE)); + // Filter private classes and methods. + const classes = []; + for (const cls of jsDocumentation.classesArray) { + if (includedClasses && !includedClasses.has(cls.name)) continue; + const members = cls.membersArray.filter( + (member) => !EXCLUDE_PROPERTIES.has(`${cls.name}.${member.name}`) + ); + classes.push(new Documentation.Class(cls.name, members)); + } + return new Documentation(classes); +} + +/** + * @param {!Documentation} doc + * @returns {!Array<string>} + */ +function checkDuplicates(doc) { + const errors = []; + const classes = new Set(); + // Report duplicates. + for (const cls of doc.classesArray) { + if (classes.has(cls.name)) + errors.push(`Duplicate declaration of class ${cls.name}`); + classes.add(cls.name); + const members = new Set(); + for (const member of cls.membersArray) { + if (members.has(member.kind + ' ' + member.name)) + errors.push( + `Duplicate declaration of ${member.kind} ${cls.name}.${member.name}()` + ); + members.add(member.kind + ' ' + member.name); + const args = new Set(); + for (const arg of member.argsArray) { + if (args.has(arg.name)) + errors.push( + `Duplicate declaration of argument ${cls.name}.${member.name} "${arg.name}"` + ); + args.add(arg.name); + } + } + } + return errors; +} + +// All the methods from our EventEmitter that we don't document for each subclass. +const EVENT_LISTENER_METHODS = new Set([ + 'emit', + 'listenerCount', + 'off', + 'on', + 'once', + 'removeListener', + 'addListener', + 'removeAllListeners', +]); + +/* Methods that are defined in code but are not documented */ +const expectedNotFoundMethods = new Map([ + ['Browser', EVENT_LISTENER_METHODS], + ['BrowserContext', EVENT_LISTENER_METHODS], + ['CDPSession', EVENT_LISTENER_METHODS], + ['Page', EVENT_LISTENER_METHODS], + ['WebWorker', EVENT_LISTENER_METHODS], +]); + +/** + * @param {!Documentation} actual + * @param {!Documentation} expected + * @returns {!Array<string>} + */ +function compareDocumentations(actual, expected) { + const errors = []; + + const actualClasses = Array.from(actual.classes.keys()).sort(); + const expectedClasses = Array.from(expected.classes.keys()).sort(); + const classesDiff = diff(actualClasses, expectedClasses); + + /* These have been moved onto PuppeteerNode but we want to document them under + * Puppeteer. See https://github.com/puppeteer/puppeteer/pull/6504 for details. + */ + const expectedPuppeteerClassMissingMethods = new Set([ + 'createBrowserFetcher', + 'defaultArgs', + 'executablePath', + 'launch', + ]); + + for (const className of classesDiff.extra) + errors.push(`Non-existing class found: ${className}`); + + for (const className of classesDiff.missing) { + if (className === 'PuppeteerNode') { + continue; + } + errors.push(`Class not found: ${className}`); + } + + for (const className of classesDiff.equal) { + const actualClass = actual.classes.get(className); + const expectedClass = expected.classes.get(className); + const actualMethods = Array.from(actualClass.methods.keys()).sort(); + const expectedMethods = Array.from(expectedClass.methods.keys()).sort(); + const methodDiff = diff(actualMethods, expectedMethods); + + for (const methodName of methodDiff.extra) { + if ( + expectedPuppeteerClassMissingMethods.has(methodName) && + actualClass.name === 'Puppeteer' + ) { + continue; + } + errors.push(`Non-existing method found: ${className}.${methodName}()`); + } + + for (const methodName of methodDiff.missing) { + const missingMethodsForClass = expectedNotFoundMethods.get(className); + if (missingMethodsForClass && missingMethodsForClass.has(methodName)) + continue; + errors.push(`Method not found: ${className}.${methodName}()`); + } + + for (const methodName of methodDiff.equal) { + const actualMethod = actualClass.methods.get(methodName); + const expectedMethod = expectedClass.methods.get(methodName); + if (!actualMethod.type !== !expectedMethod.type) { + if (actualMethod.type) + errors.push( + `Method ${className}.${methodName} has unneeded description of return type` + ); + else + errors.push( + `Method ${className}.${methodName} is missing return type description` + ); + } else if (actualMethod.hasReturn) { + checkType( + `Method ${className}.${methodName} has the wrong return type: `, + actualMethod.type, + expectedMethod.type + ); + } + const actualArgs = Array.from(actualMethod.args.keys()); + const expectedArgs = Array.from(expectedMethod.args.keys()); + const argsDiff = diff(actualArgs, expectedArgs); + + if (argsDiff.extra.length || argsDiff.missing.length) { + /* Doclint cannot handle the parameter type of the CDPSession send method. + * so we just ignore it. + */ + const isCdpSessionSend = + className === 'CDPSession' && methodName === 'send'; + if (!isCdpSessionSend) { + const text = [ + `Method ${className}.${methodName}() fails to describe its parameters:`, + ]; + for (const arg of argsDiff.missing) + text.push(`- Argument not found: ${arg}`); + for (const arg of argsDiff.extra) + text.push(`- Non-existing argument found: ${arg}`); + errors.push(text.join('\n')); + } + } + + for (const arg of argsDiff.equal) + checkProperty( + `Method ${className}.${methodName}()`, + actualMethod.args.get(arg), + expectedMethod.args.get(arg) + ); + } + const actualProperties = Array.from(actualClass.properties.keys()).sort(); + const expectedProperties = Array.from( + expectedClass.properties.keys() + ).sort(); + const propertyDiff = diff(actualProperties, expectedProperties); + for (const propertyName of propertyDiff.extra) { + if (className === 'Puppeteer' && propertyName === 'product') { + continue; + } + errors.push(`Non-existing property found: ${className}.${propertyName}`); + } + for (const propertyName of propertyDiff.missing) + errors.push(`Property not found: ${className}.${propertyName}`); + + const actualEvents = Array.from(actualClass.events.keys()).sort(); + const expectedEvents = Array.from(expectedClass.events.keys()).sort(); + const eventsDiff = diff(actualEvents, expectedEvents); + for (const eventName of eventsDiff.extra) + errors.push( + `Non-existing event found in class ${className}: '${eventName}'` + ); + for (const eventName of eventsDiff.missing) + errors.push(`Event not found in class ${className}: '${eventName}'`); + } + + /** + * @param {string} source + * @param {!Documentation.Member} actual + * @param {!Documentation.Member} expected + */ + function checkProperty(source, actual, expected) { + checkType(source + ' ' + actual.name, actual.type, expected.type); + } + + /** + * @param {string} source + * @param {!string} actualName + * @param {!string} expectedName + */ + function namingMisMatchInTypeIsExpected(source, actualName, expectedName) { + /* The DocLint tooling doesn't deal well with generics in TypeScript + * source files. We could fix this but the longterm plan is to + * auto-generate documentation from TS. So instead we document here + * the methods that use generics that DocLint trips up on and if it + * finds a mismatch that matches one of the cases below it doesn't + * error. This still means we're protected from accidental changes, as + * if the mismatch doesn't exactly match what's described below + * DocLint will fail. + */ + const expectedNamingMismatches = new Map([ + [ + 'Method CDPSession.send() method', + { + actualName: 'string', + expectedName: 'T', + }, + ], + [ + 'Method CDPSession.send() params', + { + actualName: 'Object', + expectedName: 'CommandParameters[T]', + }, + ], + [ + 'Method ElementHandle.click() options', + { + actualName: 'Object', + expectedName: 'ClickOptions', + }, + ], + [ + 'Method ElementHandle.press() options', + { + actualName: 'Object', + expectedName: 'PressOptions', + }, + ], + [ + 'Method ElementHandle.press() key', + { + actualName: 'string', + expectedName: 'KeyInput', + }, + ], + [ + 'Method Keyboard.down() key', + { + actualName: 'string', + expectedName: 'KeyInput', + }, + ], + [ + 'Method Keyboard.press() key', + { + actualName: 'string', + expectedName: 'KeyInput', + }, + ], + [ + 'Method Keyboard.up() key', + { + actualName: 'string', + expectedName: 'KeyInput', + }, + ], + [ + 'Method Mouse.down() options', + { + actualName: 'Object', + expectedName: 'MouseOptions', + }, + ], + [ + 'Method Mouse.up() options', + { + actualName: 'Object', + expectedName: 'MouseOptions', + }, + ], + [ + 'Method Mouse.wheel() options', + { + actualName: 'Object', + expectedName: 'MouseWheelOptions', + }, + ], + [ + 'Method Tracing.start() options', + { + actualName: 'Object', + expectedName: 'TracingOptions', + }, + ], + [ + 'Method Frame.waitForSelector() options', + { + actualName: 'Object', + expectedName: 'WaitForSelectorOptions', + }, + ], + [ + 'Method Frame.waitForXPath() options', + { + actualName: 'Object', + expectedName: 'WaitForSelectorOptions', + }, + ], + [ + 'Method HTTPRequest.abort() errorCode', + { + actualName: 'string', + expectedName: 'ErrorCode', + }, + ], + [ + 'Method Frame.goto() options.waitUntil', + { + actualName: + '"load"|"domcontentloaded"|"networkidle0"|"networkidle2"|Array', + expectedName: + '"load"|"domcontentloaded"|"networkidle0"|"networkidle2"|Array<PuppeteerLifeCycleEvent>', + }, + ], + [ + 'Method Frame.waitForNavigation() options.waitUntil', + { + actualName: + '"load"|"domcontentloaded"|"networkidle0"|"networkidle2"|Array', + expectedName: + '"load"|"domcontentloaded"|"networkidle0"|"networkidle2"|Array<PuppeteerLifeCycleEvent>', + }, + ], + [ + 'Method Frame.setContent() options.waitUntil', + { + actualName: + '"load"|"domcontentloaded"|"networkidle0"|"networkidle2"|Array', + expectedName: + '"load"|"domcontentloaded"|"networkidle0"|"networkidle2"|Array<PuppeteerLifeCycleEvent>', + }, + ], + [ + 'Method Puppeteer.defaultArgs() options', + { + actualName: 'Object', + expectedName: 'ChromeArgOptions', + }, + ], + [ + 'Method Page.goBack() options.waitUntil', + { + actualName: + '"load"|"domcontentloaded"|"networkidle0"|"networkidle2"|Array', + expectedName: + '"load"|"domcontentloaded"|"networkidle0"|"networkidle2"|Array<PuppeteerLifeCycleEvent>', + }, + ], + [ + 'Method Page.goForward() options.waitUntil', + { + actualName: + '"load"|"domcontentloaded"|"networkidle0"|"networkidle2"|Array', + expectedName: + '"load"|"domcontentloaded"|"networkidle0"|"networkidle2"|Array<PuppeteerLifeCycleEvent>', + }, + ], + [ + 'Method Page.goto() options.waitUntil', + { + actualName: + '"load"|"domcontentloaded"|"networkidle0"|"networkidle2"|Array', + expectedName: + '"load"|"domcontentloaded"|"networkidle0"|"networkidle2"|Array<PuppeteerLifeCycleEvent>', + }, + ], + [ + 'Method Page.reload() options.waitUntil', + { + actualName: + '"load"|"domcontentloaded"|"networkidle0"|"networkidle2"|Array', + expectedName: + '"load"|"domcontentloaded"|"networkidle0"|"networkidle2"|Array<PuppeteerLifeCycleEvent>', + }, + ], + [ + 'Method Page.setContent() options.waitUntil', + { + actualName: + '"load"|"domcontentloaded"|"networkidle0"|"networkidle2"|Array', + expectedName: + '"load"|"domcontentloaded"|"networkidle0"|"networkidle2"|Array<PuppeteerLifeCycleEvent>', + }, + ], + [ + 'Method Page.waitForNavigation() options.waitUntil', + { + actualName: + '"load"|"domcontentloaded"|"networkidle0"|"networkidle2"|Array', + expectedName: + '"load"|"domcontentloaded"|"networkidle0"|"networkidle2"|Array<PuppeteerLifeCycleEvent>', + }, + ], + [ + 'Method BrowserContext.overridePermissions() permissions', + { + actualName: 'Array<string>', + expectedName: 'Array<PermissionType>', + }, + ], + [ + 'Method Puppeteer.createBrowserFetcher() options', + { + actualName: 'Object', + expectedName: 'BrowserFetcherOptions', + }, + ], + [ + 'Method Page.authenticate() credentials', + { + actualName: 'Object', + expectedName: 'Credentials', + }, + ], + [ + 'Method Page.emulateMediaFeatures() features', + { + actualName: 'Array<Object>', + expectedName: 'Array<MediaFeature>', + }, + ], + [ + 'Method Page.emulate() options.viewport', + { + actualName: 'Object', + expectedName: 'Viewport', + }, + ], + [ + 'Method Page.setViewport() options.viewport', + { + actualName: 'Object', + expectedName: 'Viewport', + }, + ], + [ + 'Method Page.setViewport() viewport', + { + actualName: 'Object', + expectedName: 'Viewport', + }, + ], + [ + 'Method Page.connect() options.defaultViewport', + { + actualName: 'Object', + expectedName: 'Viewport', + }, + ], + [ + 'Method Puppeteer.connect() options.defaultViewport', + { + actualName: 'Object', + expectedName: 'Viewport', + }, + ], + [ + 'Method Puppeteer.launch() options.defaultViewport', + { + actualName: 'Object', + expectedName: 'Viewport', + }, + ], + [ + 'Method Page.launch() options.defaultViewport', + { + actualName: 'Object', + expectedName: 'Viewport', + }, + ], + [ + 'Method Page.goBack() options', + { + actualName: 'Object', + expectedName: 'WaitForOptions', + }, + ], + [ + 'Method Page.goForward() options', + { + actualName: 'Object', + expectedName: 'WaitForOptions', + }, + ], + [ + 'Method Page.reload() options', + { + actualName: 'Object', + expectedName: 'WaitForOptions', + }, + ], + [ + 'Method Page.waitForNavigation() options', + { + actualName: 'Object', + expectedName: 'WaitForOptions', + }, + ], + [ + 'Method Page.pdf() options', + { + actualName: 'Object', + expectedName: 'PDFOptions', + }, + ], + [ + 'Method Page.screenshot() options', + { + actualName: 'Object', + expectedName: 'ScreenshotOptions', + }, + ], + [ + 'Method Page.setContent() options', + { + actualName: 'Object', + expectedName: 'WaitForOptions', + }, + ], + [ + 'Method Page.setCookie() ...cookies', + { + actualName: '...Object', + expectedName: '...CookieParam', + }, + ], + [ + 'Method Page.emulateVisionDeficiency() type', + { + actualName: 'string', + expectedName: 'Object', + }, + ], + [ + 'Method Accessibility.snapshot() options', + { + actualName: 'Object', + expectedName: 'SnapshotOptions', + }, + ], + [ + 'Method Browser.waitForTarget() options', + { + actualName: 'Object', + expectedName: 'WaitForTargetOptions', + }, + ], + [ + 'Method EventEmitter.emit() event', + { + actualName: 'string|symbol', + expectedName: 'EventType', + }, + ], + [ + 'Method EventEmitter.listenerCount() event', + { + actualName: 'string|symbol', + expectedName: 'EventType', + }, + ], + [ + 'Method EventEmitter.off() event', + { + actualName: 'string|symbol', + expectedName: 'EventType', + }, + ], + [ + 'Method EventEmitter.on() event', + { + actualName: 'string|symbol', + expectedName: 'EventType', + }, + ], + [ + 'Method EventEmitter.once() event', + { + actualName: 'string|symbol', + expectedName: 'EventType', + }, + ], + [ + 'Method EventEmitter.removeListener() event', + { + actualName: 'string|symbol', + expectedName: 'EventType', + }, + ], + [ + 'Method EventEmitter.addListener() event', + { + actualName: 'string|symbol', + expectedName: 'EventType', + }, + ], + [ + 'Method EventEmitter.removeAllListeners() event', + { + actualName: 'string|symbol', + expectedName: 'EventType', + }, + ], + [ + 'Method Coverage.startCSSCoverage() options', + { + actualName: 'Object', + expectedName: 'CSSCoverageOptions', + }, + ], + [ + 'Method Coverage.startJSCoverage() options', + { + actualName: 'Object', + expectedName: 'JSCoverageOptions', + }, + ], + [ + 'Method Mouse.click() options.button', + { + actualName: '"left"|"right"|"middle"', + expectedName: 'MouseButton', + }, + ], + [ + 'Method Frame.click() options.button', + { + actualName: '"left"|"right"|"middle"', + expectedName: 'MouseButton', + }, + ], + [ + 'Method Page.click() options.button', + { + actualName: '"left"|"right"|"middle"', + expectedName: 'MouseButton', + }, + ], + [ + 'Method HTTPRequest.continue() overrides', + { + actualName: 'Object', + expectedName: 'ContinueRequestOverrides', + }, + ], + [ + 'Method HTTPRequest.respond() response', + { + actualName: 'Object', + expectedName: 'ResponseForRequest', + }, + ], + [ + 'Method Frame.addScriptTag() options', + { + actualName: 'Object', + expectedName: 'FrameAddScriptTagOptions', + }, + ], + [ + 'Method Frame.addStyleTag() options', + { + actualName: 'Object', + expectedName: 'FrameAddStyleTagOptions', + }, + ], + [ + 'Method Frame.waitForFunction() options', + { + actualName: 'Object', + expectedName: 'FrameWaitForFunctionOptions', + }, + ], + [ + 'Method BrowserContext.overridePermissions() permissions', + { + actualName: 'Array<string>', + expectedName: 'Array<Object>', + }, + ], + [ + 'Method Puppeteer.connect() options', + { + actualName: 'Object', + expectedName: 'ConnectOptions', + }, + ], + ]); + + const expectedForSource = expectedNamingMismatches.get(source); + if (!expectedForSource) return false; + + const namingMismatchIsExpected = + expectedForSource.actualName === actualName && + expectedForSource.expectedName === expectedName; + + return namingMismatchIsExpected; + } + + /** + * @param {string} source + * @param {!Documentation.Type} actual + * @param {!Documentation.Type} expected + */ + function checkType(source, actual, expected) { + // TODO(@JoelEinbinder): check functions and Serializable + if (actual.name.includes('unction') || actual.name.includes('Serializable')) + return; + // We don't have nullchecks on for TypeScript + const actualName = actual.name.replace(/[\? ]/g, ''); + // TypeScript likes to add some spaces + const expectedName = expected.name.replace(/\ /g, ''); + const namingMismatchIsExpected = namingMisMatchInTypeIsExpected( + source, + actualName, + expectedName + ); + if (expectedName !== actualName && !namingMismatchIsExpected) + errors.push(`${source} ${actualName} != ${expectedName}`); + + /* If we got a naming mismatch and it was expected, don't check the properties + * as they will likely be considered "wrong" by DocLint too. + */ + if (namingMismatchIsExpected) return; + + /* Some methods cause errors in the property checks for an unknown reason + * so we support a list of methods whose parameters are not checked. + */ + const skipPropertyChecksOnMethods = new Set([ + 'Method Page.deleteCookie() ...cookies', + 'Method Page.setCookie() ...cookies', + 'Method Puppeteer.connect() options', + ]); + if (skipPropertyChecksOnMethods.has(source)) return; + + const actualPropertiesMap = new Map( + actual.properties.map((property) => [property.name, property.type]) + ); + const expectedPropertiesMap = new Map( + expected.properties.map((property) => [property.name, property.type]) + ); + const propertiesDiff = diff( + Array.from(actualPropertiesMap.keys()).sort(), + Array.from(expectedPropertiesMap.keys()).sort() + ); + for (const propertyName of propertiesDiff.extra) + errors.push(`${source} has unexpected property ${propertyName}`); + for (const propertyName of propertiesDiff.missing) + errors.push(`${source} is missing property ${propertyName}`); + for (const propertyName of propertiesDiff.equal) + checkType( + source + '.' + propertyName, + actualPropertiesMap.get(propertyName), + expectedPropertiesMap.get(propertyName) + ); + } + + return errors; +} + +/** + * @param {!Array<string>} actual + * @param {!Array<string>} expected + * @returns {{extra: !Array<string>, missing: !Array<string>, equal: !Array<string>}} + */ +function diff(actual, expected) { + const N = actual.length; + const M = expected.length; + if (N === 0 && M === 0) return { extra: [], missing: [], equal: [] }; + if (N === 0) return { extra: [], missing: expected.slice(), equal: [] }; + if (M === 0) return { extra: actual.slice(), missing: [], equal: [] }; + const d = new Array(N); + const bt = new Array(N); + for (let i = 0; i < N; ++i) { + d[i] = new Array(M); + bt[i] = new Array(M); + for (let j = 0; j < M; ++j) { + const top = val(i - 1, j); + const left = val(i, j - 1); + if (top > left) { + d[i][j] = top; + bt[i][j] = 'extra'; + } else { + d[i][j] = left; + bt[i][j] = 'missing'; + } + const diag = val(i - 1, j - 1); + if (actual[i] === expected[j] && d[i][j] < diag + 1) { + d[i][j] = diag + 1; + bt[i][j] = 'eq'; + } + } + } + // Backtrack results. + let i = N - 1; + let j = M - 1; + const missing = []; + const extra = []; + const equal = []; + while (i >= 0 && j >= 0) { + switch (bt[i][j]) { + case 'extra': + extra.push(actual[i]); + i -= 1; + break; + case 'missing': + missing.push(expected[j]); + j -= 1; + break; + case 'eq': + equal.push(actual[i]); + i -= 1; + j -= 1; + break; + } + } + while (i >= 0) extra.push(actual[i--]); + while (j >= 0) missing.push(expected[j--]); + extra.reverse(); + missing.reverse(); + equal.reverse(); + return { extra, missing, equal }; + + function val(i, j) { + return i < 0 || j < 0 ? 0 : d[i][j]; + } +} diff --git a/remote/test/puppeteer/utils/doclint/cli.js b/remote/test/puppeteer/utils/doclint/cli.js new file mode 100755 index 0000000000..75775c65e5 --- /dev/null +++ b/remote/test/puppeteer/utils/doclint/cli.js @@ -0,0 +1,136 @@ +#!/usr/bin/env node +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const puppeteer = require('../..'); +const path = require('path'); +const Source = require('./Source'); + +const PROJECT_DIR = path.join(__dirname, '..', '..'); +const VERSION = require(path.join(PROJECT_DIR, 'package.json')).version; + +const RED_COLOR = '\x1b[31m'; +const YELLOW_COLOR = '\x1b[33m'; +const RESET_COLOR = '\x1b[0m'; + +run(); + +async function run() { + const startTime = Date.now(); + + /** @type {!Array<!Message>} */ + const messages = []; + let changedFiles = false; + + if (!VERSION.endsWith('-post')) { + const versions = await Source.readFile( + path.join(PROJECT_DIR, 'versions.js') + ); + versions.setText(versions.text().replace(`, 'NEXT'],`, `, '${VERSION}'],`)); + await versions.save(); + } + + // Documentation checks. + const readme = await Source.readFile(path.join(PROJECT_DIR, 'README.md')); + const contributing = await Source.readFile( + path.join(PROJECT_DIR, 'CONTRIBUTING.md') + ); + const api = await Source.readFile(path.join(PROJECT_DIR, 'docs', 'api.md')); + const troubleshooting = await Source.readFile( + path.join(PROJECT_DIR, 'docs', 'troubleshooting.md') + ); + const mdSources = [readme, api, troubleshooting, contributing]; + + const preprocessor = require('./preprocessor'); + messages.push(...(await preprocessor.runCommands(mdSources, VERSION))); + messages.push( + ...(await preprocessor.ensureReleasedAPILinks([readme], VERSION)) + ); + + const browser = await puppeteer.launch(); + const page = await browser.newPage(); + const checkPublicAPI = require('./check_public_api'); + const tsSources = [ + /* Source.readdir doesn't deal with nested directories well. + * Rather than invest time here when we're going to remove this Doc tooling soon + * we'll just list the directories manually. + */ + ...(await Source.readdir(path.join(PROJECT_DIR, 'src'), 'ts')), + ...(await Source.readdir(path.join(PROJECT_DIR, 'src', 'common'), 'ts')), + ...(await Source.readdir(path.join(PROJECT_DIR, 'src', 'node'), 'ts')), + ]; + + const tsSourcesNoDefinitions = tsSources.filter( + (source) => !source.filePath().endsWith('.d.ts') + ); + + const jsSources = [ + ...(await Source.readdir(path.join(PROJECT_DIR, 'lib'))), + ...(await Source.readdir(path.join(PROJECT_DIR, 'lib', 'cjs'))), + ...(await Source.readdir( + path.join(PROJECT_DIR, 'lib', 'cjs', 'puppeteer', 'common') + )), + ...(await Source.readdir( + path.join(PROJECT_DIR, 'lib', 'cjs', 'puppeteer', 'node') + )), + ]; + const allSrcCode = [...jsSources, ...tsSourcesNoDefinitions]; + messages.push(...(await checkPublicAPI(page, mdSources, allSrcCode))); + + await browser.close(); + + for (const source of mdSources) { + if (!source.hasUpdatedText()) continue; + await source.save(); + changedFiles = true; + } + + // Report results. + const errors = messages.filter((message) => message.type === 'error'); + if (errors.length) { + console.log('DocLint Failures:'); + for (let i = 0; i < errors.length; ++i) { + let error = errors[i].text; + error = error.split('\n').join('\n '); + console.log(` ${i + 1}) ${RED_COLOR}${error}${RESET_COLOR}`); + } + } + const warnings = messages.filter((message) => message.type === 'warning'); + if (warnings.length) { + console.log('DocLint Warnings:'); + for (let i = 0; i < warnings.length; ++i) { + let warning = warnings[i].text; + warning = warning.split('\n').join('\n '); + console.log(` ${i + 1}) ${YELLOW_COLOR}${warning}${RESET_COLOR}`); + } + } + let clearExit = messages.length === 0; + if (changedFiles) { + if (clearExit) + console.log(`${YELLOW_COLOR}Some files were updated.${RESET_COLOR}`); + clearExit = false; + } + console.log(`${errors.length} failures, ${warnings.length} warnings.`); + + if (!clearExit && !process.env.TRAVIS) + console.log( + '\nIs your lib/ directory up to date? You might need to `npm run tsc`.\n' + ); + + const runningTime = Date.now() - startTime; + console.log(`DocLint Finished in ${runningTime / 1000} seconds`); + process.exit(clearExit ? 0 : 1); +} diff --git a/remote/test/puppeteer/utils/doclint/preprocessor/index.js b/remote/test/puppeteer/utils/doclint/preprocessor/index.js new file mode 100644 index 0000000000..340d973c9a --- /dev/null +++ b/remote/test/puppeteer/utils/doclint/preprocessor/index.js @@ -0,0 +1,165 @@ +/** + * 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 Message = require('../Message'); + +module.exports.ensureReleasedAPILinks = function (sources, version) { + // Release version is everything that doesn't include "-". + const apiLinkRegex = /https:\/\/github.com\/puppeteer\/puppeteer\/blob\/v[^/]*\/docs\/api.md/gi; + const lastReleasedAPI = `https://github.com/puppeteer/puppeteer/blob/v${ + version.split('-')[0] + }/docs/api.md`; + + const messages = []; + for (const source of sources) { + const text = source.text(); + const newText = text.replace(apiLinkRegex, lastReleasedAPI); + if (source.setText(newText)) + messages.push(Message.warning(`GEN: updated ${source.projectPath()}`)); + } + return messages; +}; + +module.exports.runCommands = function (sources, version) { + // Release version is everything that doesn't include "-". + const isReleaseVersion = !version.includes('-'); + + const messages = []; + const commands = []; + for (const source of sources) { + const text = source.text(); + const commandStartRegex = /<!--\s*gen:([a-z-]+)\s*-->/gi; + const commandEndRegex = /<!--\s*gen:stop\s*-->/gi; + let start; + + while ((start = commandStartRegex.exec(text))) { + // eslint-disable-line no-cond-assign + commandEndRegex.lastIndex = commandStartRegex.lastIndex; + const end = commandEndRegex.exec(text); + if (!end) { + messages.push( + Message.error(`Failed to find 'gen:stop' for command ${start[0]}`) + ); + return messages; + } + const name = start[1]; + const from = commandStartRegex.lastIndex; + const to = end.index; + const originalText = text.substring(from, to); + commands.push({ name, from, to, originalText, source }); + commandStartRegex.lastIndex = commandEndRegex.lastIndex; + } + } + + const changedSources = new Set(); + // Iterate commands in reverse order so that edits don't conflict. + commands.sort((a, b) => b.from - a.from); + for (const command of commands) { + let newText = null; + if (command.name === 'version') + newText = isReleaseVersion ? 'v' + version : 'Tip-Of-Tree'; + else if (command.name === 'empty-if-release') + newText = isReleaseVersion ? '' : command.originalText; + else if (command.name === 'toc') + newText = generateTableOfContents( + command.source.text().substring(command.to) + ); + else if (command.name === 'versions-per-release') + newText = generateVersionsPerRelease(); + if (newText === null) + messages.push(Message.error(`Unknown command 'gen:${command.name}'`)); + else if (applyCommand(command, newText)) changedSources.add(command.source); + } + for (const source of changedSources) + messages.push(Message.warning(`GEN: updated ${source.projectPath()}`)); + return messages; +}; + +/** + * @param {{name: string, from: number, to: number, source: !Source}} command + * @param {string} editText + * @returns {boolean} + */ +function applyCommand(command, editText) { + const text = command.source.text(); + const newText = + text.substring(0, command.from) + editText + text.substring(command.to); + return command.source.setText(newText); +} + +function generateTableOfContents(mdText) { + const ids = new Set(); + const titles = []; + let insideCodeBlock = false; + for (const aLine of mdText.split('\n')) { + const line = aLine.trim(); + if (line.startsWith('```')) { + insideCodeBlock = !insideCodeBlock; + continue; + } + if (!insideCodeBlock && line.startsWith('#')) titles.push(line); + } + const tocEntries = []; + for (const title of titles) { + const [, nesting, name] = title.match(/^(#+)\s+(.*)$/); + const delinkifiedName = name.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'); + const id = delinkifiedName + .trim() + .toLowerCase() + .replace(/\s/g, '-') + .replace(/[^-0-9a-zа-яё]/gi, ''); + let dedupId = id; + let counter = 0; + while (ids.has(dedupId)) dedupId = id + '-' + ++counter; + ids.add(dedupId); + tocEntries.push({ + level: nesting.length, + name: delinkifiedName, + id: dedupId, + }); + } + + const minLevel = Math.min(...tocEntries.map((entry) => entry.level)); + tocEntries.forEach((entry) => (entry.level -= minLevel)); + return ( + '\n' + + tocEntries + .map((entry) => { + const prefix = entry.level % 2 === 0 ? '-' : '*'; + const padding = ' '.repeat(entry.level); + return `${padding}${prefix} [${entry.name}](#${entry.id})`; + }) + .join('\n') + + '\n' + ); +} + +const generateVersionsPerRelease = () => { + const versionsPerRelease = require('../../../versions.js'); + const buffer = ['- Releases per Chromium version:']; + for (const [chromiumVersion, puppeteerVersion] of versionsPerRelease) { + if (puppeteerVersion === 'NEXT') continue; + buffer.push( + ` * Chromium ${chromiumVersion} - [Puppeteer ${puppeteerVersion}](https://github.com/puppeteer/puppeteer/blob/${puppeteerVersion}/docs/api.md)` + ); + } + buffer.push( + ` * [All releases](https://github.com/puppeteer/puppeteer/releases)` + ); + + const output = '\n' + buffer.join('\n') + '\n'; + return output; +}; diff --git a/remote/test/puppeteer/utils/doclint/preprocessor/preprocessor.spec.js b/remote/test/puppeteer/utils/doclint/preprocessor/preprocessor.spec.js new file mode 100644 index 0000000000..713785db8f --- /dev/null +++ b/remote/test/puppeteer/utils/doclint/preprocessor/preprocessor.spec.js @@ -0,0 +1,248 @@ +/** + * 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 { runCommands, ensureReleasedAPILinks } = require('.'); +const Source = require('../Source'); +const expect = require('expect'); + +describe('doclint preprocessor specs', function () { + describe('ensureReleasedAPILinks', function () { + it('should work with non-release version', function () { + const source = new Source( + 'doc.md', + ` + [API](https://github.com/puppeteer/puppeteer/blob/v1.1.0/docs/api.md#class-page) + ` + ); + const messages = ensureReleasedAPILinks([source], '1.3.0-post'); + expect(messages.length).toBe(1); + expect(messages[0].type).toBe('warning'); + expect(messages[0].text).toContain('doc.md'); + expect(source.text()).toBe(` + [API](https://github.com/puppeteer/puppeteer/blob/v1.3.0/docs/api.md#class-page) + `); + }); + it('should work with release version', function () { + const source = new Source( + 'doc.md', + ` + [API](https://github.com/puppeteer/puppeteer/blob/v1.1.0/docs/api.md#class-page) + ` + ); + const messages = ensureReleasedAPILinks([source], '1.3.0'); + expect(messages.length).toBe(1); + expect(messages[0].type).toBe('warning'); + expect(messages[0].text).toContain('doc.md'); + expect(source.text()).toBe(` + [API](https://github.com/puppeteer/puppeteer/blob/v1.3.0/docs/api.md#class-page) + `); + }); + it('should keep main branch links intact', function () { + const source = new Source( + 'doc.md', + ` + [API](https://github.com/puppeteer/puppeteer/blob/main/docs/api.md#class-page) + ` + ); + const messages = ensureReleasedAPILinks([source], '1.3.0'); + expect(messages.length).toBe(0); + expect(source.text()).toBe(` + [API](https://github.com/puppeteer/puppeteer/blob/main/docs/api.md#class-page) + `); + }); + }); + + describe('runCommands', function () { + it('should throw for unknown command', function () { + const source = new Source( + 'doc.md', + ` + <!-- gen:unknown-command -->something<!-- gen:stop --> + ` + ); + const messages = runCommands([source], '1.1.1'); + expect(source.hasUpdatedText()).toBe(false); + expect(messages.length).toBe(1); + expect(messages[0].type).toBe('error'); + expect(messages[0].text).toContain('Unknown command'); + }); + describe('gen:version', function () { + it('should work', function () { + const source = new Source( + 'doc.md', + ` + Puppeteer <!-- gen:version -->XXX<!-- gen:stop --> + ` + ); + const messages = runCommands([source], '1.2.0'); + expect(messages.length).toBe(1); + expect(messages[0].type).toBe('warning'); + expect(messages[0].text).toContain('doc.md'); + expect(source.text()).toBe(` + Puppeteer <!-- gen:version -->v1.2.0<!-- gen:stop --> + `); + }); + it('should work for *-post versions', function () { + const source = new Source( + 'doc.md', + ` + Puppeteer <!-- gen:version -->XXX<!-- gen:stop --> + ` + ); + const messages = runCommands([source], '1.2.0-post'); + expect(messages.length).toBe(1); + expect(messages[0].type).toBe('warning'); + expect(messages[0].text).toContain('doc.md'); + expect(source.text()).toBe(` + Puppeteer <!-- gen:version -->Tip-Of-Tree<!-- gen:stop --> + `); + }); + it('should tolerate different writing', function () { + const source = new Source( + 'doc.md', + `Puppeteer v<!-- gEn:version -->WHAT +<!-- GEN:stop -->` + ); + runCommands([source], '1.1.1'); + expect(source.text()).toBe( + `Puppeteer v<!-- gEn:version -->v1.1.1<!-- GEN:stop -->` + ); + }); + it('should not tolerate missing gen:stop', function () { + const source = new Source('doc.md', `<!--GEN:version-->`); + const messages = runCommands([source], '1.2.0'); + expect(source.hasUpdatedText()).toBe(false); + expect(messages.length).toBe(1); + expect(messages[0].type).toBe('error'); + expect(messages[0].text).toContain(`Failed to find 'gen:stop'`); + }); + }); + describe('gen:empty-if-release', function () { + it('should clear text when release version', function () { + const source = new Source( + 'doc.md', + ` + <!-- gen:empty-if-release -->XXX<!-- gen:stop --> + ` + ); + const messages = runCommands([source], '1.1.1'); + expect(messages.length).toBe(1); + expect(messages[0].type).toBe('warning'); + expect(messages[0].text).toContain('doc.md'); + expect(source.text()).toBe(` + <!-- gen:empty-if-release --><!-- gen:stop --> + `); + }); + it('should keep text when non-release version', function () { + const source = new Source( + 'doc.md', + ` + <!-- gen:empty-if-release -->XXX<!-- gen:stop --> + ` + ); + const messages = runCommands([source], '1.1.1-post'); + expect(messages.length).toBe(0); + expect(source.text()).toBe(` + <!-- gen:empty-if-release -->XXX<!-- gen:stop --> + `); + }); + }); + describe('gen:toc', function () { + it('should work', () => { + const source = new Source( + 'doc.md', + `<!-- gen:toc -->XXX<!-- gen:stop --> + ### class: page + #### page.$ + #### page.$$` + ); + const messages = runCommands([source], '1.3.0'); + expect(messages.length).toBe(1); + expect(messages[0].type).toBe('warning'); + expect(messages[0].text).toContain('doc.md'); + expect(source.text()).toBe(`<!-- gen:toc --> +- [class: page](#class-page) + * [page.$](#page) + * [page.$$](#page-1) +<!-- gen:stop --> + ### class: page + #### page.$ + #### page.$$`); + }); + it('should work with code blocks', () => { + const source = new Source( + 'doc.md', + `<!-- gen:toc -->XXX<!-- gen:stop --> + ### class: page + + \`\`\`bash + # yo comment + \`\`\` + ` + ); + const messages = runCommands([source], '1.3.0'); + expect(messages.length).toBe(1); + expect(messages[0].type).toBe('warning'); + expect(messages[0].text).toContain('doc.md'); + expect(source.text()).toBe(`<!-- gen:toc --> +- [class: page](#class-page) +<!-- gen:stop --> + ### class: page + + \`\`\`bash + # yo comment + \`\`\` + `); + }); + it('should work with links in titles', () => { + const source = new Source( + 'doc.md', + `<!-- gen:toc -->XXX<!-- gen:stop --> + ### some [link](#foobar) here + ` + ); + const messages = runCommands([source], '1.3.0'); + expect(messages.length).toBe(1); + expect(messages[0].type).toBe('warning'); + expect(messages[0].text).toContain('doc.md'); + expect(source.text()).toBe(`<!-- gen:toc --> +- [some link here](#some-link-here) +<!-- gen:stop --> + ### some [link](#foobar) here + `); + }); + }); + it('should work with multiple commands', function () { + const source = new Source( + 'doc.md', + ` + <!-- gen:version -->XXX<!-- gen:stop --> + <!-- gen:empty-if-release -->YYY<!-- gen:stop --> + <!-- gen:version -->ZZZ<!-- gen:stop --> + ` + ); + const messages = runCommands([source], '1.1.1'); + expect(messages.length).toBe(1); + expect(messages[0].type).toBe('warning'); + expect(messages[0].text).toContain('doc.md'); + expect(source.text()).toBe(` + <!-- gen:version -->v1.1.1<!-- gen:stop --> + <!-- gen:empty-if-release --><!-- gen:stop --> + <!-- gen:version -->v1.1.1<!-- gen:stop --> + `); + }); + }); +}); diff --git a/remote/test/puppeteer/utils/fetch_devices.js b/remote/test/puppeteer/utils/fetch_devices.js new file mode 100755 index 0000000000..547b68842e --- /dev/null +++ b/remote/test/puppeteer/utils/fetch_devices.js @@ -0,0 +1,282 @@ +#!/usr/bin/env node +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const util = require('util'); +const fs = require('fs'); +const path = require('path'); +const puppeteer = require('..'); +const DEVICES_URL = + 'https://raw.githubusercontent.com/ChromeDevTools/devtools-frontend/master/front_end/emulated_devices/module.json'; + +const template = `/** + * 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. + */ + +module.exports = %s; +for (const device of module.exports) + module.exports[device.name] = device; +`; + +const help = `Usage: node ${path.basename(__filename)} [-u <from>] <outputPath> + -u, --url The URL to load devices descriptor from. If not set, + devices will be fetched from the tip-of-tree of DevTools + frontend. + + -h, --help Show this help message + +Fetch Chrome DevTools front-end emulation devices from given URL, convert them to puppeteer +devices and save to the <outputPath>. +`; + +const argv = require('minimist')(process.argv.slice(2), { + alias: { u: 'url', h: 'help' }, +}); + +if (argv.help) { + console.log(help); + return; +} + +const url = argv.url || DEVICES_URL; +const outputPath = argv._[0]; +if (!outputPath) { + console.log('ERROR: output file name is missing. Use --help for help.'); + return; +} + +main(url); + +async function main(url) { + const browser = await puppeteer.launch(); + const chromeVersion = (await browser.version()).split('/').pop(); + await browser.close(); + console.log('GET ' + url); + const text = await httpGET(url); + let json = null; + try { + json = JSON.parse(text); + } catch (error) { + console.error(`FAILED: error parsing response - ${error.message}`); + return; + } + const devicePayloads = json.extensions + .filter((extension) => extension.type === 'emulated-device') + .map((extension) => extension.device); + let devices = []; + for (const payload of devicePayloads) { + let names = []; + if (payload.title === 'iPhone 6/7/8') + names = ['iPhone 6', 'iPhone 7', 'iPhone 8']; + else if (payload.title === 'iPhone 6/7/8 Plus') + names = ['iPhone 6 Plus', 'iPhone 7 Plus', 'iPhone 8 Plus']; + else if (payload.title === 'iPhone 5/SE') names = ['iPhone 5', 'iPhone SE']; + else names = [payload.title]; + for (const name of names) { + const device = createDevice(chromeVersion, name, payload, false); + const landscape = createDevice(chromeVersion, name, payload, true); + devices.push(device); + if ( + landscape.viewport.width !== device.viewport.width || + landscape.viewport.height !== device.viewport.height + ) + devices.push(landscape); + } + } + devices = devices.filter((device) => device.viewport.isMobile); + devices.sort((a, b) => a.name.localeCompare(b.name)); + // Use single-quotes instead of double-quotes to conform with codestyle. + const serialized = JSON.stringify(devices, null, 2) + .replace(/'/g, `\\'`) + .replace(/"/g, `'`); + const result = util.format(template, serialized); + fs.writeFileSync(outputPath, result, 'utf8'); +} + +/** + * @param {string} chromeVersion + * @param {string} deviceName + * @param {*} descriptor + * @param {boolean} landscape + * @returns {!Object} + */ +function createDevice(chromeVersion, deviceName, descriptor, landscape) { + const devicePayload = loadFromJSONV1(descriptor); + const viewportPayload = landscape + ? devicePayload.horizontal + : devicePayload.vertical; + return { + name: deviceName + (landscape ? ' landscape' : ''), + userAgent: devicePayload.userAgent.includes('%s') + ? util.format(devicePayload.userAgent, chromeVersion) + : devicePayload.userAgent, + viewport: { + width: viewportPayload.width, + height: viewportPayload.height, + deviceScaleFactor: devicePayload.deviceScaleFactor, + isMobile: devicePayload.capabilities.includes('mobile'), + hasTouch: devicePayload.capabilities.includes('touch'), + isLandscape: landscape || false, + }, + }; +} + +/** + * @param {*} json + * @returns {?Object} + */ +function loadFromJSONV1(json) { + /** + * @param {*} object + * @param {string} key + * @param {string} type + * @param {*=} defaultValue + * @returns {*} + */ + function parseValue(object, key, type, defaultValue) { + if ( + typeof object !== 'object' || + object === null || + !object.hasOwnProperty(key) + ) { + if (typeof defaultValue !== 'undefined') return defaultValue; + throw new Error( + "Emulated device is missing required property '" + key + "'" + ); + } + const value = object[key]; + if (typeof value !== type || value === null) + throw new Error( + "Emulated device property '" + + key + + "' has wrong type '" + + typeof value + + "'" + ); + return value; + } + + /** + * @param {*} object + * @param {string} key + * @returns {number} + */ + function parseIntValue(object, key) { + const value = /** @type {number} */ (parseValue(object, key, 'number')); + if (value !== Math.abs(value)) + throw new Error("Emulated device value '" + key + "' must be integer"); + return value; + } + + /** + * @param {*} json + * @returns {!{width: number, height: number}} + */ + function parseOrientation(json) { + const result = {}; + const minDeviceSize = 50; + const maxDeviceSize = 9999; + result.width = parseIntValue(json, 'width'); + if ( + result.width < 0 || + result.width > maxDeviceSize || + result.width < minDeviceSize + ) + throw new Error('Emulated device has wrong width: ' + result.width); + + result.height = parseIntValue(json, 'height'); + if ( + result.height < 0 || + result.height > maxDeviceSize || + result.height < minDeviceSize + ) + throw new Error('Emulated device has wrong height: ' + result.height); + + return /** @type {!{width: number, height: number}} */ (result); + } + + const result = {}; + result.type = /** @type {string} */ (parseValue(json, 'type', 'string')); + result.userAgent = /** @type {string} */ (parseValue( + json, + 'user-agent', + 'string' + )); + + const capabilities = parseValue(json, 'capabilities', 'object', []); + if (!Array.isArray(capabilities)) + throw new Error('Emulated device capabilities must be an array'); + result.capabilities = []; + for (let i = 0; i < capabilities.length; ++i) { + if (typeof capabilities[i] !== 'string') + throw new Error('Emulated device capability must be a string'); + result.capabilities.push(capabilities[i]); + } + + result.deviceScaleFactor = /** @type {number} */ (parseValue( + json['screen'], + 'device-pixel-ratio', + 'number' + )); + if (result.deviceScaleFactor < 0 || result.deviceScaleFactor > 100) + throw new Error( + 'Emulated device has wrong deviceScaleFactor: ' + result.deviceScaleFactor + ); + + result.vertical = parseOrientation( + parseValue(json['screen'], 'vertical', 'object') + ); + result.horizontal = parseOrientation( + parseValue(json['screen'], 'horizontal', 'object') + ); + return result; +} + +/** + * @param {url} + * @returns {!Promise} + */ +function httpGET(url) { + let fulfill, reject; + const promise = new Promise((res, rej) => { + fulfill = res; + reject = rej; + }); + const driver = url.startsWith('https://') + ? require('https') + : require('http'); + const request = driver.get(url, (response) => { + let data = ''; + response.setEncoding('utf8'); + response.on('data', (chunk) => (data += chunk)); + response.on('end', () => fulfill(data)); + response.on('error', reject); + }); + request.on('error', reject); + return promise; +} diff --git a/remote/test/puppeteer/utils/prepare_puppeteer_core.js b/remote/test/puppeteer/utils/prepare_puppeteer_core.js new file mode 100755 index 0000000000..e1e9a64f2f --- /dev/null +++ b/remote/test/puppeteer/utils/prepare_puppeteer_core.js @@ -0,0 +1,27 @@ +#!/usr/bin/env node +/** + * Copyright 2018 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const fs = require('fs'); +const path = require('path'); + +const packagePath = path.join(__dirname, '..', 'package.json'); +const json = require(packagePath); + +json.name = 'puppeteer-core'; +delete json.scripts.install; +json.main = './cjs-entry-core.js'; +fs.writeFileSync(packagePath, JSON.stringify(json, null, ' ')); diff --git a/remote/test/puppeteer/utils/testserver/LICENSE b/remote/test/puppeteer/utils/testserver/LICENSE new file mode 100644 index 0000000000..afdfe50e72 --- /dev/null +++ b/remote/test/puppeteer/utils/testserver/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2017 Google Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/remote/test/puppeteer/utils/testserver/README.md b/remote/test/puppeteer/utils/testserver/README.md new file mode 100644 index 0000000000..a849eb873d --- /dev/null +++ b/remote/test/puppeteer/utils/testserver/README.md @@ -0,0 +1,18 @@ +# TestServer + +This test server is used internally by Puppeteer to test Puppeteer itself. + +### Example + +```js +const {TestServer} = require('@pptr/testserver'); + +(async(() => { + const httpServer = await TestServer.create(__dirname, 8000), + const httpsServer = await TestServer.createHTTPS(__dirname, 8001) + httpServer.setRoute('/hello', (req, res) => { + res.end('Hello, world!'); + }); + console.log('HTTP and HTTPS servers are running!'); +})(); +``` diff --git a/remote/test/puppeteer/utils/testserver/cert.pem b/remote/test/puppeteer/utils/testserver/cert.pem new file mode 100644 index 0000000000..fd3838535a --- /dev/null +++ b/remote/test/puppeteer/utils/testserver/cert.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDWDCCAkCgAwIBAgIUM8Tmw+D1j+eVz9x9So4zRVqFsKowDQYJKoZIhvcNAQEL +BQAwGjEYMBYGA1UEAwwPcHVwcGV0ZWVyLXRlc3RzMB4XDTIwMDUxMzA4MDQyOVoX +DTMwMDUxMTA4MDQyOVowGjEYMBYGA1UEAwwPcHVwcGV0ZWVyLXRlc3RzMIIBIjAN +BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApWbbhgc6CnWywd8xGETT1mfLi3wi +KIbpAUHghLF4sj0jXz8vLh/4oicpQ12d6bsz+IAi7qrdXNh11P5nEej6/Gx4fWzB +gGdrJFGPqsvXuhYdzZAmy6xOaWcLIJeQ543bXv3YeST7EGRXJBc/ocTo2jIGTGjq +hksFaid910VQlX3KGOLTDMUCk00TeEYBTTUx47PWoIsxVqbl2RzVXRSWL5hlPWlW +29/BQtBGmsXxZyWtqqHudiUulGBSr4LcPyicZLI8nqCqD0ioS0TEmGh61nRBuwBa +xmLCvPmpt0+sDuOU+1bme3w8juvTVToBIFxGB86rADd3ys+8NeZzXqi+bQIDAQAB +o4GVMIGSMB0GA1UdDgQWBBT/m3vdkZpQyVQFdYrKHVoAHXDFODAfBgNVHSMEGDAW +gBT/m3vdkZpQyVQFdYrKHVoAHXDFODAPBgNVHRMBAf8EBTADAQH/MD8GA1UdEQQ4 +MDaCGHd3dy5wdXBwZXRlZXItdGVzdHMudGVzdIIad3d3LnB1cHBldGVlci10ZXN0 +cy0xLnRlc3QwDQYJKoZIhvcNAQELBQADggEBAI1qp5ZppV1R3e8XxzwwkFDPFN8W +Pe3AoqhAKyJnJl1NUn9q3sroEeSQRhODWUHCd7lENzhsT+3mzonNNkN9B/hq0rpK +KHHczXILDqdyuxH3LxQ1VHGE8VN2NbdkfobtzAsA3woiJxOuGeusXJnKB4kJQeIP +V+BMEZWeaSDC2PREkG7GOezmE1/WDUCYaorPw2whdCA5wJvTW3zXpJjYhfsld+5z +KuErx4OCxRJij73/BD9SpLxDEY1cdl819F1IvxsRGhmTIaSly2hQLrhOgo1jgZtV +FGCa6DSlXnQGLaV+N+ssR0lkCksNrNBVDfA1bP5bT/4VCcwUWwm9TUeF0Qo= +-----END CERTIFICATE----- diff --git a/remote/test/puppeteer/utils/testserver/index.js b/remote/test/puppeteer/utils/testserver/index.js new file mode 100644 index 0000000000..7d21009c94 --- /dev/null +++ b/remote/test/puppeteer/utils/testserver/index.js @@ -0,0 +1,284 @@ +// @ts-nocheck + +/** + * 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 http = require('http'); +const https = require('https'); +const url = require('url'); +const fs = require('fs'); +const path = require('path'); +const mime = require('mime'); +const WebSocketServer = require('ws').Server; + +const fulfillSymbol = Symbol('fullfil callback'); +const rejectSymbol = Symbol('reject callback'); + +class TestServer { + PORT = undefined; + PREFIX = undefined; + CROSS_PROCESS_PREFIX = undefined; + EMPTY_PAGE = undefined; + + /** + * @param {string} dirPath + * @param {number} port + * @returns {!TestServer} + */ + static async create(dirPath, port) { + const server = new TestServer(dirPath, port); + await new Promise((x) => server._server.once('listening', x)); + return server; + } + + /** + * @param {string} dirPath + * @param {number} port + * @returns {!TestServer} + */ + static async createHTTPS(dirPath, port) { + const server = new TestServer(dirPath, port, { + key: fs.readFileSync(path.join(__dirname, 'key.pem')), + cert: fs.readFileSync(path.join(__dirname, 'cert.pem')), + passphrase: 'aaaa', + }); + await new Promise((x) => server._server.once('listening', x)); + return server; + } + + /** + * @param {string} dirPath + * @param {number} port + * @param {!Object=} sslOptions + */ + constructor(dirPath, port, sslOptions) { + if (sslOptions) + this._server = https.createServer(sslOptions, this._onRequest.bind(this)); + else this._server = http.createServer(this._onRequest.bind(this)); + this._server.on('connection', (socket) => this._onSocket(socket)); + this._wsServer = new WebSocketServer({ server: this._server }); + this._wsServer.on('connection', this._onWebSocketConnection.bind(this)); + this._server.listen(port); + this._dirPath = dirPath; + + this._startTime = new Date(); + this._cachedPathPrefix = null; + + /** @type {!Set<!net.Socket>} */ + this._sockets = new Set(); + + /** @type {!Map<string, function(!IncomingMessage, !ServerResponse)>} */ + this._routes = new Map(); + /** @type {!Map<string, !{username:string, password:string}>} */ + this._auths = new Map(); + /** @type {!Map<string, string>} */ + this._csp = new Map(); + /** @type {!Set<string>} */ + this._gzipRoutes = new Set(); + /** @type {!Map<string, !Promise>} */ + this._requestSubscribers = new Map(); + } + + _onSocket(socket) { + this._sockets.add(socket); + // ECONNRESET is a legit error given + // that tab closing simply kills process. + socket.on('error', (error) => { + if (error.code !== 'ECONNRESET') throw error; + }); + socket.once('close', () => this._sockets.delete(socket)); + } + + /** + * @param {string} pathPrefix + */ + enableHTTPCache(pathPrefix) { + this._cachedPathPrefix = pathPrefix; + } + + /** + * @param {string} path + * @param {string} username + * @param {string} password + */ + setAuth(path, username, password) { + this._auths.set(path, { username, password }); + } + + enableGzip(path) { + this._gzipRoutes.add(path); + } + + /** + * @param {string} path + * @param {string} csp + */ + setCSP(path, csp) { + this._csp.set(path, csp); + } + + async stop() { + this.reset(); + for (const socket of this._sockets) socket.destroy(); + this._sockets.clear(); + await new Promise((x) => this._server.close(x)); + } + + /** + * @param {string} path + * @param {function(!IncomingMessage, !ServerResponse)} handler + */ + setRoute(path, handler) { + this._routes.set(path, handler); + } + + /** + * @param {string} from + * @param {string} to + */ + setRedirect(from, to) { + this.setRoute(from, (req, res) => { + res.writeHead(302, { location: to }); + res.end(); + }); + } + + /** + * @param {string} path + * @returns {!Promise<!IncomingMessage>} + */ + waitForRequest(path) { + let promise = this._requestSubscribers.get(path); + if (promise) return promise; + let fulfill, reject; + promise = new Promise((f, r) => { + fulfill = f; + reject = r; + }); + promise[fulfillSymbol] = fulfill; + promise[rejectSymbol] = reject; + this._requestSubscribers.set(path, promise); + return promise; + } + + reset() { + this._routes.clear(); + this._auths.clear(); + this._csp.clear(); + this._gzipRoutes.clear(); + const error = new Error('Static Server has been reset'); + for (const subscriber of this._requestSubscribers.values()) + subscriber[rejectSymbol].call(null, error); + this._requestSubscribers.clear(); + } + + _onRequest(request, response) { + request.on('error', (error) => { + if (error.code === 'ECONNRESET') response.end(); + else throw error; + }); + request.postBody = new Promise((resolve) => { + let body = ''; + request.on('data', (chunk) => (body += chunk)); + request.on('end', () => resolve(body)); + }); + const pathName = url.parse(request.url).path; + if (this._auths.has(pathName)) { + const auth = this._auths.get(pathName); + const credentials = Buffer.from( + (request.headers.authorization || '').split(' ')[1] || '', + 'base64' + ).toString(); + if (credentials !== `${auth.username}:${auth.password}`) { + response.writeHead(401, { + 'WWW-Authenticate': 'Basic realm="Secure Area"', + }); + response.end('HTTP Error 401 Unauthorized: Access is denied'); + return; + } + } + // Notify request subscriber. + if (this._requestSubscribers.has(pathName)) { + this._requestSubscribers.get(pathName)[fulfillSymbol].call(null, request); + this._requestSubscribers.delete(pathName); + } + const handler = this._routes.get(pathName); + if (handler) { + handler.call(null, request, response); + } else { + const pathName = url.parse(request.url).path; + this.serveFile(request, response, pathName); + } + } + + /** + * @param {!IncomingMessage} request + * @param {!ServerResponse} response + * @param {string} pathName + */ + serveFile(request, response, pathName) { + if (pathName === '/') pathName = '/index.html'; + const filePath = path.join(this._dirPath, pathName.substring(1)); + + if ( + this._cachedPathPrefix !== null && + filePath.startsWith(this._cachedPathPrefix) + ) { + if (request.headers['if-modified-since']) { + response.statusCode = 304; // not modified + response.end(); + return; + } + response.setHeader('Cache-Control', 'public, max-age=31536000'); + response.setHeader('Last-Modified', this._startTime.toISOString()); + } else { + response.setHeader('Cache-Control', 'no-cache, no-store'); + } + if (this._csp.has(pathName)) + response.setHeader('Content-Security-Policy', this._csp.get(pathName)); + + fs.readFile(filePath, (err, data) => { + if (err) { + response.statusCode = 404; + response.end(`File not found: ${filePath}`); + return; + } + const mimeType = mime.getType(filePath); + const isTextEncoding = /^text\/|^application\/(javascript|json)/.test( + mimeType + ); + const contentType = isTextEncoding + ? `${mimeType}; charset=utf-8` + : mimeType; + response.setHeader('Content-Type', contentType); + if (this._gzipRoutes.has(pathName)) { + response.setHeader('Content-Encoding', 'gzip'); + const zlib = require('zlib'); + zlib.gzip(data, (_, result) => { + response.end(result); + }); + } else { + response.end(data); + } + }); + } + + _onWebSocketConnection(connection) { + connection.send('opened'); + } +} + +module.exports = { TestServer }; diff --git a/remote/test/puppeteer/utils/testserver/key.pem b/remote/test/puppeteer/utils/testserver/key.pem new file mode 100644 index 0000000000..cbc3acb229 --- /dev/null +++ b/remote/test/puppeteer/utils/testserver/key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQClZtuGBzoKdbLB +3zEYRNPWZ8uLfCIohukBQeCEsXiyPSNfPy8uH/iiJylDXZ3puzP4gCLuqt1c2HXU +/mcR6Pr8bHh9bMGAZ2skUY+qy9e6Fh3NkCbLrE5pZwsgl5Dnjdte/dh5JPsQZFck +Fz+hxOjaMgZMaOqGSwVqJ33XRVCVfcoY4tMMxQKTTRN4RgFNNTHjs9agizFWpuXZ +HNVdFJYvmGU9aVbb38FC0EaaxfFnJa2qoe52JS6UYFKvgtw/KJxksjyeoKoPSKhL +RMSYaHrWdEG7AFrGYsK8+am3T6wO45T7VuZ7fDyO69NVOgEgXEYHzqsAN3fKz7w1 +5nNeqL5tAgMBAAECggEAKPveo0xBHnxhidZzBM9xKixX7D0a/a3IKI6ZQmfzPz8U +97HhT+2OHyfS+qVEzribPRULEtZ1uV7Ne7R5958iKc/63yFGpTl6++nVzn1p++sl +AV2Zr1gHqehlgnLr7eRhmh0OOZ5nM32ZdhDorH3tMLu6gc5xZktKkS4t6Vx8hj3a +Docx+rbawp8GRd0p7I6vzIE3bsDab8hC+RTRO63q2G0BqgKwV9ZNtJxQgcDJ5L8N +6gtM2z5nKXAIOCbCQYa1PsrDh3IRA/ZNxEeA9G3YQjwlZYCWmdRRplgDraYxcTBO +oQGjaLwICNdcprMacPD6cCSgrI+PadzyMsAuk9SgpQKBgQDO9PT4gK40Pm+Damxv ++tWYBFmvn3vasmyolc1zVDltsxQbQTjKhVpTLXTTGmrIhDXEIIV9I4rg164WptQs +6Brp2EwYR7ZJIrjvXs/9i2QTW1ZXvhdiWpB3s+RXD5VHGovHUadcI6wOgw2Cl+Jk +zXjSIgyXKM99N1MAonuR7DyzTwKBgQDMmPX+9vWZMpS/gc6JLQiPPoGszE6tYjXg +W3LpRUNqmO0/bDDjslbebDgrGAmhlkJlxzH6gz96VmGm1evEGPEet3euy8S9zuM3 +LCgEM9Ulqa3JbInwtKupmKv76Im+XWLLSxAXbfiel1zFRRwxI99A3ad0QRZ6Bov5 +3cHJBwvzgwKBgAU5HW2gIcVjxgC1EOOKmxVpFrJd/gw48JEYpsTAXWqtWFaPwNUr +pGnw/b/OLN++pnS6tWPBH+Ioz1X3A+fWO8enE9SRCsKxw6UW6XzmpbHvXjB8ta5f +xsGeoqan2AahXuG659RlehQrro2bM7WDkgcLoPG3r/TjDo83ipLWOXn1AoGAKWiL +4R56dpcWI+xRsNG8ecFc3Ww8QDswTEg16aBrFJf+7GcpPexKSJn+hDpJOLsAlTjL +lLgbkNcKzIlfPkEOC/l175quJvxIYFI/hxo2eXjuA2ZERMNMOvb7V/CocC7WX+7B +Qvyu5OodjI+ANTHdbXNvAMhrlCbfDaMkJVuXv6ECgYBzvY4aYmVoFsr+72/EfLls +Dz9pi55tUUWc61w6ovd+iliawvXeGi4wibtTH4iGj/C2sJIaMmOD99NQ7Oi/x89D +oMgSUemkoFL8FGsZGyZ7szqxyON1jP42Bm2MQrW5kIf7Y4yaIGhoak5JNxn2JUyV +gupVbY1mQ1GTPByxHeLh1w== +-----END PRIVATE KEY----- diff --git a/remote/test/puppeteer/utils/testserver/package.json b/remote/test/puppeteer/utils/testserver/package.json new file mode 100644 index 0000000000..6cac90ffc5 --- /dev/null +++ b/remote/test/puppeteer/utils/testserver/package.json @@ -0,0 +1,15 @@ +{ + "name": "@pptr/testserver", + "version": "0.5.0", + "description": "testing server", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "https://github.com/puppeteer/puppeteer/tree/main/utils/testserver" + }, + "author": "The Chromium Authors", + "license": "Apache-2.0" +} diff --git a/remote/test/puppeteer/vendor/README.md b/remote/test/puppeteer/vendor/README.md new file mode 100644 index 0000000000..bca3d5e10f --- /dev/null +++ b/remote/test/puppeteer/vendor/README.md @@ -0,0 +1,13 @@ +# Vendoring third party dependencies + +Because we are working towards an agnostic Puppeteer that can run in any environment (see [#6125](https://github.com/puppeteer/puppeteer/issues/6125)) we cannot import common dependencies in a way that relies on Node's resolution to find them. For example, `import mitt from 'mitt'` works fine in Node, but in an ESM build running in the browser, the browser has no idea where to find `'mitt'`. + +Therefore we put all common dependencies into this directory, `vendor`. This means there are extra criteria for these dependencies; ideally they will not depend on any other modules. If they do, we should consider an alternative way of managing our dependencies. + +The process for updating a vendored dependency is: + +1. `npm install {DEP NAME HERE}` +2. `cp -r node_modules/DEP vendor` +3. Update `eslintrc.js` to forbid importing DEP directly (see the `Mitt` rule already defined in there). +4. Use the new DEP, and run `npm run tsc` to check everything compiles successfully. +5. If the dep ships as compiled JS, you may need to disable TypeScript checking the file. Add an entry to the `excludes` property of the TSConfig files in `vendor`. (again, see the entry that's already there for Mitt as an example). Don't forget to update both the ESM and CJS config files. diff --git a/remote/test/puppeteer/vendor/mitt/README.md b/remote/test/puppeteer/vendor/mitt/README.md new file mode 100644 index 0000000000..08a21bf7ad --- /dev/null +++ b/remote/test/puppeteer/vendor/mitt/README.md @@ -0,0 +1,179 @@ +<p align="center"> + <img src="https://i.imgur.com/BqsX9NT.png" width="300" height="300" alt="mitt"> + <br> + <a href="https://www.npmjs.org/package/mitt"><img src="https://img.shields.io/npm/v/mitt.svg" alt="npm"></a> + <img src="https://github.com/developit/mitt/workflows/CI/badge.svg" alt="build status"> + <a href="https://unpkg.com/mitt/dist/mitt.js"><img src="https://img.badgesize.io/https://unpkg.com/mitt/dist/mitt.js?compression=gzip" alt="gzip size"></a> +</p> + +# Mitt + +> Tiny 200b functional event emitter / pubsub. + +- **Microscopic:** weighs less than 200 bytes gzipped +- **Useful:** a wildcard `"*"` event type listens to all events +- **Familiar:** same names & ideas as [Node's EventEmitter](https://nodejs.org/api/events.html#events_class_eventemitter) +- **Functional:** methods don't rely on `this` +- **Great Name:** somehow [mitt](https://npm.im/mitt) wasn't taken + +Mitt was made for the browser, but works in any JavaScript runtime. It has no dependencies and supports IE9+. + +## Table of Contents + +- [Install](#install) +- [Usage](#usage) +- [Examples & Demos](#examples--demos) +- [API](#api) +- [Contribute](#contribute) +- [License](#license) + +## Install + +This project uses [node](http://nodejs.org) and [npm](https://npmjs.com). Go check them out if you don't have them locally installed. + +```sh +$ npm install --save mitt +``` + +Then with a module bundler like [rollup](http://rollupjs.org/) or [webpack](https://webpack.js.org/), use as you would anything else: + +```javascript +// using ES6 modules +import mitt from 'mitt' + +// using CommonJS modules +var mitt = require('mitt') +``` + +The [UMD](https://github.com/umdjs/umd) build is also available on [unpkg](https://unpkg.com): + +```html +<script src="https://unpkg.com/mitt/dist/mitt.umd.js"></script> +``` + +You can find the library on `window.mitt`. + +## Usage + +```js +import mitt from 'mitt' + +const emitter = mitt() + +// listen to an event +emitter.on('foo', e => console.log('foo', e) ) + +// listen to all events +emitter.on('*', (type, e) => console.log(type, e) ) + +// fire an event +emitter.emit('foo', { a: 'b' }) + +// clearing all events +emitter.all.clear() + +// working with handler references: +function onFoo() {} +emitter.on('foo', onFoo) // listen +emitter.off('foo', onFoo) // unlisten +``` + +### Typescript + +```ts +import mitt from 'mitt'; +const emitter: mitt.Emitter = mitt(); +``` + +## Examples & Demos + +<a href="http://codepen.io/developit/pen/rjMEwW?editors=0110"> + <b>Preact + Mitt Codepen Demo</b> + <br> + <img src="https://i.imgur.com/CjBgOfJ.png" width="278" alt="preact + mitt preview"> +</a> + +* * * + +## API + +<!-- Generated by documentation.js. Update this documentation by updating the source code. --> + +#### Table of Contents + +- [mitt](#mitt) +- [all](#all) +- [on](#on) + - [Parameters](#parameters) +- [off](#off) + - [Parameters](#parameters-1) +- [emit](#emit) + - [Parameters](#parameters-2) + +### mitt + +Mitt: Tiny (~200b) functional event emitter / pubsub. + +Returns **Mitt** + +### all + +A Map of event names to registered handler functions. + +### on + +Register an event handler for the given type. + +#### Parameters + +- `type` **([string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String) \| [symbol](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Symbol))** Type of event to listen for, or `"*"` for all events +- `handler` **[Function](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Statements/function)** Function to call in response to given event + +### off + +Remove an event handler for the given type. + +#### Parameters + +- `type` **([string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String) \| [symbol](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Symbol))** Type of event to unregister `handler` from, or `"*"` +- `handler` **[Function](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Statements/function)** Handler function to remove + +### emit + +Invoke all handlers for the given type. +If present, `"*"` handlers are invoked after type-matched handlers. + +Note: Manually firing "\*" handlers is not supported. + +#### Parameters + +- `type` **([string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String) \| [symbol](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Symbol))** The event type to invoke +- `evt` **Any?** Any value (object is recommended and powerful), passed to each handler + +## Contribute + +First off, thanks for taking the time to contribute! +Now, take a moment to be sure your contributions make sense to everyone else. + +### Reporting Issues + +Found a problem? Want a new feature? First of all see if your issue or idea has [already been reported](../../issues). +If don't, just open a [new clear and descriptive issue](../../issues/new). + +### Submitting pull requests + +Pull requests are the greatest contributions, so be sure they are focused in scope, and do avoid unrelated commits. + +- Fork it! +- Clone your fork: `git clone https://github.com/<your-username>/mitt` +- Navigate to the newly cloned directory: `cd mitt` +- Create a new branch for the new feature: `git checkout -b my-new-feature` +- Install the tools necessary for development: `npm install` +- Make your changes. +- Commit your changes: `git commit -am 'Add some feature'` +- Push to the branch: `git push origin my-new-feature` +- Submit a pull request with full remarks documenting your changes. + +## License + +[MIT License](https://opensource.org/licenses/MIT) © [Jason Miller](https://jasonformat.com/) diff --git a/remote/test/puppeteer/vendor/mitt/dist/mitt.es.js b/remote/test/puppeteer/vendor/mitt/dist/mitt.es.js new file mode 100644 index 0000000000..889e27282f --- /dev/null +++ b/remote/test/puppeteer/vendor/mitt/dist/mitt.es.js @@ -0,0 +1,2 @@ +export default function(n){return{all:n=n||new Map,on:function(t,e){var i=n.get(t);i&&i.push(e)||n.set(t,[e])},off:function(t,e){var i=n.get(t);i&&i.splice(i.indexOf(e)>>>0,1)},emit:function(t,e){(n.get(t)||[]).slice().map(function(n){n(e)}),(n.get("*")||[]).slice().map(function(n){n(t,e)})}}} +//# sourceMappingURL=mitt.es.js.map diff --git a/remote/test/puppeteer/vendor/mitt/dist/mitt.es.js.map b/remote/test/puppeteer/vendor/mitt/dist/mitt.es.js.map new file mode 100644 index 0000000000..6576278e2d --- /dev/null +++ b/remote/test/puppeteer/vendor/mitt/dist/mitt.es.js.map @@ -0,0 +1 @@ +{"version":3,"file":"mitt.es.js","sources":["../src/index.ts"],"sourcesContent":["export type EventType = string | symbol;\n\n// An event handler can take an optional event argument\n// and should not return a value\nexport type Handler<T = any> = (event?: T) => void;\nexport type WildcardHandler = (type: EventType, event?: any) => void;\n\n// An array of all currently registered event handlers for a type\nexport type EventHandlerList = Array<Handler>;\nexport type WildCardEventHandlerList = Array<WildcardHandler>;\n\n// A map of event types and their corresponding event handlers.\nexport type EventHandlerMap = Map<EventType, EventHandlerList | WildCardEventHandlerList>;\n\nexport interface Emitter {\n\tall: EventHandlerMap;\n\n\ton<T = any>(type: EventType, handler: Handler<T>): void;\n\ton(type: '*', handler: WildcardHandler): void;\n\n\toff<T = any>(type: EventType, handler: Handler<T>): void;\n\toff(type: '*', handler: WildcardHandler): void;\n\n\temit<T = any>(type: EventType, event?: T): void;\n\temit(type: '*', event?: any): void;\n}\n\n/**\n * Mitt: Tiny (~200b) functional event emitter / pubsub.\n * @name mitt\n * @returns {Mitt}\n */\nexport default function mitt(all?: EventHandlerMap): Emitter {\n\tall = all || new Map();\n\n\treturn {\n\n\t\t/**\n\t\t * A Map of event names to registered handler functions.\n\t\t */\n\t\tall,\n\n\t\t/**\n\t\t * Register an event handler for the given type.\n\t\t * @param {string|symbol} type Type of event to listen for, or `\"*\"` for all events\n\t\t * @param {Function} handler Function to call in response to given event\n\t\t * @memberOf mitt\n\t\t */\n\t\ton<T = any>(type: EventType, handler: Handler<T>) {\n\t\t\tconst handlers = all.get(type);\n\t\t\tconst added = handlers && handlers.push(handler);\n\t\t\tif (!added) {\n\t\t\t\tall.set(type, [handler]);\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Remove an event handler for the given type.\n\t\t * @param {string|symbol} type Type of event to unregister `handler` from, or `\"*\"`\n\t\t * @param {Function} handler Handler function to remove\n\t\t * @memberOf mitt\n\t\t */\n\t\toff<T = any>(type: EventType, handler: Handler<T>) {\n\t\t\tconst handlers = all.get(type);\n\t\t\tif (handlers) {\n\t\t\t\thandlers.splice(handlers.indexOf(handler) >>> 0, 1);\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Invoke all handlers for the given type.\n\t\t * If present, `\"*\"` handlers are invoked after type-matched handlers.\n\t\t *\n\t\t * Note: Manually firing \"*\" handlers is not supported.\n\t\t *\n\t\t * @param {string|symbol} type The event type to invoke\n\t\t * @param {Any} [evt] Any value (object is recommended and powerful), passed to each handler\n\t\t * @memberOf mitt\n\t\t */\n\t\temit<T = any>(type: EventType, evt: T) {\n\t\t\t((all.get(type) || []) as EventHandlerList).slice().map((handler) => { handler(evt); });\n\t\t\t((all.get('*') || []) as WildCardEventHandlerList).slice().map((handler) => { handler(type, evt); });\n\t\t}\n\t};\n}\n"],"names":["all","Map","on","type","handler","handlers","get","push","set","off","splice","indexOf","emit","evt","slice","map"],"mappings":"wBAgC6BA,GAG5B,MAAO,CAKNA,IAPDA,EAAMA,GAAO,IAAIC,IAehBC,YAAYC,EAAiBC,GAC5B,IAAMC,EAAWL,EAAIM,IAAIH,GACXE,GAAYA,EAASE,KAAKH,IAEvCJ,EAAIQ,IAAIL,EAAM,CAACC,KAUjBK,aAAaN,EAAiBC,GAC7B,IAAMC,EAAWL,EAAIM,IAAIH,GACrBE,GACHA,EAASK,OAAOL,EAASM,QAAQP,KAAa,EAAG,IAcnDQ,cAAcT,EAAiBU,IAC5Bb,EAAIM,IAAIH,IAAS,IAAyBW,QAAQC,IAAI,SAACX,GAAcA,EAAQS,MAC7Eb,EAAIM,IAAI,MAAQ,IAAiCQ,QAAQC,IAAI,SAACX,GAAcA,EAAQD,EAAMU"}
\ No newline at end of file diff --git a/remote/test/puppeteer/vendor/mitt/dist/mitt.js b/remote/test/puppeteer/vendor/mitt/dist/mitt.js new file mode 100644 index 0000000000..2bd0cf9e44 --- /dev/null +++ b/remote/test/puppeteer/vendor/mitt/dist/mitt.js @@ -0,0 +1,2 @@ +module.exports=function(n){return{all:n=n||new Map,on:function(e,t){var i=n.get(e);i&&i.push(t)||n.set(e,[t])},off:function(e,t){var i=n.get(e);i&&i.splice(i.indexOf(t)>>>0,1)},emit:function(e,t){(n.get(e)||[]).slice().map(function(n){n(t)}),(n.get("*")||[]).slice().map(function(n){n(e,t)})}}}; +//# sourceMappingURL=mitt.js.map diff --git a/remote/test/puppeteer/vendor/mitt/dist/mitt.js.map b/remote/test/puppeteer/vendor/mitt/dist/mitt.js.map new file mode 100644 index 0000000000..37f6f59ebd --- /dev/null +++ b/remote/test/puppeteer/vendor/mitt/dist/mitt.js.map @@ -0,0 +1 @@ +{"version":3,"file":"mitt.js","sources":["../src/index.ts"],"sourcesContent":["export type EventType = string | symbol;\n\n// An event handler can take an optional event argument\n// and should not return a value\nexport type Handler<T = any> = (event?: T) => void;\nexport type WildcardHandler = (type: EventType, event?: any) => void;\n\n// An array of all currently registered event handlers for a type\nexport type EventHandlerList = Array<Handler>;\nexport type WildCardEventHandlerList = Array<WildcardHandler>;\n\n// A map of event types and their corresponding event handlers.\nexport type EventHandlerMap = Map<EventType, EventHandlerList | WildCardEventHandlerList>;\n\nexport interface Emitter {\n\tall: EventHandlerMap;\n\n\ton<T = any>(type: EventType, handler: Handler<T>): void;\n\ton(type: '*', handler: WildcardHandler): void;\n\n\toff<T = any>(type: EventType, handler: Handler<T>): void;\n\toff(type: '*', handler: WildcardHandler): void;\n\n\temit<T = any>(type: EventType, event?: T): void;\n\temit(type: '*', event?: any): void;\n}\n\n/**\n * Mitt: Tiny (~200b) functional event emitter / pubsub.\n * @name mitt\n * @returns {Mitt}\n */\nexport default function mitt(all?: EventHandlerMap): Emitter {\n\tall = all || new Map();\n\n\treturn {\n\n\t\t/**\n\t\t * A Map of event names to registered handler functions.\n\t\t */\n\t\tall,\n\n\t\t/**\n\t\t * Register an event handler for the given type.\n\t\t * @param {string|symbol} type Type of event to listen for, or `\"*\"` for all events\n\t\t * @param {Function} handler Function to call in response to given event\n\t\t * @memberOf mitt\n\t\t */\n\t\ton<T = any>(type: EventType, handler: Handler<T>) {\n\t\t\tconst handlers = all.get(type);\n\t\t\tconst added = handlers && handlers.push(handler);\n\t\t\tif (!added) {\n\t\t\t\tall.set(type, [handler]);\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Remove an event handler for the given type.\n\t\t * @param {string|symbol} type Type of event to unregister `handler` from, or `\"*\"`\n\t\t * @param {Function} handler Handler function to remove\n\t\t * @memberOf mitt\n\t\t */\n\t\toff<T = any>(type: EventType, handler: Handler<T>) {\n\t\t\tconst handlers = all.get(type);\n\t\t\tif (handlers) {\n\t\t\t\thandlers.splice(handlers.indexOf(handler) >>> 0, 1);\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Invoke all handlers for the given type.\n\t\t * If present, `\"*\"` handlers are invoked after type-matched handlers.\n\t\t *\n\t\t * Note: Manually firing \"*\" handlers is not supported.\n\t\t *\n\t\t * @param {string|symbol} type The event type to invoke\n\t\t * @param {Any} [evt] Any value (object is recommended and powerful), passed to each handler\n\t\t * @memberOf mitt\n\t\t */\n\t\temit<T = any>(type: EventType, evt: T) {\n\t\t\t((all.get(type) || []) as EventHandlerList).slice().map((handler) => { handler(evt); });\n\t\t\t((all.get('*') || []) as WildCardEventHandlerList).slice().map((handler) => { handler(type, evt); });\n\t\t}\n\t};\n}\n"],"names":["all","Map","on","type","handler","handlers","get","push","set","off","splice","indexOf","emit","evt","slice","map"],"mappings":"wBAgC6BA,GAG5B,MAAO,CAKNA,IAPDA,EAAMA,GAAO,IAAIC,IAehBC,YAAYC,EAAiBC,GAC5B,IAAMC,EAAWL,EAAIM,IAAIH,GACXE,GAAYA,EAASE,KAAKH,IAEvCJ,EAAIQ,IAAIL,EAAM,CAACC,KAUjBK,aAAaN,EAAiBC,GAC7B,IAAMC,EAAWL,EAAIM,IAAIH,GACrBE,GACHA,EAASK,OAAOL,EAASM,QAAQP,KAAa,EAAG,IAcnDQ,cAAcT,EAAiBU,IAC5Bb,EAAIM,IAAIH,IAAS,IAAyBW,QAAQC,IAAI,SAACX,GAAcA,EAAQS,MAC7Eb,EAAIM,IAAI,MAAQ,IAAiCQ,QAAQC,IAAI,SAACX,GAAcA,EAAQD,EAAMU"}
\ No newline at end of file diff --git a/remote/test/puppeteer/vendor/mitt/dist/mitt.modern.js b/remote/test/puppeteer/vendor/mitt/dist/mitt.modern.js new file mode 100644 index 0000000000..0777f6de72 --- /dev/null +++ b/remote/test/puppeteer/vendor/mitt/dist/mitt.modern.js @@ -0,0 +1,2 @@ +export default function(e){return{all:e=e||new Map,on(t,n){const s=e.get(t);s&&s.push(n)||e.set(t,[n])},off(t,n){const s=e.get(t);s&&s.splice(s.indexOf(n)>>>0,1)},emit(t,n){(e.get(t)||[]).slice().map(e=>{e(n)}),(e.get("*")||[]).slice().map(e=>{e(t,n)})}}} +//# sourceMappingURL=mitt.modern.js.map diff --git a/remote/test/puppeteer/vendor/mitt/dist/mitt.modern.js.map b/remote/test/puppeteer/vendor/mitt/dist/mitt.modern.js.map new file mode 100644 index 0000000000..5f669b2d61 --- /dev/null +++ b/remote/test/puppeteer/vendor/mitt/dist/mitt.modern.js.map @@ -0,0 +1 @@ +{"version":3,"file":"mitt.modern.js","sources":["../src/index.ts"],"sourcesContent":["export type EventType = string | symbol;\n\n// An event handler can take an optional event argument\n// and should not return a value\nexport type Handler<T = any> = (event?: T) => void;\nexport type WildcardHandler = (type: EventType, event?: any) => void;\n\n// An array of all currently registered event handlers for a type\nexport type EventHandlerList = Array<Handler>;\nexport type WildCardEventHandlerList = Array<WildcardHandler>;\n\n// A map of event types and their corresponding event handlers.\nexport type EventHandlerMap = Map<EventType, EventHandlerList | WildCardEventHandlerList>;\n\nexport interface Emitter {\n\tall: EventHandlerMap;\n\n\ton<T = any>(type: EventType, handler: Handler<T>): void;\n\ton(type: '*', handler: WildcardHandler): void;\n\n\toff<T = any>(type: EventType, handler: Handler<T>): void;\n\toff(type: '*', handler: WildcardHandler): void;\n\n\temit<T = any>(type: EventType, event?: T): void;\n\temit(type: '*', event?: any): void;\n}\n\n/**\n * Mitt: Tiny (~200b) functional event emitter / pubsub.\n * @name mitt\n * @returns {Mitt}\n */\nexport default function mitt(all?: EventHandlerMap): Emitter {\n\tall = all || new Map();\n\n\treturn {\n\n\t\t/**\n\t\t * A Map of event names to registered handler functions.\n\t\t */\n\t\tall,\n\n\t\t/**\n\t\t * Register an event handler for the given type.\n\t\t * @param {string|symbol} type Type of event to listen for, or `\"*\"` for all events\n\t\t * @param {Function} handler Function to call in response to given event\n\t\t * @memberOf mitt\n\t\t */\n\t\ton<T = any>(type: EventType, handler: Handler<T>) {\n\t\t\tconst handlers = all.get(type);\n\t\t\tconst added = handlers && handlers.push(handler);\n\t\t\tif (!added) {\n\t\t\t\tall.set(type, [handler]);\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Remove an event handler for the given type.\n\t\t * @param {string|symbol} type Type of event to unregister `handler` from, or `\"*\"`\n\t\t * @param {Function} handler Handler function to remove\n\t\t * @memberOf mitt\n\t\t */\n\t\toff<T = any>(type: EventType, handler: Handler<T>) {\n\t\t\tconst handlers = all.get(type);\n\t\t\tif (handlers) {\n\t\t\t\thandlers.splice(handlers.indexOf(handler) >>> 0, 1);\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Invoke all handlers for the given type.\n\t\t * If present, `\"*\"` handlers are invoked after type-matched handlers.\n\t\t *\n\t\t * Note: Manually firing \"*\" handlers is not supported.\n\t\t *\n\t\t * @param {string|symbol} type The event type to invoke\n\t\t * @param {Any} [evt] Any value (object is recommended and powerful), passed to each handler\n\t\t * @memberOf mitt\n\t\t */\n\t\temit<T = any>(type: EventType, evt: T) {\n\t\t\t((all.get(type) || []) as EventHandlerList).slice().map((handler) => { handler(evt); });\n\t\t\t((all.get('*') || []) as WildCardEventHandlerList).slice().map((handler) => { handler(type, evt); });\n\t\t}\n\t};\n}\n"],"names":["all","Map","on","type","handler","handlers","get","push","set","off","splice","indexOf","emit","evt","slice","map"],"mappings":"wBAgC6BA,GAG5B,MAAO,CAKNA,IAPDA,EAAMA,GAAO,IAAIC,IAehBC,GAAYC,EAAiBC,GAC5B,MAAMC,EAAWL,EAAIM,IAAIH,GACXE,GAAYA,EAASE,KAAKH,IAEvCJ,EAAIQ,IAAIL,EAAM,CAACC,KAUjBK,IAAaN,EAAiBC,GAC7B,MAAMC,EAAWL,EAAIM,IAAIH,GACrBE,GACHA,EAASK,OAAOL,EAASM,QAAQP,KAAa,EAAG,IAcnDQ,KAAcT,EAAiBU,IAC5Bb,EAAIM,IAAIH,IAAS,IAAyBW,QAAQC,IAAKX,IAAcA,EAAQS,MAC7Eb,EAAIM,IAAI,MAAQ,IAAiCQ,QAAQC,IAAKX,IAAcA,EAAQD,EAAMU"}
\ No newline at end of file diff --git a/remote/test/puppeteer/vendor/mitt/dist/mitt.umd.js b/remote/test/puppeteer/vendor/mitt/dist/mitt.umd.js new file mode 100644 index 0000000000..ce0e0059ae --- /dev/null +++ b/remote/test/puppeteer/vendor/mitt/dist/mitt.umd.js @@ -0,0 +1,2 @@ +!function(e,n){"object"==typeof exports&&"undefined"!=typeof module?module.exports=n():"function"==typeof define&&define.amd?define(n):(e=e||self).mitt=n()}(this,function(){return function(e){return{all:e=e||new Map,on:function(n,t){var f=e.get(n);f&&f.push(t)||e.set(n,[t])},off:function(n,t){var f=e.get(n);f&&f.splice(f.indexOf(t)>>>0,1)},emit:function(n,t){(e.get(n)||[]).slice().map(function(e){e(t)}),(e.get("*")||[]).slice().map(function(e){e(n,t)})}}}}); +//# sourceMappingURL=mitt.umd.js.map diff --git a/remote/test/puppeteer/vendor/mitt/dist/mitt.umd.js.map b/remote/test/puppeteer/vendor/mitt/dist/mitt.umd.js.map new file mode 100644 index 0000000000..642c894006 --- /dev/null +++ b/remote/test/puppeteer/vendor/mitt/dist/mitt.umd.js.map @@ -0,0 +1 @@ +{"version":3,"file":"mitt.umd.js","sources":["../src/index.ts"],"sourcesContent":["export type EventType = string | symbol;\n\n// An event handler can take an optional event argument\n// and should not return a value\nexport type Handler<T = any> = (event?: T) => void;\nexport type WildcardHandler = (type: EventType, event?: any) => void;\n\n// An array of all currently registered event handlers for a type\nexport type EventHandlerList = Array<Handler>;\nexport type WildCardEventHandlerList = Array<WildcardHandler>;\n\n// A map of event types and their corresponding event handlers.\nexport type EventHandlerMap = Map<EventType, EventHandlerList | WildCardEventHandlerList>;\n\nexport interface Emitter {\n\tall: EventHandlerMap;\n\n\ton<T = any>(type: EventType, handler: Handler<T>): void;\n\ton(type: '*', handler: WildcardHandler): void;\n\n\toff<T = any>(type: EventType, handler: Handler<T>): void;\n\toff(type: '*', handler: WildcardHandler): void;\n\n\temit<T = any>(type: EventType, event?: T): void;\n\temit(type: '*', event?: any): void;\n}\n\n/**\n * Mitt: Tiny (~200b) functional event emitter / pubsub.\n * @name mitt\n * @returns {Mitt}\n */\nexport default function mitt(all?: EventHandlerMap): Emitter {\n\tall = all || new Map();\n\n\treturn {\n\n\t\t/**\n\t\t * A Map of event names to registered handler functions.\n\t\t */\n\t\tall,\n\n\t\t/**\n\t\t * Register an event handler for the given type.\n\t\t * @param {string|symbol} type Type of event to listen for, or `\"*\"` for all events\n\t\t * @param {Function} handler Function to call in response to given event\n\t\t * @memberOf mitt\n\t\t */\n\t\ton<T = any>(type: EventType, handler: Handler<T>) {\n\t\t\tconst handlers = all.get(type);\n\t\t\tconst added = handlers && handlers.push(handler);\n\t\t\tif (!added) {\n\t\t\t\tall.set(type, [handler]);\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Remove an event handler for the given type.\n\t\t * @param {string|symbol} type Type of event to unregister `handler` from, or `\"*\"`\n\t\t * @param {Function} handler Handler function to remove\n\t\t * @memberOf mitt\n\t\t */\n\t\toff<T = any>(type: EventType, handler: Handler<T>) {\n\t\t\tconst handlers = all.get(type);\n\t\t\tif (handlers) {\n\t\t\t\thandlers.splice(handlers.indexOf(handler) >>> 0, 1);\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Invoke all handlers for the given type.\n\t\t * If present, `\"*\"` handlers are invoked after type-matched handlers.\n\t\t *\n\t\t * Note: Manually firing \"*\" handlers is not supported.\n\t\t *\n\t\t * @param {string|symbol} type The event type to invoke\n\t\t * @param {Any} [evt] Any value (object is recommended and powerful), passed to each handler\n\t\t * @memberOf mitt\n\t\t */\n\t\temit<T = any>(type: EventType, evt: T) {\n\t\t\t((all.get(type) || []) as EventHandlerList).slice().map((handler) => { handler(evt); });\n\t\t\t((all.get('*') || []) as WildCardEventHandlerList).slice().map((handler) => { handler(type, evt); });\n\t\t}\n\t};\n}\n"],"names":["all","Map","on","type","handler","handlers","get","push","set","off","splice","indexOf","emit","evt","slice","map"],"mappings":"6LAgC6BA,GAG5B,MAAO,CAKNA,IAPDA,EAAMA,GAAO,IAAIC,IAehBC,YAAYC,EAAiBC,GAC5B,IAAMC,EAAWL,EAAIM,IAAIH,GACXE,GAAYA,EAASE,KAAKH,IAEvCJ,EAAIQ,IAAIL,EAAM,CAACC,KAUjBK,aAAaN,EAAiBC,GAC7B,IAAMC,EAAWL,EAAIM,IAAIH,GACrBE,GACHA,EAASK,OAAOL,EAASM,QAAQP,KAAa,EAAG,IAcnDQ,cAAcT,EAAiBU,IAC5Bb,EAAIM,IAAIH,IAAS,IAAyBW,QAAQC,IAAI,SAACX,GAAcA,EAAQS,MAC7Eb,EAAIM,IAAI,MAAQ,IAAiCQ,QAAQC,IAAI,SAACX,GAAcA,EAAQD,EAAMU"}
\ No newline at end of file diff --git a/remote/test/puppeteer/vendor/mitt/index.d.ts b/remote/test/puppeteer/vendor/mitt/index.d.ts new file mode 100644 index 0000000000..55346dd976 --- /dev/null +++ b/remote/test/puppeteer/vendor/mitt/index.d.ts @@ -0,0 +1,21 @@ +export declare type EventType = string | symbol;
+export declare type Handler<T = any> = (event?: T) => void;
+export declare type WildcardHandler = (type: EventType, event?: any) => void;
+export declare type EventHandlerList = Array<Handler>;
+export declare type WildCardEventHandlerList = Array<WildcardHandler>;
+export declare type EventHandlerMap = Map<EventType, EventHandlerList | WildCardEventHandlerList>;
+export interface Emitter {
+ all: EventHandlerMap;
+ on<T = any>(type: EventType, handler: Handler<T>): void;
+ on(type: '*', handler: WildcardHandler): void;
+ off<T = any>(type: EventType, handler: Handler<T>): void;
+ off(type: '*', handler: WildcardHandler): void;
+ emit<T = any>(type: EventType, event?: T): void;
+ emit(type: '*', event?: any): void;
+}
+/**
+ * Mitt: Tiny (~200b) functional event emitter / pubsub.
+ * @name mitt
+ * @returns {Mitt}
+ */
+export default function mitt(all?: EventHandlerMap): Emitter;
diff --git a/remote/test/puppeteer/vendor/mitt/package.json b/remote/test/puppeteer/vendor/mitt/package.json new file mode 100644 index 0000000000..0105524a0d --- /dev/null +++ b/remote/test/puppeteer/vendor/mitt/package.json @@ -0,0 +1,141 @@ +{ + "_from": "mitt@latest", + "_id": "mitt@2.1.0", + "_inBundle": false, + "_integrity": "sha512-ILj2TpLiysu2wkBbWjAmww7TkZb65aiQO+DkVdUTBpBXq+MHYiETENkKFMtsJZX1Lf4pe4QOrTSjIfUwN5lRdg==", + "_location": "/mitt", + "_phantomChildren": {}, + "_requested": { + "type": "tag", + "registry": true, + "raw": "mitt@latest", + "name": "mitt", + "escapedName": "mitt", + "rawSpec": "latest", + "saveSpec": null, + "fetchSpec": "latest" + }, + "_requiredBy": [ + "#USER", + "/" + ], + "_resolved": "https://registry.npmjs.org/mitt/-/mitt-2.1.0.tgz", + "_shasum": "f740577c23176c6205b121b2973514eade1b2230", + "_spec": "mitt@latest", + "_where": "/Users/jacktfranklin/src/puppeteer", + "authors": [ + "Jason Miller <jason@developit.ca>" + ], + "bugs": { + "url": "https://github.com/developit/mitt/issues" + }, + "bundleDependencies": false, + "deprecated": false, + "description": "Tiny 200b functional Event Emitter / pubsub.", + "devDependencies": { + "@types/chai": "^4.2.11", + "@types/mocha": "^7.0.2", + "@types/sinon": "^9.0.4", + "@types/sinon-chai": "^3.2.4", + "@typescript-eslint/eslint-plugin": "^3.0.1", + "@typescript-eslint/parser": "^3.0.1", + "chai": "^4.2.0", + "documentation": "^13.0.0", + "eslint": "^7.1.0", + "eslint-config-developit": "^1.2.0", + "esm": "^3.2.25", + "microbundle": "^0.12.3", + "mocha": "^8.0.1", + "npm-run-all": "^4.1.5", + "rimraf": "^3.0.2", + "sinon": "^9.0.2", + "sinon-chai": "^3.5.0", + "ts-node": "^8.10.2", + "typescript": "^3.9.3" + }, + "eslintConfig": { + "extends": [ + "developit", + "plugin:@typescript-eslint/eslint-recommended", + "plugin:@typescript-eslint/recommended" + ], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "sourceType": "module" + }, + "env": { + "browser": true, + "mocha": true, + "jest": false, + "es6": true + }, + "globals": { + "expect": true + }, + "rules": { + "semi": [ + 2, + "always" + ], + "jest/valid-expect": 0, + "@typescript-eslint/no-explicit-any": 0, + "@typescript-eslint/explicit-function-return-type": 0, + "@typescript-eslint/explicit-module-boundary-types": 0, + "@typescript-eslint/no-empty-function": 0 + } + }, + "eslintIgnore": [ + "dist", + "index.d.ts" + ], + "esmodules": "dist/mitt.modern.js", + "files": [ + "src", + "dist", + "index.d.ts" + ], + "homepage": "https://github.com/developit/mitt", + "jsnext:main": "dist/mitt.es.js", + "keywords": [ + "events", + "eventemitter", + "emitter", + "pubsub" + ], + "license": "MIT", + "main": "dist/mitt.js", + "mocha": { + "extension": [ + "ts" + ], + "require": [ + "ts-node/register", + "esm" + ], + "spec": [ + "test/*_test.ts" + ] + }, + "module": "dist/mitt.es.js", + "name": "mitt", + "repository": { + "type": "git", + "url": "git+https://github.com/developit/mitt.git" + }, + "scripts": { + "build": "npm-run-all --silent clean -p bundle -s docs", + "bundle": "microbundle", + "clean": "rimraf dist", + "docs": "documentation readme src/index.ts --section API -q --parse-extension ts", + "lint": "eslint src test --ext ts --ext js", + "mocha": "mocha test", + "release": "npm run -s build -s && npm t && git commit -am $npm_package_version && git tag $npm_package_version && git push && git push --tags && npm publish", + "test": "npm-run-all --silent typecheck lint mocha test-types", + "test-types": "tsc test/test-types-compilation.ts --noEmit", + "typecheck": "tsc --noEmit" + }, + "source": "src/index.ts", + "typings": "index.d.ts", + "umd:main": "dist/mitt.umd.js", + "version": "2.1.0" +} diff --git a/remote/test/puppeteer/vendor/mitt/src/index.ts b/remote/test/puppeteer/vendor/mitt/src/index.ts new file mode 100644 index 0000000000..ae85607f7c --- /dev/null +++ b/remote/test/puppeteer/vendor/mitt/src/index.ts @@ -0,0 +1,85 @@ +export type EventType = string | symbol; + +// An event handler can take an optional event argument +// and should not return a value +export type Handler<T = any> = (event?: T) => void; +export type WildcardHandler = (type: EventType, event?: any) => void; + +// An array of all currently registered event handlers for a type +export type EventHandlerList = Array<Handler>; +export type WildCardEventHandlerList = Array<WildcardHandler>; + +// A map of event types and their corresponding event handlers. +export type EventHandlerMap = Map<EventType, EventHandlerList | WildCardEventHandlerList>; + +export interface Emitter { + all: EventHandlerMap; + + on<T = any>(type: EventType, handler: Handler<T>): void; + on(type: '*', handler: WildcardHandler): void; + + off<T = any>(type: EventType, handler: Handler<T>): void; + off(type: '*', handler: WildcardHandler): void; + + emit<T = any>(type: EventType, event?: T): void; + emit(type: '*', event?: any): void; +} + +/** + * Mitt: Tiny (~200b) functional event emitter / pubsub. + * @name mitt + * @returns {Mitt} + */ +export default function mitt(all?: EventHandlerMap): Emitter { + all = all || new Map(); + + return { + + /** + * A Map of event names to registered handler functions. + */ + all, + + /** + * Register an event handler for the given type. + * @param {string|symbol} type Type of event to listen for, or `"*"` for all events + * @param {Function} handler Function to call in response to given event + * @memberOf mitt + */ + on<T = any>(type: EventType, handler: Handler<T>) { + const handlers = all.get(type); + const added = handlers && handlers.push(handler); + if (!added) { + all.set(type, [handler]); + } + }, + + /** + * Remove an event handler for the given type. + * @param {string|symbol} type Type of event to unregister `handler` from, or `"*"` + * @param {Function} handler Handler function to remove + * @memberOf mitt + */ + off<T = any>(type: EventType, handler: Handler<T>) { + const handlers = all.get(type); + if (handlers) { + handlers.splice(handlers.indexOf(handler) >>> 0, 1); + } + }, + + /** + * Invoke all handlers for the given type. + * If present, `"*"` handlers are invoked after type-matched handlers. + * + * Note: Manually firing "*" handlers is not supported. + * + * @param {string|symbol} type The event type to invoke + * @param {Any} [evt] Any value (object is recommended and powerful), passed to each handler + * @memberOf mitt + */ + emit<T = any>(type: EventType, evt: T) { + ((all.get(type) || []) as EventHandlerList).slice().map((handler) => { handler(evt); }); + ((all.get('*') || []) as WildCardEventHandlerList).slice().map((handler) => { handler(type, evt); }); + } + }; +} diff --git a/remote/test/puppeteer/vendor/tsconfig.cjs.json b/remote/test/puppeteer/vendor/tsconfig.cjs.json new file mode 100644 index 0000000000..f73a8d57d0 --- /dev/null +++ b/remote/test/puppeteer/vendor/tsconfig.cjs.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.base.json", + "exclude": [ + "mitt/dist" + ], + "compilerOptions": { + "composite": true, + "outDir": "../lib/cjs/vendor", + "module": "CommonJS" + } +} diff --git a/remote/test/puppeteer/vendor/tsconfig.esm.json b/remote/test/puppeteer/vendor/tsconfig.esm.json new file mode 100644 index 0000000000..1f0bae8e3d --- /dev/null +++ b/remote/test/puppeteer/vendor/tsconfig.esm.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.base.json", + "exclude": [ + "mitt/dist" + ], + "compilerOptions": { + "composite": true, + "outDir": "../lib/esm/vendor", + "module": "esnext" + } +} diff --git a/remote/test/puppeteer/versions.js b/remote/test/puppeteer/versions.js new file mode 100644 index 0000000000..5a66247a4d --- /dev/null +++ b/remote/test/puppeteer/versions.js @@ -0,0 +1,37 @@ +/** + * Copyright 2020 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 versionsPerRelease = new Map([ + // This is a mapping from Chromium version => Puppeteer version. + // In Chromium roll patches, use 'NEXT' for the Puppeteer version. + ['88.0.4298.0', '5.5.0'], + ['87.0.4272.0', 'v5.4.0'], + ['86.0.4240.0', 'v5.3.0'], + ['85.0.4182.0', 'v5.2.1'], + ['84.0.4147.0', 'v5.1.0'], + ['83.0.4103.0', 'v3.1.0'], + ['81.0.4044.0', 'v3.0.0'], + ['80.0.3987.0', 'v2.1.0'], + ['79.0.3942.0', 'v2.0.0'], + ['78.0.3882.0', 'v1.20.0'], + ['77.0.3803.0', 'v1.19.0'], + ['76.0.3803.0', 'v1.17.0'], + ['75.0.3765.0', 'v1.15.0'], + ['74.0.3723.0', 'v1.13.0'], + ['73.0.3679.0', 'v1.12.2'], +]); + +module.exports = versionsPerRelease; diff --git a/remote/test/puppeteer/web-test-runner.config.js b/remote/test/puppeteer/web-test-runner.config.js new file mode 100644 index 0000000000..8cf8ad5361 --- /dev/null +++ b/remote/test/puppeteer/web-test-runner.config.js @@ -0,0 +1,43 @@ +/** + * Copyright 2020 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 { chromeLauncher } = require('@web/test-runner-chrome'); + +module.exports = { + files: ['test-browser/**/*.spec.js'], + browsers: [ + chromeLauncher({ + async createPage({ browser }) { + const page = await browser.newPage(); + page.evaluateOnNewDocument((wsEndpoint) => { + window.__ENV__ = { wsEndpoint }; + }, browser.wsEndpoint()); + + return page; + }, + }), + ], + plugins: [ + { + // turn expect UMD into an es module + name: 'esmify-expect', + transform(context) { + if (context.path === '/node_modules/expect/build-es5/index.js') { + return `const module = {}; const exports = {};\n${context.body};\n export default module.exports;`; + } + }, + }, + ], +}; |