Bug 1891762 - [puppeteer] Sync vendored puppeteer to v22.6.5 r=webdriver-reviewers,Sasha

Differential Revision: https://phabricator.services.mozilla.com/D207741
This commit is contained in:
Julian Descottes 2024-04-18 09:03:10 +00:00
parent 397d15d90f
commit 01a62a155f
111 changed files with 3590 additions and 2083 deletions

View file

@ -1,7 +1,7 @@
{
"packages/puppeteer": "22.4.0",
"packages/puppeteer-core": "22.4.0",
"packages/puppeteer": "22.6.5",
"packages/puppeteer-core": "22.6.5",
"packages/testserver": "0.6.0",
"packages/ng-schematics": "0.6.0",
"packages/browsers": "2.1.0"
"packages/browsers": "2.2.2"
}

View file

@ -0,0 +1,46 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"inputs": [
{
"type": "pickString",
"id": "suit",
"description": "Which test suit to run?",
"options": [
"chrome-headless",
"chrome-headful",
"chrome-headless-shell",
"firefox-headless",
"firefox-headful",
"firefox-bidi",
"chrome-bidi"
],
"default": "chrome-headless"
}
],
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Launch Tests",
"skipFiles": ["<node_internals>/**"],
"runtimeExecutable": "npm",
"cwd": "${workspaceFolder}",
"runtimeArgs": [
"run-script",
"test",
"--",
"--test-suite",
"${input:suit}",
"--no-coverage",
"--no-suggestions"
],
"outFiles": ["${workspaceFolder}/**/*.js"],
"env": {
"DEBUGGER_ATTACHED": true
}
}
]
}

View file

@ -6,20 +6,20 @@
/* eslint-disable import/order */
import {copyFile, readFile, writeFile} from 'fs/promises';
import {readFile, writeFile} from 'fs/promises';
import {docgen, spliceIntoSection} from '@puppeteer/docgen';
import {execa} from 'execa';
import {task} from 'hereby';
import semver from 'semver';
export const docsNgSchematicsTask = task({
name: 'docs:ng-schematics',
run: async () => {
const readme = await readFile('packages/ng-schematics/README.md', 'utf-8');
await writeFile('docs/integrations/ng-schematics.md', readme);
},
});
function addNoTocHeader(markdown) {
return `---
hide_table_of_contents: true
---
${markdown}`;
}
/**
* This logic should match the one in `website/docusaurus.config.js`.
@ -34,10 +34,18 @@ function getApiUrl(version) {
}
}
export const docsChromiumSupportTask = task({
name: 'docs:chromium-support',
export const docsNgSchematicsTask = task({
name: 'docs:ng-schematics',
run: async () => {
const content = await readFile('docs/chromium-support.md', {
const readme = await readFile('packages/ng-schematics/README.md', 'utf-8');
await writeFile('docs/guides/ng-schematics.md', readme);
},
});
export const docsChromiumSupportTask = task({
name: 'docs:supported-browsers',
run: async () => {
const content = await readFile('docs/supported-browsers.md', {
encoding: 'utf8',
});
const {versionsPerRelease} = await import('./versions.js');
@ -61,7 +69,7 @@ export const docsChromiumSupportTask = task({
}
}
await writeFile(
'docs/chromium-support.md',
'docs/supported-browsers.md',
spliceIntoSection('version', content, buffer.join('\n'))
);
},
@ -72,7 +80,8 @@ export const docsTask = task({
dependencies: [docsNgSchematicsTask, docsChromiumSupportTask],
run: async () => {
// Copy main page.
await copyFile('README.md', 'docs/index.md');
const mainPage = await readFile('README.md', 'utf-8');
await writeFile('docs/index.md', addNoTocHeader(mainPage));
// Generate documentation
for (const [name, folder] of [

View file

@ -1,145 +1,21 @@
# Puppeteer
[![Build status](https://github.com/puppeteer/puppeteer/workflows/CI/badge.svg)](https://github.com/puppeteer/puppeteer/actions?query=workflow%3ACI)
[![build](https://github.com/puppeteer/puppeteer/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/puppeteer/puppeteer/actions/workflows/ci.yml)
[![npm puppeteer package](https://img.shields.io/npm/v/puppeteer.svg)](https://npmjs.org/package/puppeteer)
<img src="https://user-images.githubusercontent.com/10379601/29446482-04f7036a-841f-11e7-9872-91d1fc2ea683.png" height="200" align="right"/>
#### [Guides](https://pptr.dev/category/guides) | [API](https://pptr.dev/api) | [FAQ](https://pptr.dev/faq) | [Contributing](https://pptr.dev/contributing) | [Troubleshooting](https://pptr.dev/troubleshooting)
> Puppeteer is a Node.js library which provides a high-level API to control
> Chrome/Chromium over the
> [DevTools Protocol](https://chromedevtools.github.io/devtools-protocol/).
> Puppeteer runs in
> [headless](https://developer.chrome.com/articles/new-headless/)
> [headless](https://developer.chrome.com/docs/chromium/new-headless/)
> mode by default, but can be configured to run in full ("headful")
> Chrome/Chromium.
#### What can I do?
## [Get started](https://pptr.dev/docs) | [API](https://pptr.dev/api) | [FAQ](https://pptr.dev/faq) | [Contributing](https://pptr.dev/contributing) | [Troubleshooting](https://pptr.dev/troubleshooting)
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 automated testing environment 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](https://pptr.dev/guides/chrome-extensions).
## Getting Started
### Installation
To use Puppeteer in your project, run:
```bash
npm i puppeteer
# or using yarn
yarn add puppeteer
# or using pnpm
pnpm i puppeteer
```
When you install Puppeteer, it automatically downloads a recent version of
[Chrome for Testing](https://developer.chrome.com/blog/chrome-for-testing/) (~170MB macOS, ~282MB Linux, ~280MB Windows) and a `chrome-headless-shell` binary (starting with Puppeteer v21.6.0) that is [guaranteed to
work](https://pptr.dev/faq#q-why-doesnt-puppeteer-vxxx-work-with-chromium-vyyy)
with Puppeteer. The browser is downloaded to the `$HOME/.cache/puppeteer` folder
by default (starting with Puppeteer v19.0.0). See [configuration](https://pptr.dev/api/puppeteer.configuration) for configuration options and environmental variables to control the download behavor.
If you deploy a project using Puppeteer to a hosting provider, such as Render or
Heroku, you might need to reconfigure the location of the cache to be within
your project folder (see an example below) because not all hosting providers
include `$HOME/.cache` into the project's deployment.
For a version of Puppeteer without the browser installation, see
[`puppeteer-core`](#puppeteer-core).
If used with TypeScript, the minimum supported TypeScript version is `4.7.4`.
#### Configuration
Puppeteer uses several defaults that can be customized through configuration
files.
For example, to change the default cache directory Puppeteer uses to install
browsers, you can add a `.puppeteerrc.cjs` (or `puppeteer.config.cjs`) at the
root of your application with the contents
```js
const {join} = require('path');
/**
* @type {import("puppeteer").Configuration}
*/
module.exports = {
// Changes the cache location for Puppeteer.
cacheDirectory: join(__dirname, '.cache', 'puppeteer'),
};
```
After adding the configuration file, you will need to remove and reinstall
`puppeteer` for it to take effect.
See the [configuration guide](https://pptr.dev/guides/configuration) for more
information.
#### `puppeteer-core`
For every release since v1.7.0 we publish two packages:
- [`puppeteer`](https://www.npmjs.com/package/puppeteer)
- [`puppeteer-core`](https://www.npmjs.com/package/puppeteer-core)
`puppeteer` is a _product_ for browser automation. When installed, it downloads
a version of Chrome, which it then drives using `puppeteer-core`. Being an
end-user product, `puppeteer` automates several workflows using reasonable
defaults [that can be customized](https://pptr.dev/guides/configuration).
`puppeteer-core` is a _library_ to help drive anything that supports DevTools
protocol. Being a library, `puppeteer-core` is fully driven through its
programmatic interface implying no defaults are assumed and `puppeteer-core`
will not download Chrome when installed.
You should use `puppeteer-core` if you are
[connecting to a remote browser](https://pptr.dev/api/puppeteer.puppeteer.connect)
or [managing browsers yourself](https://pptr.dev/browsers-api/).
If you are managing browsers yourself, you will need to call
[`puppeteer.launch`](https://pptr.dev/api/puppeteer.puppeteernode.launch) with
an explicit
[`executablePath`](https://pptr.dev/api/puppeteer.launchoptions)
(or [`channel`](https://pptr.dev/api/puppeteer.launchoptions) if it's
installed in a standard location).
When using `puppeteer-core`, remember to change the import:
```ts
import puppeteer from 'puppeteer-core';
```
### Usage
Puppeteer follows the latest
[maintenance LTS](https://github.com/nodejs/Release#release-schedule) version of
Node.
Puppeteer will be familiar to people using other browser testing frameworks. You
[launch](https://pptr.dev/api/puppeteer.puppeteernode.launch)/[connect](https://pptr.dev/api/puppeteer.puppeteernode.connect)
a [browser](https://pptr.dev/api/puppeteer.browser),
[create](https://pptr.dev/api/puppeteer.browser.newpage) some
[pages](https://pptr.dev/api/puppeteer.page), and then manipulate them with
[Puppeteer's API](https://pptr.dev/api).
For more in-depth usage, check our [guides](https://pptr.dev/category/guides)
and [examples](https://github.com/puppeteer/puppeteer/tree/main/examples).
#### Example
The following example searches [developer.chrome.com](https://developer.chrome.com/) for blog posts with text "automate beyond recorder", click on the first result and print the full title of the blog post.
## Example
```ts
import puppeteer from 'puppeteer';
@ -175,87 +51,3 @@ import puppeteer from 'puppeteer';
await browser.close();
})();
```
### Default runtime settings
**1. Uses Headless mode**
By default Puppeteer launches Chrome in
[the Headless mode](https://developer.chrome.com/articles/new-headless/).
```ts
const browser = await puppeteer.launch();
// Equivalent to
const browser = await puppeteer.launch({headless: true});
```
Before v22, Puppeteer launched the [old Headless mode](https://developer.chrome.com/articles/new-headless/) by default.
The old headless mode is now known as
[`chrome-headless-shell`](https://developer.chrome.com/blog/chrome-headless-shell)
and ships as a separate binary. `chrome-headless-shell` does not match the
behavior of the regular Chrome completely but it is currently more performant
for automation tasks where the complete Chrome feature set is not needed. If the performance
is more important for your use case, switch to `chrome-headless-shell` as following:
```ts
const browser = await puppeteer.launch({headless: 'shell'});
```
To launch a "headful" version of Chrome, set the
[`headless`](https://pptr.dev/api/puppeteer.browserlaunchargumentoptions) to `false`
option when launching a browser:
```ts
const browser = await puppeteer.launch({headless: false});
```
**2. Runs a bundled version of Chrome**
By default, Puppeteer downloads and uses a specific version of Chrome 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:
```ts
const browser = await puppeteer.launch({executablePath: '/path/to/Chrome'});
```
You can also use Puppeteer with Firefox. See
[status of cross-browser support](https://pptr.dev/faq/#q-what-is-the-status-of-cross-browser-support) 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/+/refs/heads/main/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**.
#### Using Docker
See our [Docker guide](https://pptr.dev/guides/docker).
#### Using Chrome Extensions
See our [Chrome extensions guide](https://pptr.dev/guides/chrome-extensions).
## Resources
- [API Documentation](https://pptr.dev/api)
- [Guides](https://pptr.dev/category/guides)
- [Examples](https://github.com/puppeteer/puppeteer/tree/main/examples)
- [Community list of Puppeteer resources](https://github.com/transitive-bullshit/awesome-puppeteer)
## Contributing
Check out our [contributing guide](https://pptr.dev/contributing) to get an
overview of Puppeteer development.
## FAQ
Our [FAQ](https://pptr.dev/faq) has migrated to
[our site](https://pptr.dev/faq).

View file

@ -1,6 +1,18 @@
# Running the examples
Assuming you have a checkout of the Puppeteer repo and have run `npm i` (or `yarn`) to install the dependencies, and `npm run build` (or `yarn run build`) to build the project, the examples can be run from the root folder like so:
Assuming you have a checkout of the Puppeteer repo and install the dependencies:
```bash npm2yarn
npm install
```
Build the project:
```bash npm2yarn
npm run build
```
The examples can be run from the root folder like so:
```bash
NODE_PATH=../ node examples/search.js

View file

@ -5,6 +5,6 @@ origin:
description: Headless Chrome Node API
license: Apache-2.0
name: puppeteer
release: puppeteer-v22.4.0
url: ../puppeteer
release: puppeteer-v22.6.5
url: /Users/juliandescottes/Development/git/puppeteer
schema: 1

File diff suppressed because it is too large Load diff

View file

@ -142,13 +142,13 @@
"@types/node": "20.8.4",
"@types/semver": "7.5.8",
"@types/sinon": "17.0.3",
"@typescript-eslint/eslint-plugin": "7.1.0",
"@typescript-eslint/parser": "7.1.0",
"esbuild": "0.20.1",
"@typescript-eslint/eslint-plugin": "7.6.0",
"@typescript-eslint/parser": "7.6.0",
"esbuild": "0.20.2",
"eslint-config-prettier": "9.1.0",
"eslint-import-resolver-typescript": "3.6.1",
"eslint-plugin-import": "2.29.1",
"eslint-plugin-mocha": "10.3.0",
"eslint-plugin-mocha": "10.4.2",
"eslint-plugin-prettier": "5.1.3",
"eslint-plugin-rulesdir": "0.2.2",
"eslint-plugin-tsdoc": "0.2.17",
@ -156,18 +156,18 @@
"eslint": "8.57.0",
"execa": "8.0.1",
"expect": "29.7.0",
"gts": "5.2.0",
"gts": "5.3.0",
"hereby": "1.8.9",
"license-checker": "25.0.1",
"mocha": "10.3.0",
"mocha": "10.4.0",
"npm-run-all2": "6.1.2",
"prettier": "3.2.5",
"semver": "7.6.0",
"sinon": "17.0.1",
"source-map-support": "0.5.21",
"spdx-satisfies": "5.0.1",
"tsd": "0.30.7",
"tsx": "4.7.1",
"tsd": "0.31.0",
"tsx": "4.7.2",
"typescript": "5.3.3",
"wireit": "0.14.4"
},

View file

@ -1,5 +1,32 @@
# Changelog
## [2.2.2](https://github.com/puppeteer/puppeteer/compare/browsers-v2.2.1...browsers-v2.2.2) (2024-04-15)
### Bug Fixes
* remove NetworkServiceInProcess2 set by default ([#12261](https://github.com/puppeteer/puppeteer/issues/12261)) ([ff4f70f](https://github.com/puppeteer/puppeteer/commit/ff4f70f4ae7ca8deb0becbec2e49b35322dba336)), closes [#12257](https://github.com/puppeteer/puppeteer/issues/12257)
## [2.2.1](https://github.com/puppeteer/puppeteer/compare/browsers-v2.2.0...browsers-v2.2.1) (2024-04-05)
### Bug Fixes
* do not use fallback download URLs if custom baseUrl is provided ([#12206](https://github.com/puppeteer/puppeteer/issues/12206)) ([ab560bc](https://github.com/puppeteer/puppeteer/commit/ab560bcf6fee57cabde94d9d261d28ffc2112948))
* only set up a single process event listener in launch ([#12200](https://github.com/puppeteer/puppeteer/issues/12200)) ([7bc5e0f](https://github.com/puppeteer/puppeteer/commit/7bc5e0fb2dc443765e2512e4dc15fb2bcc1cb4be))
## [2.2.0](https://github.com/puppeteer/puppeteer/compare/browsers-v2.1.0...browsers-v2.2.0) (2024-03-15)
### Features
* allow downloading Firefox channels other than nightly ([#12051](https://github.com/puppeteer/puppeteer/issues/12051)) ([e4cc2f9](https://github.com/puppeteer/puppeteer/commit/e4cc2f9ee944f2507a03cf8f5af99759c97ee2ec))
### Bug Fixes
* don't keep connection alive ([#12096](https://github.com/puppeteer/puppeteer/issues/12096)) ([0a142bf](https://github.com/puppeteer/puppeteer/commit/0a142bf0aa8a6666d1ca230d05a1ece0e03ad7d4))
## [2.1.0](https://github.com/puppeteer/puppeteer/compare/browsers-v2.0.1...browsers-v2.1.0) (2024-02-21)

View file

@ -10,7 +10,7 @@ Use `npx` to run the CLI:
npx @puppeteer/browsers --help
```
CLI help will provide all documentation you need to use the CLI.
Built-in per-command `help` will provide all documentation you need to use the CLI.
```bash
npx @puppeteer/browsers --help # help for all commands
@ -18,10 +18,28 @@ npx @puppeteer/browsers install --help # help for the install command
npx @puppeteer/browsers launch --help # help for the launch command
```
Some example to give an idea of what the CLI looks like (use the `--help` command for more examples):
```sh
# Download the latest available Chrome for Testing binary corresponding to the Stable channel.
npx @puppeteer/browsers install chrome@stable
# Download a specific Chrome for Testing version.
npx @puppeteer/browsers install chrome@116.0.5793.0
# Download the latest Chrome for Testing version for the given milestone.
npx @puppeteer/browsers install chrome@117
# Download the latest available ChromeDriver version corresponding to the Canary channel.
npx @puppeteer/browsers install chromedriver@canary
# Download a specific ChromeDriver version.
npx @puppeteer/browsers install chromedriver@116.0.5793.0
```
## Known limitations
1. We support installing and running Firefox, Chrome and Chromium. The `latest`, `beta`, `dev`, `canary`, `stable` keywords are only supported for the install command. For the `launch` command you need to specify an exact build ID. The build ID is provided by the `install` command (see `npx @puppeteer/browsers install --help` for the format).
2. Launching the system browsers is only possible for Chrome/Chromium.
1. Launching the system browsers is only possible for Chrome/Chromium.
## API

View file

@ -1,6 +1,6 @@
{
"name": "@puppeteer/browsers",
"version": "2.1.0",
"version": "2.2.2",
"description": "Download and launch browsers",
"scripts": {
"build:docs": "wireit",

View file

@ -222,7 +222,31 @@ export class CLI {
);
yargs.example(
'$0 install firefox',
'Install the latest available build of the Firefox browser.'
'Install the latest nightly available build of the Firefox browser.'
);
yargs.example(
'$0 install firefox@stable',
'Install the latest stable build of the Firefox browser.'
);
yargs.example(
'$0 install firefox@beta',
'Install the latest beta build of the Firefox browser.'
);
yargs.example(
'$0 install firefox@devedition',
'Install the latest devedition build of the Firefox browser.'
);
yargs.example(
'$0 install firefox@esr',
'Install the latest ESR build of the Firefox browser.'
);
yargs.example(
'$0 install firefox@nightly',
'Install the latest nightly build of the Firefox browser.'
);
yargs.example(
'$0 install firefox@stable_111.0.1',
'Install a specific version of the Firefox browser.'
);
yargs.example(
'$0 install firefox --platform mac',
@ -395,7 +419,7 @@ export function makeProgressCallback(
return (downloadedBytes: number, totalBytes: number) => {
if (!progressBar) {
progressBar = new ProgressBar(
`Downloading ${browser} r${buildId} - ${toMegabytes(
`Downloading ${browser} ${buildId} - ${toMegabytes(
totalBytes
)} [:bar] :percent :etas `,
{

View file

@ -54,28 +54,36 @@ export const versionComparators = {
export {Browser, BrowserPlatform, ChromeReleaseChannel};
/**
* @public
* @internal
*/
export async function resolveBuildId(
async function resolveBuildIdForBrowserTag(
browser: Browser,
platform: BrowserPlatform,
tag: string
tag: BrowserTag
): Promise<string> {
switch (browser) {
case Browser.FIREFOX:
switch (tag as BrowserTag) {
switch (tag) {
case BrowserTag.LATEST:
return await firefox.resolveBuildId('FIREFOX_NIGHTLY');
return await firefox.resolveBuildId(firefox.FirefoxChannel.NIGHTLY);
case BrowserTag.BETA:
return await firefox.resolveBuildId(firefox.FirefoxChannel.BETA);
case BrowserTag.NIGHTLY:
return await firefox.resolveBuildId(firefox.FirefoxChannel.NIGHTLY);
case BrowserTag.DEVEDITION:
return await firefox.resolveBuildId(
firefox.FirefoxChannel.DEVEDITION
);
case BrowserTag.STABLE:
return await firefox.resolveBuildId(firefox.FirefoxChannel.STABLE);
case BrowserTag.ESR:
return await firefox.resolveBuildId(firefox.FirefoxChannel.ESR);
case BrowserTag.CANARY:
case BrowserTag.DEV:
case BrowserTag.STABLE:
throw new Error(
`${tag} is not supported for ${browser}. Use 'latest' instead.`
);
throw new Error(`${tag.toUpperCase()} is not available for Firefox`);
}
case Browser.CHROME: {
switch (tag as BrowserTag) {
switch (tag) {
case BrowserTag.LATEST:
return await chrome.resolveBuildId(ChromeReleaseChannel.CANARY);
case BrowserTag.BETA:
@ -86,13 +94,11 @@ export async function resolveBuildId(
return await chrome.resolveBuildId(ChromeReleaseChannel.DEV);
case BrowserTag.STABLE:
return await chrome.resolveBuildId(ChromeReleaseChannel.STABLE);
default:
const result = await chrome.resolveBuildId(tag);
if (result) {
return result;
}
case BrowserTag.NIGHTLY:
case BrowserTag.DEVEDITION:
case BrowserTag.ESR:
throw new Error(`${tag.toUpperCase()} is not available for Chrome`);
}
return tag;
}
case Browser.CHROMEDRIVER: {
switch (tag) {
@ -105,13 +111,13 @@ export async function resolveBuildId(
return await chromedriver.resolveBuildId(ChromeReleaseChannel.DEV);
case BrowserTag.STABLE:
return await chromedriver.resolveBuildId(ChromeReleaseChannel.STABLE);
default:
const result = await chromedriver.resolveBuildId(tag);
if (result) {
return result;
}
case BrowserTag.NIGHTLY:
case BrowserTag.DEVEDITION:
case BrowserTag.ESR:
throw new Error(
`${tag.toUpperCase()} is not available for ChromeDriver`
);
}
return tag;
}
case Browser.CHROMEHEADLESSSHELL: {
switch (tag) {
@ -132,29 +138,68 @@ export async function resolveBuildId(
return await chromeHeadlessShell.resolveBuildId(
ChromeReleaseChannel.STABLE
);
default:
const result = await chromeHeadlessShell.resolveBuildId(tag);
if (result) {
return result;
}
case BrowserTag.NIGHTLY:
case BrowserTag.DEVEDITION:
case BrowserTag.ESR:
throw new Error(`${tag} is not available for chrome-headless-shell`);
}
return tag;
}
case Browser.CHROMIUM:
switch (tag as BrowserTag) {
switch (tag) {
case BrowserTag.LATEST:
return await chromium.resolveBuildId(platform);
case BrowserTag.BETA:
case BrowserTag.NIGHTLY:
case BrowserTag.CANARY:
case BrowserTag.DEV:
case BrowserTag.DEVEDITION:
case BrowserTag.BETA:
case BrowserTag.STABLE:
case BrowserTag.ESR:
throw new Error(
`${tag} is not supported for ${browser}. Use 'latest' instead.`
`${tag} is not supported for Chromium. Use 'latest' instead.`
);
}
}
// We assume the tag is the buildId if it didn't match any keywords.
return tag;
}
/**
* @public
*/
export async function resolveBuildId(
browser: Browser,
platform: BrowserPlatform,
tag: string
): Promise<string> {
const browserTag = tag as BrowserTag;
if (Object.values(BrowserTag).includes(browserTag)) {
return await resolveBuildIdForBrowserTag(browser, platform, browserTag);
}
switch (browser) {
case Browser.FIREFOX:
return tag;
case Browser.CHROME:
const chromeResult = await chrome.resolveBuildId(tag);
if (chromeResult) {
return chromeResult;
}
return tag;
case Browser.CHROMEDRIVER:
const chromeDriverResult = await chromedriver.resolveBuildId(tag);
if (chromeDriverResult) {
return chromeDriverResult;
}
return tag;
case Browser.CHROMEHEADLESSSHELL:
const chromeHeadlessShellResult =
await chromeHeadlessShell.resolveBuildId(tag);
if (chromeHeadlessShellResult) {
return chromeHeadlessShellResult;
}
return tag;
case Browser.CHROMIUM:
return tag;
}
}
/**

View file

@ -11,7 +11,7 @@ import {getJSON} from '../httpUtil.js';
import {BrowserPlatform, type ProfileOptions} from './types.js';
function archive(platform: BrowserPlatform, buildId: string): string {
function archiveNightly(platform: BrowserPlatform, buildId: string): string {
switch (platform) {
case BrowserPlatform.LINUX:
return `firefox-${buildId}.en-US.${platform}-x86_64.tar.bz2`;
@ -24,48 +24,146 @@ function archive(platform: BrowserPlatform, buildId: string): string {
}
}
function archive(platform: BrowserPlatform, buildId: string): string {
switch (platform) {
case BrowserPlatform.LINUX:
return `firefox-${buildId}.tar.bz2`;
case BrowserPlatform.MAC_ARM:
case BrowserPlatform.MAC:
return `Firefox ${buildId}.dmg`;
case BrowserPlatform.WIN32:
case BrowserPlatform.WIN64:
return `Firefox Setup ${buildId}.exe`;
}
}
function platformName(platform: BrowserPlatform): string {
switch (platform) {
case BrowserPlatform.LINUX:
return `linux-x86_64`;
case BrowserPlatform.MAC_ARM:
case BrowserPlatform.MAC:
return `mac`;
case BrowserPlatform.WIN32:
case BrowserPlatform.WIN64:
return platform;
}
}
function parseBuildId(buildId: string): [FirefoxChannel, string] {
for (const value of Object.values(FirefoxChannel)) {
if (buildId.startsWith(value + '_')) {
buildId = buildId.substring(value.length + 1);
return [value, buildId];
}
}
// Older versions do not have channel as the prefix.«
return [FirefoxChannel.NIGHTLY, buildId];
}
export function resolveDownloadUrl(
platform: BrowserPlatform,
buildId: string,
baseUrl = 'https://archive.mozilla.org/pub/firefox/nightly/latest-mozilla-central'
baseUrl?: string
): string {
return `${baseUrl}/${resolveDownloadPath(platform, buildId).join('/')}`;
const [channel, resolvedBuildId] = parseBuildId(buildId);
switch (channel) {
case FirefoxChannel.NIGHTLY:
baseUrl ??=
'https://archive.mozilla.org/pub/firefox/nightly/latest-mozilla-central';
break;
case FirefoxChannel.DEVEDITION:
baseUrl ??= 'https://archive.mozilla.org/pub/devedition/releases';
break;
case FirefoxChannel.BETA:
case FirefoxChannel.STABLE:
case FirefoxChannel.ESR:
baseUrl ??= 'https://archive.mozilla.org/pub/firefox/releases';
break;
}
switch (channel) {
case FirefoxChannel.NIGHTLY:
return `${baseUrl}/${resolveDownloadPath(platform, resolvedBuildId).join('/')}`;
case FirefoxChannel.DEVEDITION:
case FirefoxChannel.BETA:
case FirefoxChannel.STABLE:
case FirefoxChannel.ESR:
return `${baseUrl}/${resolvedBuildId}/${platformName(platform)}/en-US/${archive(platform, resolvedBuildId)}`;
}
}
export function resolveDownloadPath(
platform: BrowserPlatform,
buildId: string
): string[] {
return [archive(platform, buildId)];
return [archiveNightly(platform, buildId)];
}
export function relativeExecutablePath(
platform: BrowserPlatform,
_buildId: string
buildId: string
): string {
switch (platform) {
case BrowserPlatform.MAC_ARM:
case BrowserPlatform.MAC:
return path.join('Firefox Nightly.app', 'Contents', 'MacOS', 'firefox');
case BrowserPlatform.LINUX:
return path.join('firefox', 'firefox');
case BrowserPlatform.WIN32:
case BrowserPlatform.WIN64:
return path.join('firefox', 'firefox.exe');
const [channel] = parseBuildId(buildId);
switch (channel) {
case FirefoxChannel.NIGHTLY:
switch (platform) {
case BrowserPlatform.MAC_ARM:
case BrowserPlatform.MAC:
return path.join(
'Firefox Nightly.app',
'Contents',
'MacOS',
'firefox'
);
case BrowserPlatform.LINUX:
return path.join('firefox', 'firefox');
case BrowserPlatform.WIN32:
case BrowserPlatform.WIN64:
return path.join('firefox', 'firefox.exe');
}
case FirefoxChannel.BETA:
case FirefoxChannel.DEVEDITION:
case FirefoxChannel.ESR:
case FirefoxChannel.STABLE:
switch (platform) {
case BrowserPlatform.MAC_ARM:
case BrowserPlatform.MAC:
return path.join('Firefox.app', 'Contents', 'MacOS', 'firefox');
case BrowserPlatform.LINUX:
return path.join('firefox', 'firefox');
case BrowserPlatform.WIN32:
case BrowserPlatform.WIN64:
return path.join('core', 'firefox.exe');
}
}
}
export enum FirefoxChannel {
STABLE = 'stable',
ESR = 'esr',
DEVEDITION = 'devedition',
BETA = 'beta',
NIGHTLY = 'nightly',
}
export async function resolveBuildId(
channel: 'FIREFOX_NIGHTLY' = 'FIREFOX_NIGHTLY'
channel: FirefoxChannel = FirefoxChannel.NIGHTLY
): Promise<string> {
const channelToVersionKey = {
[FirefoxChannel.ESR]: 'FIREFOX_ESR',
[FirefoxChannel.STABLE]: 'LATEST_FIREFOX_VERSION',
[FirefoxChannel.DEVEDITION]: 'FIREFOX_DEVEDITION',
[FirefoxChannel.BETA]: 'FIREFOX_DEVEDITION',
[FirefoxChannel.NIGHTLY]: 'FIREFOX_NIGHTLY',
};
const versions = (await getJSON(
new URL('https://product-details.mozilla.org/1.0/firefox_versions.json')
)) as Record<string, string>;
const version = versions[channel];
const version = versions[channelToVersionKey[channel]];
if (!version) {
throw new Error(`Channel ${channel} is not found.`);
}
return version;
return channel + '_' + version;
}
export async function createProfile(options: ProfileOptions): Promise<void> {

View file

@ -36,9 +36,12 @@ export enum BrowserPlatform {
*/
export enum BrowserTag {
CANARY = 'canary',
NIGHTLY = 'nightly',
BETA = 'beta',
DEV = 'dev',
DEVEDITION = 'devedition',
STABLE = 'stable',
ESR = 'esr',
LATEST = 'latest',
}

View file

@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {exec as execChildProcess} from 'child_process';
import {exec as execChildProcess, spawnSync} from 'child_process';
import {createReadStream} from 'fs';
import {mkdir, readdir} from 'fs/promises';
import * as path from 'path';
@ -30,6 +30,18 @@ export async function unpackArchive(
} else if (archivePath.endsWith('.dmg')) {
await mkdir(folderPath);
await installDMG(archivePath, folderPath);
} else if (archivePath.endsWith('.exe')) {
// Firefox on Windows.
const result = spawnSync(archivePath, [`/ExtractDir=${folderPath}`], {
env: {
__compat_layer: 'RunAsInvoker',
},
});
if (result.status !== 0) {
throw new Error(
`Failed to extract ${archivePath} to ${folderPath}: ${result.output}`
);
}
} else {
throw new Error(`Unsupported archive format: ${archivePath}`);
}

View file

@ -54,6 +54,9 @@ export function httpRequest(
res.headers.location
) {
httpRequest(new URL(res.headers.location), method, response);
// consume response data to free up memory
// And prevents the connection from being kept alive
res.resume();
} else {
response(res);
}

View file

@ -92,6 +92,11 @@ export interface InstallOptions {
* @defaultValue `true`
*/
unpack?: boolean;
/**
* @internal
* @defaultValue `false`
*/
forceFallbackForTesting?: boolean;
}
/**
@ -125,6 +130,10 @@ export async function install(
try {
return await installUrl(url, options);
} catch (err) {
// If custom baseUrl is provided, do not fall back to CfT dashboard.
if (options.baseUrl && !options.forceFallbackForTesting) {
throw err;
}
debugInstall(`Error downloading from ${url}.`);
switch (options.browser) {
case Browser.CHROME:

View file

@ -135,6 +135,59 @@ export const CDP_WEBSOCKET_ENDPOINT_REGEX =
export const WEBDRIVER_BIDI_WEBSOCKET_ENDPOINT_REGEX =
/^WebDriver BiDi listening on (ws:\/\/.*)$/;
type EventHandler = (...args: any[]) => void;
const processListeners = new Map<string, EventHandler[]>();
const dispatchers = {
exit: (...args: any[]) => {
processListeners.get('exit')?.forEach(handler => {
return handler(...args);
});
},
SIGINT: (...args: any[]) => {
processListeners.get('SIGINT')?.forEach(handler => {
return handler(...args);
});
},
SIGHUP: (...args: any[]) => {
processListeners.get('SIGHUP')?.forEach(handler => {
return handler(...args);
});
},
SIGTERM: (...args: any[]) => {
processListeners.get('SIGTERM')?.forEach(handler => {
return handler(...args);
});
},
};
function subscribeToProcessEvent(
event: 'exit' | 'SIGINT' | 'SIGHUP' | 'SIGTERM',
handler: EventHandler
): void {
const listeners = processListeners.get(event) || [];
if (listeners.length === 0) {
process.on(event, dispatchers[event]);
}
listeners.push(handler);
processListeners.set(event, listeners);
}
function unsubscribeFromProcessEvent(
event: 'exit' | 'SIGINT' | 'SIGHUP' | 'SIGTERM',
handler: EventHandler
): void {
const listeners = processListeners.get(event) || [];
const existingListenerIdx = listeners.indexOf(handler);
if (existingListenerIdx === -1) {
return;
}
listeners.splice(existingListenerIdx, 1);
processListeners.set(event, listeners);
if (listeners.length === 0) {
process.off(event, dispatchers[event]);
}
}
/**
* @public
*/
@ -201,15 +254,15 @@ export class Process {
this.#browserProcess.stderr?.pipe(process.stderr);
this.#browserProcess.stdout?.pipe(process.stdout);
}
process.on('exit', this.#onDriverProcessExit);
subscribeToProcessEvent('exit', this.#onDriverProcessExit);
if (opts.handleSIGINT) {
process.on('SIGINT', this.#onDriverProcessSignal);
subscribeToProcessEvent('SIGINT', this.#onDriverProcessSignal);
}
if (opts.handleSIGTERM) {
process.on('SIGTERM', this.#onDriverProcessSignal);
subscribeToProcessEvent('SIGTERM', this.#onDriverProcessSignal);
}
if (opts.handleSIGHUP) {
process.on('SIGHUP', this.#onDriverProcessSignal);
subscribeToProcessEvent('SIGHUP', this.#onDriverProcessSignal);
}
if (opts.onExit) {
this.#onExitHook = opts.onExit;
@ -262,10 +315,10 @@ export class Process {
}
#clearListeners(): void {
process.off('exit', this.#onDriverProcessExit);
process.off('SIGINT', this.#onDriverProcessSignal);
process.off('SIGTERM', this.#onDriverProcessSignal);
process.off('SIGHUP', this.#onDriverProcessSignal);
unsubscribeFromProcessEvent('exit', this.#onDriverProcessExit);
unsubscribeFromProcessEvent('SIGINT', this.#onDriverProcessSignal);
unsubscribeFromProcessEvent('SIGTERM', this.#onDriverProcessSignal);
unsubscribeFromProcessEvent('SIGHUP', this.#onDriverProcessSignal);
}
#onDriverProcessExit = (_code: number) => {

View file

@ -138,6 +138,7 @@ describe('Chrome install', () => {
});
it('falls back to the chrome-for-testing dashboard URLs if URL is not available', async function () {
this.timeout(60000);
const expectedOutputPath = path.join(
tmpDir,
'chrome',
@ -150,6 +151,7 @@ describe('Chrome install', () => {
platform: BrowserPlatform.LINUX,
buildId: testChromeBuildId,
baseUrl: 'https://127.0.0.1',
forceFallbackForTesting: true,
});
assert.strictEqual(fs.existsSync(expectedOutputPath), true);
});

View file

@ -77,7 +77,6 @@ describe('Chrome', () => {
'--disable-renderer-backgrounding',
'--disable-sync',
'--enable-automation',
'--enable-features=NetworkServiceInProcess2',
'--export-tagged-pdf',
'--force-color-profile=srgb',
'--headless=new',

View file

@ -77,7 +77,6 @@ describe('Chromium', () => {
'--disable-renderer-backgrounding',
'--disable-sync',
'--enable-automation',
'--enable-features=NetworkServiceInProcess2',
'--export-tagged-pdf',
'--force-color-profile=srgb',
'--headless=new',

View file

@ -18,7 +18,7 @@ import {
} from '../../../lib/cjs/browser-data/firefox.js';
describe('Firefox', () => {
it('should resolve download URLs', () => {
it('should resolve download URLs for Nightly', () => {
assert.strictEqual(
resolveDownloadUrl(BrowserPlatform.LINUX, '111.0a1'),
'https://archive.mozilla.org/pub/firefox/nightly/latest-mozilla-central/firefox-111.0a1.en-US.linux-x86_64.tar.bz2'
@ -41,6 +41,75 @@ describe('Firefox', () => {
);
});
it('should resolve download URLs for beta', () => {
assert.strictEqual(
resolveDownloadUrl(BrowserPlatform.LINUX, 'beta_115.0b8'),
'https://archive.mozilla.org/pub/firefox/releases/115.0b8/linux-x86_64/en-US/firefox-115.0b8.tar.bz2'
);
assert.strictEqual(
resolveDownloadUrl(BrowserPlatform.MAC, 'beta_115.0b8'),
'https://archive.mozilla.org/pub/firefox/releases/115.0b8/mac/en-US/Firefox 115.0b8.dmg'
);
assert.strictEqual(
resolveDownloadUrl(BrowserPlatform.MAC_ARM, 'beta_115.0b8'),
'https://archive.mozilla.org/pub/firefox/releases/115.0b8/mac/en-US/Firefox 115.0b8.dmg'
);
assert.strictEqual(
resolveDownloadUrl(BrowserPlatform.WIN32, 'beta_115.0b8'),
'https://archive.mozilla.org/pub/firefox/releases/115.0b8/win32/en-US/Firefox Setup 115.0b8.exe'
);
assert.strictEqual(
resolveDownloadUrl(BrowserPlatform.WIN64, 'beta_115.0b8'),
'https://archive.mozilla.org/pub/firefox/releases/115.0b8/win64/en-US/Firefox Setup 115.0b8.exe'
);
});
it('should resolve download URLs for stable', () => {
assert.strictEqual(
resolveDownloadUrl(BrowserPlatform.LINUX, 'stable_111.0.1'),
'https://archive.mozilla.org/pub/firefox/releases/111.0.1/linux-x86_64/en-US/firefox-111.0.1.tar.bz2'
);
assert.strictEqual(
resolveDownloadUrl(BrowserPlatform.MAC, 'stable_111.0.1'),
'https://archive.mozilla.org/pub/firefox/releases/111.0.1/mac/en-US/Firefox 111.0.1.dmg'
);
assert.strictEqual(
resolveDownloadUrl(BrowserPlatform.MAC_ARM, 'stable_111.0.1'),
'https://archive.mozilla.org/pub/firefox/releases/111.0.1/mac/en-US/Firefox 111.0.1.dmg'
);
assert.strictEqual(
resolveDownloadUrl(BrowserPlatform.WIN32, 'stable_111.0.1'),
'https://archive.mozilla.org/pub/firefox/releases/111.0.1/win32/en-US/Firefox Setup 111.0.1.exe'
);
assert.strictEqual(
resolveDownloadUrl(BrowserPlatform.WIN64, 'stable_111.0.1'),
'https://archive.mozilla.org/pub/firefox/releases/111.0.1/win64/en-US/Firefox Setup 111.0.1.exe'
);
});
it('should resolve download URLs for devedition', () => {
assert.strictEqual(
resolveDownloadUrl(BrowserPlatform.LINUX, 'devedition_115.0b8'),
'https://archive.mozilla.org/pub/devedition/releases/115.0b8/linux-x86_64/en-US/firefox-115.0b8.tar.bz2'
);
assert.strictEqual(
resolveDownloadUrl(BrowserPlatform.MAC, 'devedition_115.0b8'),
'https://archive.mozilla.org/pub/devedition/releases/115.0b8/mac/en-US/Firefox 115.0b8.dmg'
);
assert.strictEqual(
resolveDownloadUrl(BrowserPlatform.MAC_ARM, 'devedition_115.0b8'),
'https://archive.mozilla.org/pub/devedition/releases/115.0b8/mac/en-US/Firefox 115.0b8.dmg'
);
assert.strictEqual(
resolveDownloadUrl(BrowserPlatform.WIN32, 'devedition_115.0b8'),
'https://archive.mozilla.org/pub/devedition/releases/115.0b8/win32/en-US/Firefox Setup 115.0b8.exe'
);
assert.strictEqual(
resolveDownloadUrl(BrowserPlatform.WIN64, 'devedition_115.0b8'),
'https://archive.mozilla.org/pub/devedition/releases/115.0b8/win64/en-US/Firefox Setup 115.0b8.exe'
);
});
it('should resolve executable paths', () => {
assert.strictEqual(
relativeExecutablePath(BrowserPlatform.LINUX, '111.0a1'),

View file

@ -6,6 +6,6 @@
export const testChromeBuildId = '121.0.6167.85';
export const testChromiumBuildId = '1083080';
export const testFirefoxBuildId = '125.0a1';
export const testFirefoxBuildId = '126.0a1';
export const testChromeDriverBuildId = '121.0.6167.85';
export const testChromeHeadlessShellBuildId = '121.0.6167.85';

View file

@ -1,5 +1,5 @@
# Ignore File that will be copied to Angular
/files/
# Ignore sandbox enviroment
# Ignore sandbox environment
./sandbox/

View file

@ -85,7 +85,7 @@ node tools/smoke.mjs
The schematics utilize `@angular-devkit/schematics/testing` for verifying correct file creation and `package.json` updates. To execute the test suit:
```bash
```bash npm2yarn
npm run test
```

View file

@ -20,6 +20,104 @@ All notable changes to this project will be documented in this file. See [standa
* dependencies
* @puppeteer/browsers bumped from 1.5.1 to 1.6.0
## [22.6.5](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v22.6.4...puppeteer-core-v22.6.5) (2024-04-15)
### Bug Fixes
* remove NetworkServiceInProcess2 set by default ([#12261](https://github.com/puppeteer/puppeteer/issues/12261)) ([ff4f70f](https://github.com/puppeteer/puppeteer/commit/ff4f70f4ae7ca8deb0becbec2e49b35322dba336)), closes [#12257](https://github.com/puppeteer/puppeteer/issues/12257)
* use setImmediate to reduce flakiness when processing events ([#12264](https://github.com/puppeteer/puppeteer/issues/12264)) ([73403b3](https://github.com/puppeteer/puppeteer/commit/73403b323ec0dd8a08c164cb2c07751451215788))
### Dependencies
* The following workspace dependencies were updated
* dependencies
* @puppeteer/browsers bumped from 2.2.1 to 2.2.2
## [22.6.4](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v22.6.3...puppeteer-core-v22.6.4) (2024-04-11)
### Bug Fixes
* **a11y:** query only unignored nodes ([#12224](https://github.com/puppeteer/puppeteer/issues/12224)) ([e20cd64](https://github.com/puppeteer/puppeteer/commit/e20cd64fff374c4113777912c193f4a5d7d04297))
* retain stale main frame for longer ([#12225](https://github.com/puppeteer/puppeteer/issues/12225)) ([aa5b182](https://github.com/puppeteer/puppeteer/commit/aa5b1824a5c82005fcfc05b002facfbbb9810f8f))
* roll to Chrome 123.0.6312.122 (r1262506) ([#12248](https://github.com/puppeteer/puppeteer/issues/12248)) ([50b6659](https://github.com/puppeteer/puppeteer/commit/50b66591e70a7b6907d86594d7dacee6e76afc2d))
* **webdriver:** suppress error for error code errors ([5f7254c](https://github.com/puppeteer/puppeteer/commit/5f7254c41c7c1bda82477488f10254d204373d54))
## [22.6.3](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v22.6.2...puppeteer-core-v22.6.3) (2024-04-05)
### Bug Fixes
* check if executablePath exists ([#12201](https://github.com/puppeteer/puppeteer/issues/12201)) ([4ec0280](https://github.com/puppeteer/puppeteer/commit/4ec02800801d441238d6160a933f88f98c5f7165))
* roll to Chrome 123.0.6312.105 (r1262506) ([#12209](https://github.com/puppeteer/puppeteer/issues/12209)) ([ee31272](https://github.com/puppeteer/puppeteer/commit/ee312721152cce61a9e9cb2b78b71b40c4fa9e64))
* wait for fonts before pdf printing ([#12175](https://github.com/puppeteer/puppeteer/issues/12175)) ([59bffce](https://github.com/puppeteer/puppeteer/commit/59bffce9720b4d5e5204b26b335735e0a5ca9cc1))
* **webdriver:** request redirect chain ([#12168](https://github.com/puppeteer/puppeteer/issues/12168)) ([d345055](https://github.com/puppeteer/puppeteer/commit/d345055af3c63effbdfb2751274b9d7137b8a308))
### Dependencies
* The following workspace dependencies were updated
* dependencies
* @puppeteer/browsers bumped from 2.2.0 to 2.2.1
## [22.6.2](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v22.6.1...puppeteer-core-v22.6.2) (2024-03-28)
### Bug Fixes
* roll to Chrome 123.0.6312.86 (r1262506) ([#12156](https://github.com/puppeteer/puppeteer/issues/12156)) ([29637f2](https://github.com/puppeteer/puppeteer/commit/29637f2b8f2dc1d684dbbb62d1a75857e016be33))
## [22.6.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v22.6.0...puppeteer-core-v22.6.1) (2024-03-25)
### Bug Fixes
* apply timeout to waiting for a response ([#12142](https://github.com/puppeteer/puppeteer/issues/12142)) ([ac1767d](https://github.com/puppeteer/puppeteer/commit/ac1767da0b4214ced548a62dd737e2863f92c715))
* reload should not resolve early on fragment navigations ([#12119](https://github.com/puppeteer/puppeteer/issues/12119)) ([d476031](https://github.com/puppeteer/puppeteer/commit/d4760317c9bd359c9ecdb5f36231449dae16a8d2))
* support clip in ElementHandle.screenshot ([#12115](https://github.com/puppeteer/puppeteer/issues/12115)) ([b096ffa](https://github.com/puppeteer/puppeteer/commit/b096ffaa0359078bd5748b53b67e87c9453c7196))
## [22.6.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v22.5.0...puppeteer-core-v22.6.0) (2024-03-20)
### Features
* roll to Chrome 123.0.6312.58 (r1262506) ([#12110](https://github.com/puppeteer/puppeteer/issues/12110)) ([6f5b3bc](https://github.com/puppeteer/puppeteer/commit/6f5b3bc9b88c6d3204dda396f8963591ea6eb883))
### Bug Fixes
* **webdriver:** emit RequestServedFromCache for requests ([#12104](https://github.com/puppeteer/puppeteer/issues/12104)) ([6ba6bef](https://github.com/puppeteer/puppeteer/commit/6ba6bef1b99742543942cef2f6c840bd543f5dee))
## [22.5.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v22.4.1...puppeteer-core-v22.5.0) (2024-03-15)
### Features
* deprecate `Frame.prototype.name` ([#12084](https://github.com/puppeteer/puppeteer/issues/12084)) ([0203b45](https://github.com/puppeteer/puppeteer/commit/0203b4533dfec503f9ce7fcd07c3493021a9cecb))
### Bug Fixes
* fix keyboard.sendCharacter ([#12088](https://github.com/puppeteer/puppeteer/issues/12088)) ([2637622](https://github.com/puppeteer/puppeteer/commit/26376224d557ce30c911f670c5e7625dd1a1df72))
* roll to Chrome 122.0.6261.128 (r1250580) ([#12078](https://github.com/puppeteer/puppeteer/issues/12078)) ([ef7a9ea](https://github.com/puppeteer/puppeteer/commit/ef7a9eac16dcb466b220bcb0bc06a1eac3492354))
* **webdriver:** emit CDP events ([#12058](https://github.com/puppeteer/puppeteer/issues/12058)) ([9afe424](https://github.com/puppeteer/puppeteer/commit/9afe4246bb58c30a13215a254f9326935b24ece3))
### Dependencies
* The following workspace dependencies were updated
* dependencies
* @puppeteer/browsers bumped from 2.1.0 to 2.2.0
## [22.4.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v22.4.0...puppeteer-core-v22.4.1) (2024-03-08)
### Bug Fixes
* roll to Chrome 122.0.6261.111 (r1250580) ([#12055](https://github.com/puppeteer/puppeteer/issues/12055)) ([9b31bca](https://github.com/puppeteer/puppeteer/commit/9b31bca01adeb2ce04c97d9fcb3c6b6461469f07))
## [22.4.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v22.3.0...puppeteer-core-v22.4.0) (2024-03-05)

View file

@ -110,8 +110,10 @@ export const buildTask = task({
bundle: true,
allowOverwrite: true,
format,
target: 'node16',
minify: true,
target: 'node18',
// Do not minify for readability and leave minification to
// consumers.
minify: false,
legalComments: 'inline',
})
);

View file

@ -1,6 +1,6 @@
{
"name": "puppeteer-core",
"version": "22.4.0",
"version": "22.6.5",
"description": "A high-level API to control headless Chrome over the DevTools Protocol",
"keywords": [
"puppeteer",
@ -119,11 +119,10 @@
"author": "The Chromium Authors",
"license": "Apache-2.0",
"dependencies": {
"@puppeteer/browsers": "2.1.0",
"chromium-bidi": "0.5.12",
"cross-fetch": "4.0.0",
"@puppeteer/browsers": "2.2.2",
"chromium-bidi": "0.5.17",
"debug": "4.3.4",
"devtools-protocol": "0.0.1249869",
"devtools-protocol": "0.0.1262051",
"ws": "8.16.0"
},
"devDependencies": {

View file

@ -197,7 +197,7 @@ export interface DebugInfo {
* - connected to via {@link Puppeteer.connect} or
* - launched by {@link PuppeteerNode.launch}.
*
* {@link Browser} {@link EventEmitter | emits} various events which are
* {@link Browser} {@link EventEmitter.emit | emits} various events which are
* documented in the {@link BrowserEvent} enum.
*
* @example Using a {@link Browser} to create a {@link Page}:

View file

@ -151,7 +151,7 @@ export abstract class BrowserContext extends EventEmitter<BrowserContextEvents>
*
* @deprecated In Chrome, the
* {@link Browser.defaultBrowserContext | default browser context} can also be
* "icognito" if configured via the arguments and in such cases this getter
* "incognito" if configured via the arguments and in such cases this getter
* returns wrong results (see
* https://github.com/puppeteer/puppeteer/issues/8836). Also, the term
* "incognito" is not applicable to other browsers. To migrate, check the

View file

@ -74,7 +74,7 @@ export interface CommandOptions {
* @example
*
* ```ts
* const client = await page.target().createCDPSession();
* const client = await page.createCDPSession();
* await client.send('Animation.enable');
* client.on('Animation.animationCreated', () =>
* console.log('Animation created!')

View file

@ -623,7 +623,7 @@ export abstract class ElementHandle<
/**
* This method scrolls element into view if needed, and then
* uses {@link Page} to hover over the center of the element.
* uses {@link Page.mouse} to hover over the center of the element.
* If the element is detached from DOM, the method throws an error.
*/
@throwIfDisposed()
@ -636,7 +636,7 @@ export abstract class ElementHandle<
/**
* This method scrolls element into view if needed, and then
* uses {@link Page | Page.mouse} to click in the center of the element.
* uses {@link Page.mouse} to click in the center of the element.
* If the element is detached from DOM, the method throws an error.
*/
@throwIfDisposed()
@ -1236,9 +1236,9 @@ export abstract class ElementHandle<
this: ElementHandle<Element>,
options: Readonly<ElementScreenshotOptions> = {}
): Promise<string | Buffer> {
const {scrollIntoView = true} = options;
const {scrollIntoView = true, clip} = options;
let clip = await this.#nonEmptyVisibleBoundingBox();
let elementClip = await this.#nonEmptyVisibleBoundingBox();
const page = this.frame.page();
@ -1247,7 +1247,7 @@ export abstract class ElementHandle<
await this.scrollIntoViewIfNeeded();
// We measure again just in case.
clip = await this.#nonEmptyVisibleBoundingBox();
elementClip = await this.#nonEmptyVisibleBoundingBox();
}
const [pageLeft, pageTop] = await this.evaluate(() => {
@ -1259,10 +1259,16 @@ export abstract class ElementHandle<
window.visualViewport.pageTop,
] as const;
});
clip.x += pageLeft;
clip.y += pageTop;
elementClip.x += pageLeft;
elementClip.y += pageTop;
if (clip) {
elementClip.x += clip.x;
elementClip.y += clip.y;
elementClip.height = clip.height;
elementClip.width = clip.width;
}
return await page.screenshot({...options, clip});
return await page.screenshot({...options, clip: elementClip});
}
async #nonEmptyVisibleBoundingBox() {

View file

@ -18,7 +18,6 @@ import type {PuppeteerLifeCycleEvent} from '../cdp/LifecycleWatcher.js';
import {EventEmitter, type EventType} from '../common/EventEmitter.js';
import {getQueryHandlerAndSelector} from '../common/GetQueryHandler.js';
import {transposeIterableHandle} from '../common/HandleIterator.js';
import {LazyArg} from '../common/LazyArg.js';
import type {
Awaitable,
EvaluateFunc,
@ -63,6 +62,10 @@ export interface WaitForOptions {
* @defaultValue `'load'`
*/
waitUntil?: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[];
/**
* @internal
*/
ignoreSameDocumentNavigation?: boolean;
}
/**
@ -405,7 +408,7 @@ export abstract class Frame extends EventEmitter<FrameEvents> {
}
/**
* @internal
* @returns The frame element associated with this frame (if any).
*/
@throwIfDetached
async frameElement(): Promise<HandleFor<HTMLIFrameElement> | null> {
@ -447,7 +450,7 @@ export abstract class Frame extends EventEmitter<FrameEvents> {
}
/**
* Behaves identically to {@link Page.evaluate} except it's run within the
* Behaves identically to {@link Page.evaluate} except it's run within
* the context of this frame.
*
* @see {@link Page.evaluate} for details.
@ -760,6 +763,13 @@ export abstract class Frame extends EventEmitter<FrameEvents> {
* @remarks
* This value is calculated once when the frame is created, and will not
* update if the attribute is changed later.
*
* @deprecated Use
*
* ```ts
* const element = await frame.frameElement();
* const nameOrId = await element.evaluate(frame => frame.name ?? frame.id);
* ```
*/
name(): string {
return this._name || '';
@ -830,42 +840,37 @@ export abstract class Frame extends EventEmitter<FrameEvents> {
return await this.mainRealm().transferHandle(
await this.isolatedRealm().evaluateHandle(
async ({Deferred}, {url, id, type, content}) => {
const deferred = Deferred.create<void>();
const script = document.createElement('script');
script.type = type;
script.text = content;
if (url) {
script.src = url;
script.addEventListener(
'load',
() => {
return deferred.resolve();
},
{once: true}
);
async ({url, id, type, content}) => {
return await new Promise<HTMLScriptElement>((resolve, reject) => {
const script = document.createElement('script');
script.type = type;
script.text = content;
script.addEventListener(
'error',
event => {
deferred.reject(
new Error(event.message ?? 'Could not load script')
);
reject(new Error(event.message ?? 'Could not load script'));
},
{once: true}
);
} else {
deferred.resolve();
}
if (id) {
script.id = id;
}
document.head.appendChild(script);
await deferred.valueOrThrow();
return script;
if (id) {
script.id = id;
}
if (url) {
script.src = url;
script.addEventListener(
'load',
() => {
resolve(script);
},
{once: true}
);
document.head.appendChild(script);
} else {
document.head.appendChild(script);
resolve(script);
}
});
},
LazyArg.create(context => {
return context.puppeteerUtil;
}),
{...options, type, content}
)
);
@ -915,46 +920,42 @@ export abstract class Frame extends EventEmitter<FrameEvents> {
}
return await this.mainRealm().transferHandle(
await this.isolatedRealm().evaluateHandle(
async ({Deferred}, {url, content}) => {
const deferred = Deferred.create<void>();
let element: HTMLStyleElement | HTMLLinkElement;
if (!url) {
element = document.createElement('style');
element.appendChild(document.createTextNode(content!));
} else {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = url;
element = link;
await this.isolatedRealm().evaluateHandle(async ({url, content}) => {
return await new Promise<HTMLStyleElement | HTMLLinkElement>(
(resolve, reject) => {
let element: HTMLStyleElement | HTMLLinkElement;
if (!url) {
element = document.createElement('style');
element.appendChild(document.createTextNode(content!));
} else {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = url;
element = link;
}
element.addEventListener(
'load',
() => {
resolve(element);
},
{once: true}
);
element.addEventListener(
'error',
event => {
reject(
new Error(
(event as ErrorEvent).message ?? 'Could not load style'
)
);
},
{once: true}
);
document.head.appendChild(element);
return element;
}
element.addEventListener(
'load',
() => {
deferred.resolve();
},
{once: true}
);
element.addEventListener(
'error',
event => {
deferred.reject(
new Error(
(event as ErrorEvent).message ?? 'Could not load style'
)
);
},
{once: true}
);
document.head.appendChild(element);
await deferred.valueOrThrow();
return element;
},
LazyArg.create(context => {
return context.puppeteerUtil;
}),
options
)
);
}, options)
);
}

View file

@ -5,6 +5,10 @@
*/
import type {Protocol} from 'devtools-protocol';
import type {ProtocolError} from '../common/Errors.js';
import {debugError} from '../common/util.js';
import {assert} from '../util/assert.js';
import type {CDPSession} from './CDPSession.js';
import type {Frame} from './Frame.js';
import type {HTTPResponse} from './HTTPResponse.js';
@ -117,6 +121,29 @@ export abstract class HTTPRequest {
*/
_redirectChain: HTTPRequest[] = [];
/**
* @internal
*/
protected interception: {
enabled: boolean;
handled: boolean;
handlers: Array<() => void | PromiseLike<any>>;
resolutionState: InterceptResolutionState;
requestOverrides: ContinueRequestOverrides;
response: Partial<ResponseForRequest> | null;
abortReason: Protocol.Network.ErrorReason | null;
} = {
enabled: false,
handled: false,
handlers: [],
resolutionState: {
action: InterceptResolutionAction.None,
},
requestOverrides: {},
response: null,
abortReason: null,
};
/**
* Warning! Using this client can break Puppeteer. Use with caution.
*
@ -139,18 +166,27 @@ export abstract class HTTPRequest {
* if the interception is allowed to continue (ie, `abort()` and
* `respond()` aren't called).
*/
abstract continueRequestOverrides(): ContinueRequestOverrides;
continueRequestOverrides(): ContinueRequestOverrides {
assert(this.interception.enabled, 'Request Interception is not enabled!');
return this.interception.requestOverrides;
}
/**
* The `ResponseForRequest` that gets used if the
* interception is allowed to respond (ie, `abort()` is not called).
*/
abstract responseForRequest(): Partial<ResponseForRequest> | null;
responseForRequest(): Partial<ResponseForRequest> | null {
assert(this.interception.enabled, 'Request Interception is not enabled!');
return this.interception.response;
}
/**
* The most recent reason for aborting the request
*/
abstract abortErrorReason(): Protocol.Network.ErrorReason | null;
abortErrorReason(): Protocol.Network.ErrorReason | null {
assert(this.interception.enabled, 'Request Interception is not enabled!');
return this.interception.abortReason;
}
/**
* An InterceptResolutionState object describing the current resolution
@ -163,13 +199,23 @@ export abstract class HTTPRequest {
* InterceptResolutionAction is one of: `abort`, `respond`, `continue`,
* `disabled`, `none`, or `already-handled`.
*/
abstract interceptResolutionState(): InterceptResolutionState;
interceptResolutionState(): InterceptResolutionState {
if (!this.interception.enabled) {
return {action: InterceptResolutionAction.Disabled};
}
if (this.interception.handled) {
return {action: InterceptResolutionAction.AlreadyHandled};
}
return {...this.interception.resolutionState};
}
/**
* Is `true` if the intercept resolution has already been handled,
* `false` otherwise.
*/
abstract isInterceptResolutionHandled(): boolean;
isInterceptResolutionHandled(): boolean {
return this.interception.handled;
}
/**
* Adds an async request handler to the processing queue.
@ -177,15 +223,51 @@ export abstract class HTTPRequest {
* but they are guaranteed to resolve before the request interception
* is finalized.
*/
abstract enqueueInterceptAction(
enqueueInterceptAction(
pendingHandler: () => void | PromiseLike<unknown>
): void;
): void {
this.interception.handlers.push(pendingHandler);
}
/**
* @internal
*/
abstract _abort(
errorReason: Protocol.Network.ErrorReason | null
): Promise<void>;
/**
* @internal
*/
abstract _respond(response: Partial<ResponseForRequest>): Promise<void>;
/**
* @internal
*/
abstract _continue(overrides: ContinueRequestOverrides): Promise<void>;
/**
* Awaits pending interception handlers and then decides how to fulfill
* the request interception.
*/
abstract finalizeInterceptions(): Promise<void>;
async finalizeInterceptions(): Promise<void> {
await this.interception.handlers.reduce((promiseChain, interceptAction) => {
return promiseChain.then(interceptAction);
}, Promise.resolve());
this.interception.handlers = []; // TODO: verify this is correct top let gc run
const {action} = this.interceptResolutionState();
switch (action) {
case 'abort':
return await this._abort(this.interception.abortReason);
case 'respond':
if (this.interception.response === null) {
throw new Error('Response is missing for the interception');
}
return await this._respond(this.interception.response);
case 'continue':
return await this._continue(this.interception.requestOverrides);
}
}
/**
* Contains the request's resource type as it was perceived by the rendering
@ -323,10 +405,42 @@ export abstract class HTTPRequest {
*
* Exception is immediately thrown if the request interception is not enabled.
*/
abstract continue(
overrides?: ContinueRequestOverrides,
async continue(
overrides: ContinueRequestOverrides = {},
priority?: number
): Promise<void>;
): Promise<void> {
// Request interception is not supported for data: urls.
if (this.url().startsWith('data:')) {
return;
}
assert(this.interception.enabled, 'Request Interception is not enabled!');
assert(!this.interception.handled, 'Request is already handled!');
if (priority === undefined) {
return await this._continue(overrides);
}
this.interception.requestOverrides = overrides;
if (
this.interception.resolutionState.priority === undefined ||
priority > this.interception.resolutionState.priority
) {
this.interception.resolutionState = {
action: InterceptResolutionAction.Continue,
priority,
};
return;
}
if (priority === this.interception.resolutionState.priority) {
if (
this.interception.resolutionState.action === 'abort' ||
this.interception.resolutionState.action === 'respond'
) {
return;
}
this.interception.resolutionState.action =
InterceptResolutionAction.Continue;
}
return;
}
/**
* Fulfills a request with the given response.
@ -360,10 +474,38 @@ export abstract class HTTPRequest {
*
* Exception is immediately thrown if the request interception is not enabled.
*/
abstract respond(
async respond(
response: Partial<ResponseForRequest>,
priority?: number
): Promise<void>;
): Promise<void> {
// Mocking responses for dataURL requests is not currently supported.
if (this.url().startsWith('data:')) {
return;
}
assert(this.interception.enabled, 'Request Interception is not enabled!');
assert(!this.interception.handled, 'Request is already handled!');
if (priority === undefined) {
return await this._respond(response);
}
this.interception.response = response;
if (
this.interception.resolutionState.priority === undefined ||
priority > this.interception.resolutionState.priority
) {
this.interception.resolutionState = {
action: InterceptResolutionAction.Respond,
priority,
};
return;
}
if (priority === this.interception.resolutionState.priority) {
if (this.interception.resolutionState.action === 'abort') {
return;
}
this.interception.resolutionState.action =
InterceptResolutionAction.Respond;
}
}
/**
* Aborts a request.
@ -379,7 +521,33 @@ export abstract class HTTPRequest {
* {@link Page.setRequestInterception}. If it is not enabled, this method will
* throw an exception immediately.
*/
abstract abort(errorCode?: ErrorCode, priority?: number): Promise<void>;
async abort(
errorCode: ErrorCode = 'failed',
priority?: number
): 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.interception.enabled, 'Request Interception is not enabled!');
assert(!this.interception.handled, 'Request is already handled!');
if (priority === undefined) {
return await this._abort(errorReason);
}
this.interception.abortReason = errorReason;
if (
this.interception.resolutionState.priority === undefined ||
priority >= this.interception.resolutionState.priority
) {
this.interception.resolutionState = {
action: InterceptResolutionAction.Abort,
priority,
};
return;
}
}
}
/**
@ -513,3 +681,33 @@ export const STATUS_TEXTS: Record<string, string> = {
'510': 'Not Extended',
'511': 'Network Authentication Required',
} as const;
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;
/**
* @internal
*/
export function handleError(error: ProtocolError): void {
if (error.originalMessage.includes('Invalid header')) {
throw 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);
}

View file

@ -81,11 +81,18 @@ export abstract class HTTPResponse {
/**
* Promise which resolves to a buffer with response body.
*
* @remarks
*
* The buffer might be re-encoded by the browser
* based on HTTP-headers or other heuristics. If the browser
* failed to detect the correct encoding, the buffer might
* be encoded incorrectly. See https://github.com/puppeteer/puppeteer/issues/6478.
*/
abstract buffer(): Promise<Buffer>;
/**
* Promise which resolves to a text representation of response body.
* Promise which resolves to a text (utf8) representation of response body.
*/
async text(): Promise<string> {
const content = await this.buffer();

View file

@ -32,7 +32,7 @@ import type {HTTPResponse} from '../api/HTTPResponse.js';
import type {Accessibility} from '../cdp/Accessibility.js';
import type {Coverage} from '../cdp/Coverage.js';
import type {DeviceRequestPrompt} from '../cdp/DeviceRequestPrompt.js';
import type {Credentials, NetworkConditions} from '../cdp/NetworkManager.js';
import type {NetworkConditions} from '../cdp/NetworkManager.js';
import type {Tracing} from '../cdp/Tracing.js';
import type {ConsoleMessage} from '../common/ConsoleMessage.js';
import type {
@ -131,6 +131,14 @@ export interface Metrics {
JSHeapTotalSize?: number;
}
/**
* @public
*/
export interface Credentials {
username: string;
password: string;
}
/**
* @public
*/
@ -274,7 +282,7 @@ export interface ScreenshotOptions {
*/
path?: string;
/**
* Specifies the region of the page to clip.
* Specifies the region of the page/element to clip.
*/
clip?: ScreenshotClip;
/**
@ -644,7 +652,7 @@ export abstract class Page extends EventEmitter<PageEvents> {
*
* @deprecated We no longer support intercepting drag payloads. Use the new
* drag APIs found on {@link ElementHandle} to drag (or just use the
* {@link Page | Page.mouse}).
* {@link Page.mouse}).
*/
abstract isDragInterceptionEnabled(): boolean;
@ -875,7 +883,7 @@ export abstract class Page extends EventEmitter<PageEvents> {
*
* @deprecated We no longer support intercepting drag payloads. Use the new
* drag APIs found on {@link ElementHandle} to drag (or just use the
* {@link Page | Page.mouse}).
* {@link Page.mouse}).
*/
abstract setDragInterception(enabled: boolean): Promise<void>;
@ -1342,7 +1350,7 @@ export abstract class Page extends EventEmitter<PageEvents> {
*
* Functions installed via `page.exposeFunction` survive navigations.
*
* :::note
* :::
*
* @example
* An example of adding an `md5` function into the page:
@ -2116,7 +2124,8 @@ export abstract class Page extends EventEmitter<PageEvents> {
*
* This is either the viewport set with the previous {@link Page.setViewport}
* call or the default viewport set via
* {@link BrowserConnectOptions | BrowserConnectOptions.defaultViewport}.
* {@link BrowserConnectOptions.defaultViewport |
* BrowserConnectOptions.defaultViewport}.
*/
abstract viewport(): Viewport | null;
@ -2458,7 +2467,7 @@ export abstract class Page extends EventEmitter<PageEvents> {
};
if (options.type === undefined && options.path !== undefined) {
const filePath = options.path;
// Note we cannot use Node.js here due to browser compatability.
// Note we cannot use Node.js here due to browser compatibility.
const extension = filePath
.slice(filePath.lastIndexOf('.') + 1)
.toLowerCase();
@ -2609,7 +2618,7 @@ export abstract class Page extends EventEmitter<PageEvents> {
/**
* This method fetches an element with `selector`, scrolls it into view if
* needed, and then uses {@link Page | Page.mouse} to click in the center of the
* 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.
*
@ -2660,7 +2669,7 @@ export abstract class Page extends EventEmitter<PageEvents> {
/**
* This method fetches an element with `selector`, scrolls it into view if
* needed, and then uses {@link Page | Page.mouse}
* needed, and then uses {@link Page.mouse}
* to hover over the center of the element.
* If there's no element matching `selector`, the method throws an error.
* @param selector - A
@ -2709,7 +2718,7 @@ export abstract class Page extends EventEmitter<PageEvents> {
/**
* This method fetches an element with `selector`, scrolls it into view if
* needed, and then uses {@link Page | Page.touchscreen}
* needed, and then uses {@link Page.touchscreen}
* to tap in the center of the element.
* If there's no element matching `selector`, the method throws an error.
* @param selector - A

View file

@ -172,19 +172,23 @@ export abstract class Locator<T> extends EventEmitter<LocatorEvents> {
});
},
retryAndRaceWithSignalAndTimer: <T>(
signal?: AbortSignal
signal?: AbortSignal,
cause?: Error
): OperatorFunction<T, T> => {
const candidates = [];
if (signal) {
candidates.push(
fromEvent(signal, 'abort').pipe(
map(() => {
if (signal.reason instanceof Error) {
signal.reason.cause = cause;
}
throw signal.reason;
})
)
);
}
candidates.push(timeout(this._timeout));
candidates.push(timeout(this._timeout, cause));
return pipe(
retry({delay: RETRY_DELAY}),
raceWith<T, never[]>(...candidates)
@ -368,6 +372,7 @@ export abstract class Locator<T> extends EventEmitter<LocatorEvents> {
options?: Readonly<LocatorClickOptions>
): Observable<void> {
const signal = options?.signal;
const cause = new Error('Locator.click');
return this._wait(options).pipe(
this.operators.conditions(
[
@ -388,7 +393,7 @@ export abstract class Locator<T> extends EventEmitter<LocatorEvents> {
})
);
}),
this.operators.retryAndRaceWithSignalAndTimer(signal)
this.operators.retryAndRaceWithSignalAndTimer(signal, cause)
);
}
@ -398,6 +403,7 @@ export abstract class Locator<T> extends EventEmitter<LocatorEvents> {
options?: Readonly<ActionOptions>
): Observable<void> {
const signal = options?.signal;
const cause = new Error('Locator.fill');
return this._wait(options).pipe(
this.operators.conditions(
[
@ -521,7 +527,7 @@ export abstract class Locator<T> extends EventEmitter<LocatorEvents> {
})
);
}),
this.operators.retryAndRaceWithSignalAndTimer(signal)
this.operators.retryAndRaceWithSignalAndTimer(signal, cause)
);
}
@ -530,6 +536,7 @@ export abstract class Locator<T> extends EventEmitter<LocatorEvents> {
options?: Readonly<ActionOptions>
): Observable<void> {
const signal = options?.signal;
const cause = new Error('Locator.hover');
return this._wait(options).pipe(
this.operators.conditions(
[
@ -549,7 +556,7 @@ export abstract class Locator<T> extends EventEmitter<LocatorEvents> {
})
);
}),
this.operators.retryAndRaceWithSignalAndTimer(signal)
this.operators.retryAndRaceWithSignalAndTimer(signal, cause)
);
}
@ -558,6 +565,7 @@ export abstract class Locator<T> extends EventEmitter<LocatorEvents> {
options?: Readonly<LocatorScrollOptions>
): Observable<void> {
const signal = options?.signal;
const cause = new Error('Locator.scroll');
return this._wait(options).pipe(
this.operators.conditions(
[
@ -590,7 +598,7 @@ export abstract class Locator<T> extends EventEmitter<LocatorEvents> {
})
);
}),
this.operators.retryAndRaceWithSignalAndTimer(signal)
this.operators.retryAndRaceWithSignalAndTimer(signal, cause)
);
}
@ -617,9 +625,10 @@ export abstract class Locator<T> extends EventEmitter<LocatorEvents> {
* @public
*/
async waitHandle(options?: Readonly<ActionOptions>): Promise<HandleFor<T>> {
const cause = new Error('Locator.waitHandle');
return await firstValueFrom(
this._wait(options).pipe(
this.operators.retryAndRaceWithSignalAndTimer(options?.signal)
this.operators.retryAndRaceWithSignalAndTimer(options?.signal, cause)
)
);
}

View file

@ -25,9 +25,7 @@ const bidiServerLogger = (prefix: string, ...args: unknown[]): void => {
*/
export async function connectBidiOverCdp(
cdp: CdpConnection,
// TODO: replace with `BidiMapper.MapperOptions`, once it's exported in
// https://github.com/puppeteer/puppeteer/pull/11415.
options: {acceptInsecureCerts: boolean}
options: BidiMapper.MapperOptions
): Promise<BidiConnection> {
const transportBiDi = new NoOpTransport();
const cdpConnectionAdapter = new CdpConnectionAdapter(cdp);

View file

@ -19,7 +19,6 @@ import {
import {BrowserContextEvent} from '../api/BrowserContext.js';
import type {Page} from '../api/Page.js';
import type {Target} from '../api/Target.js';
import {UnsupportedOperation} from '../common/Errors.js';
import {EventEmitter} from '../common/EventEmitter.js';
import {debugError} from '../common/util.js';
import type {Viewport} from '../common/Viewport.js';
@ -50,7 +49,7 @@ export class BidiBrowser extends Browser {
readonly protocol = 'webDriverBiDi';
// TODO: Update generator to include fully module
static readonly subscribeModules: string[] = [
static readonly subscribeModules: [string, ...string[]] = [
'browsingContext',
'network',
'log',
@ -133,8 +132,8 @@ export class BidiBrowser extends Browser {
return !this.#browserName.toLocaleLowerCase().includes('firefox');
}
override userAgent(): never {
throw new UnsupportedOperation();
override async userAgent(): Promise<string> {
return this.#browserCore.session.capabilities.userAgent;
}
#createBrowserContext(userContext: UserContext) {

View file

@ -5,6 +5,7 @@
*/
import type ProtocolMapping from 'devtools-protocol/types/protocol-mapping.js';
import type {CommandOptions} from '../api/CDPSession.js';
import {CDPSession} from '../api/CDPSession.js';
import type {Connection as CdpConnection} from '../cdp/Connection.js';
import {TargetCloseError, UnsupportedOperation} from '../common/Errors.js';
@ -61,7 +62,8 @@ export class BidiCdpSession extends CDPSession {
override async send<T extends keyof ProtocolMapping.Commands>(
method: T,
params?: ProtocolMapping.Commands[T]['paramsType'][0]
params?: ProtocolMapping.Commands[T]['paramsType'][0],
options?: CommandOptions
): Promise<ProtocolMapping.Commands[T]['returnType']> {
if (this.#connection === undefined) {
throw new UnsupportedOperation(
@ -74,11 +76,15 @@ export class BidiCdpSession extends CDPSession {
);
}
const session = await this.#sessionId.valueOrThrow();
const {result} = await this.#connection.send('cdp.sendCommand', {
method: method,
params: params,
session,
});
const {result} = await this.#connection.send(
'cdp.sendCommand',
{
method: method,
params: params,
session,
},
options?.timeout
);
return result.result;
}

View file

@ -97,11 +97,12 @@ export class BidiConnection
send<T extends keyof Commands>(
method: T,
params: Commands[T]['params']
params: Commands[T]['params'],
timeout?: number
): Promise<{result: Commands[T]['returnType']}> {
assert(!this.#closed, 'Protocol error: Connection closed.');
return this.#callbacks.create(method, this.#timeout, id => {
return this.#callbacks.create(method, timeout ?? this.#timeout, id => {
const stringifiedMessage = JSON.stringify({
id,
method,

View file

@ -37,6 +37,7 @@ import {TargetCloseError, UnsupportedOperation} from '../common/Errors.js';
import type {TimeoutSettings} from '../common/TimeoutSettings.js';
import type {Awaitable, NodeFor} from '../common/types.js';
import {debugError, fromEmitterEvent, timeout} from '../common/util.js';
import {isErrorLike} from '../util/ErrorLike.js';
import {BidiCdpSession} from './CDPSession.js';
import type {BrowsingContext} from './core/BrowsingContext.js';
@ -114,13 +115,13 @@ export class BidiFrame extends Frame {
this.browsingContext.on('request', ({request}) => {
const httpRequest = BidiHTTPRequest.from(request, this);
request.once('success', () => {
// SAFETY: BidiHTTPRequest will create this before here.
this.page().trustedEmitter.emit(PageEvent.RequestFinished, httpRequest);
});
request.once('error', () => {
this.page().trustedEmitter.emit(PageEvent.RequestFailed, httpRequest);
});
void httpRequest.finalizeInterceptions();
});
this.browsingContext.on('navigation', ({navigation}) => {
@ -300,10 +301,18 @@ export class BidiFrame extends Frame {
// readiness=interactive.
//
// Related: https://bugzilla.mozilla.org/show_bug.cgi?id=1846601
this.browsingContext.navigate(
url,
Bidi.BrowsingContext.ReadinessState.Interactive
),
this.browsingContext
.navigate(url, Bidi.BrowsingContext.ReadinessState.Interactive)
.catch(error => {
if (
isErrorLike(error) &&
error.message.includes('net::ERR_HTTP_RESPONSE_CODE_FAILURE')
) {
return;
}
throw error;
}),
]).catch(
rewriteNavigationError(
url,
@ -351,11 +360,7 @@ export class BidiFrame extends Frame {
}),
raceWith(
fromEmitterEvent(navigation, 'fragment'),
fromEmitterEvent(navigation, 'failed').pipe(
map(({url}) => {
throw new Error(`Navigation failed: ${url}`);
})
),
fromEmitterEvent(navigation, 'failed'),
fromEmitterEvent(navigation, 'aborted').pipe(
map(({url}) => {
throw new Error(`Navigation aborted: ${url}`);
@ -401,11 +406,9 @@ export class BidiFrame extends Frame {
if (!request) {
return null;
}
const httpRequest = requests.get(request)!;
const lastRedirect = httpRequest.redirectChain().at(-1);
return (
lastRedirect !== undefined ? lastRedirect : httpRequest
).response();
const lastRequest = request.lastRedirect ?? request;
const httpRequest = requests.get(lastRequest)!;
return httpRequest.response();
}),
raceWith(
timeout(ms),
@ -471,6 +474,7 @@ export class BidiFrame extends Frame {
targetId: this._id,
flatten: true,
});
await this.browsingContext.subscribe([Bidi.ChromiumBidi.BiDiModule.Cdp]);
return new BidiCdpSession(this, sessionId);
}

View file

@ -10,7 +10,12 @@ import type {
ContinueRequestOverrides,
ResponseForRequest,
} from '../api/HTTPRequest.js';
import {HTTPRequest, type ResourceType} from '../api/HTTPRequest.js';
import {
HTTPRequest,
STATUS_TEXTS,
type ResourceType,
handleError,
} from '../api/HTTPRequest.js';
import {PageEvent} from '../api/Page.js';
import {UnsupportedOperation} from '../common/Errors.js';
@ -26,41 +31,61 @@ export const requests = new WeakMap<Request, BidiHTTPRequest>();
export class BidiHTTPRequest extends HTTPRequest {
static from(
bidiRequest: Request,
frame: BidiFrame | undefined
frame: BidiFrame,
redirect?: BidiHTTPRequest
): BidiHTTPRequest {
const request = new BidiHTTPRequest(bidiRequest, frame);
const request = new BidiHTTPRequest(bidiRequest, frame, redirect);
request.#initialize();
return request;
}
#redirect: BidiHTTPRequest | undefined;
#redirectBy: BidiHTTPRequest | undefined;
#response: BidiHTTPResponse | null = null;
override readonly id: string;
readonly #frame: BidiFrame | undefined;
readonly #frame: BidiFrame;
readonly #request: Request;
private constructor(request: Request, frame: BidiFrame | undefined) {
private constructor(
request: Request,
frame: BidiFrame,
redirectBy?: BidiHTTPRequest
) {
super();
requests.set(request, this);
this.interception.enabled = request.isBlocked;
this.#request = request;
this.#frame = frame;
this.#redirectBy = redirectBy;
this.id = request.id;
}
override get client(): CDPSession {
throw new UnsupportedOperation();
return this.#frame.client;
}
#initialize() {
this.#request.on('redirect', request => {
this.#redirect = BidiHTTPRequest.from(request, this.#frame);
const httpRequest = BidiHTTPRequest.from(request, this.#frame, this);
void httpRequest.finalizeInterceptions();
});
this.#request.once('success', data => {
this.#response = BidiHTTPResponse.from(data, this);
});
this.#request.on('authenticate', this.#handleAuthentication);
this.#frame?.page().trustedEmitter.emit(PageEvent.Request, this);
this.#frame.page().trustedEmitter.emit(PageEvent.Request, this);
if (Object.keys(this.#extraHTTPHeaders).length) {
this.interception.handlers.push(async () => {
await this.continue(
{
headers: this.headers(),
},
0
);
});
}
}
override url(): string {
@ -68,7 +93,7 @@ export class BidiHTTPRequest extends HTTPRequest {
}
override resourceType(): ResourceType {
return this.initiator().type.toLowerCase() as ResourceType;
throw new UnsupportedOperation();
}
override method(): string {
@ -87,12 +112,19 @@ export class BidiHTTPRequest extends HTTPRequest {
throw new UnsupportedOperation();
}
get #extraHTTPHeaders(): Record<string, string> {
return this.#frame?.page()._extraHTTPHeaders ?? {};
}
override headers(): Record<string, string> {
const headers: Record<string, string> = {};
for (const header of this.#request.headers) {
headers[header.name.toLowerCase()] = header.value.value;
}
return headers;
return {
...headers,
...this.#extraHTTPHeaders,
};
}
override response(): BidiHTTPResponse | null {
@ -115,65 +147,167 @@ export class BidiHTTPRequest extends HTTPRequest {
}
override redirectChain(): BidiHTTPRequest[] {
if (this.#redirect === undefined) {
if (this.#redirectBy === undefined) {
return [];
}
const redirects = [this.#redirect];
const redirects = [this.#redirectBy];
for (const redirect of redirects) {
if (redirect.#redirect !== undefined) {
redirects.push(redirect.#redirect);
if (redirect.#redirectBy !== undefined) {
redirects.push(redirect.#redirectBy);
}
}
return redirects;
}
override enqueueInterceptAction(
pendingHandler: () => void | PromiseLike<unknown>
): void {
// Execute the handler when interception is not supported
void pendingHandler();
override frame(): BidiFrame {
return this.#frame;
}
override frame(): BidiFrame | null {
return this.#frame ?? null;
override async continue(
overrides?: ContinueRequestOverrides,
priority?: number | undefined
): Promise<void> {
return await super.continue(
{
headers: Object.keys(this.#extraHTTPHeaders).length
? this.headers()
: undefined,
...overrides,
},
priority
);
}
override continueRequestOverrides(): never {
throw new UnsupportedOperation();
override async _continue(
overrides: ContinueRequestOverrides = {}
): Promise<void> {
const headers: Bidi.Network.Header[] = getBidiHeaders(overrides.headers);
this.interception.handled = true;
return await this.#request
.continueRequest({
url: overrides.url,
method: overrides.method,
body: overrides.postData
? {
type: 'base64',
value: btoa(overrides.postData),
}
: undefined,
headers: headers.length > 0 ? headers : undefined,
})
.catch(error => {
this.interception.handled = false;
return handleError(error);
});
}
override continue(_overrides: ContinueRequestOverrides = {}): never {
throw new UnsupportedOperation();
override async _abort(): Promise<void> {
this.interception.handled = true;
return await this.#request.failRequest().catch(error => {
this.interception.handled = false;
throw error;
});
}
override responseForRequest(): never {
throw new UnsupportedOperation();
}
override abortErrorReason(): never {
throw new UnsupportedOperation();
}
override interceptResolutionState(): never {
throw new UnsupportedOperation();
}
override isInterceptResolutionHandled(): never {
throw new UnsupportedOperation();
}
override finalizeInterceptions(): never {
throw new UnsupportedOperation();
}
override abort(): never {
throw new UnsupportedOperation();
}
override respond(
_response: Partial<ResponseForRequest>,
override async _respond(
response: Partial<ResponseForRequest>,
_priority?: number
): never {
throw new UnsupportedOperation();
): Promise<void> {
this.interception.handled = true;
const responseBody: string | undefined =
response.body && response.body instanceof Uint8Array
? response.body.toString('base64')
: response.body
? btoa(response.body)
: undefined;
const headers: Bidi.Network.Header[] = getBidiHeaders(response.headers);
const hasContentLength = headers.some(header => {
return header.name === 'content-length';
});
if (response.contentType) {
headers.push({
name: 'content-type',
value: {
type: 'string',
value: response.contentType,
},
});
}
if (responseBody && !hasContentLength) {
const encoder = new TextEncoder();
headers.push({
name: 'content-length',
value: {
type: 'string',
value: String(encoder.encode(responseBody).byteLength),
},
});
}
const status = response.status || 200;
return await this.#request
.provideResponse({
statusCode: status,
headers: headers.length > 0 ? headers : undefined,
reasonPhrase: STATUS_TEXTS[status],
body: responseBody
? {
type: 'base64',
value: responseBody,
}
: undefined,
})
.catch(error => {
this.interception.handled = false;
throw error;
});
}
#authenticationHandled = false;
#handleAuthentication = async () => {
if (!this.#frame) {
return;
}
const credentials = this.#frame.page()._credentials;
if (credentials && !this.#authenticationHandled) {
this.#authenticationHandled = true;
void this.#request.continueWithAuth({
action: 'provideCredentials',
credentials: {
type: 'password',
username: credentials.username,
password: credentials.password,
},
});
} else {
void this.#request.continueWithAuth({
action: 'cancel',
});
}
};
}
function getBidiHeaders(rawHeaders?: Record<string, unknown>) {
const headers: Bidi.Network.Header[] = [];
for (const [name, value] of Object.entries(rawHeaders ?? [])) {
if (!Object.is(value, undefined)) {
const values = Array.isArray(value) ? value : [value];
for (const value of values) {
headers.push({
name: name.toLowerCase(),
value: {
type: 'string',
value: String(value),
},
});
}
}
}
return headers;
}

View file

@ -40,6 +40,12 @@ export class BidiHTTPResponse extends HTTPResponse {
}
#initialize() {
if (this.#data.fromCache) {
this.#request
.frame()
?.page()
.trustedEmitter.emit(PageEvent.RequestServedFromCache, this.#request);
}
this.#request.frame()?.page().trustedEmitter.emit(PageEvent.Response, this);
}

View file

@ -13,8 +13,9 @@ import type {BoundingBox} from '../api/ElementHandle.js';
import type {WaitForOptions} from '../api/Frame.js';
import type {HTTPResponse} from '../api/HTTPResponse.js';
import type {
MediaFeature,
Credentials,
GeolocationOptions,
MediaFeature,
PageEvents,
} from '../api/Page.js';
import {
@ -27,13 +28,22 @@ import {Accessibility} from '../cdp/Accessibility.js';
import {Coverage} from '../cdp/Coverage.js';
import {EmulationManager} from '../cdp/EmulationManager.js';
import {Tracing} from '../cdp/Tracing.js';
import type {Cookie, CookieParam, CookieSameSite} from '../common/Cookie.js';
import type {DeleteCookiesRequest} from '../common/Cookie.js';
import type {
Cookie,
CookieParam,
CookieSameSite,
DeleteCookiesRequest,
} from '../common/Cookie.js';
import {UnsupportedOperation} from '../common/Errors.js';
import {EventEmitter} from '../common/EventEmitter.js';
import type {PDFOptions} from '../common/PDFOptions.js';
import type {Awaitable} from '../common/types.js';
import {evaluationString, parsePDFOptions, timeout} from '../common/util.js';
import {
evaluationString,
isString,
parsePDFOptions,
timeout,
} from '../common/util.js';
import type {Viewport} from '../common/Viewport.js';
import {assert} from '../util/assert.js';
import {bubble} from '../util/decorators.js';
@ -43,7 +53,6 @@ import type {BidiBrowser} from './Browser.js';
import type {BidiBrowserContext} from './BrowserContext.js';
import type {BidiCdpSession} from './CDPSession.js';
import type {BrowsingContext} from './core/BrowsingContext.js';
import {BidiElementHandle} from './ElementHandle.js';
import {BidiFrame} from './Frame.js';
import type {BidiHTTPResponse} from './HTTPResponse.js';
import {BidiKeyboard, BidiMouse, BidiTouchscreen} from './Input.js';
@ -161,21 +170,28 @@ export class BidiPage extends Page {
}
async focusedFrame(): Promise<BidiFrame> {
using frame = await this.mainFrame()
using handle = (await this.mainFrame()
.isolatedRealm()
.evaluateHandle(() => {
let frame: HTMLIFrameElement | undefined;
let win: Window | null = window;
while (win?.document.activeElement instanceof HTMLIFrameElement) {
frame = win.document.activeElement;
win = frame.contentWindow;
let win = window;
while (
win.document.activeElement instanceof win.HTMLIFrameElement ||
win.document.activeElement instanceof win.HTMLFrameElement
) {
if (win.document.activeElement.contentWindow === null) {
break;
}
win = win.document.activeElement.contentWindow as typeof win;
}
return frame;
});
if (!(frame instanceof BidiElementHandle)) {
return this.mainFrame();
}
return await frame.contentFrame();
return win;
})) as BidiJSHandle<Window & typeof globalThis>;
const value = handle.remoteValue();
assert(value.type === 'window');
const frame = this.frames().find(frame => {
return frame._id === value.value.context;
});
assert(frame);
return frame;
}
override frames(): BidiFrame[] {
@ -311,6 +327,17 @@ export class BidiPage extends Page {
preferCSSPageSize,
} = parsePDFOptions(options, 'cm');
const pageRanges = ranges ? ranges.split(', ') : [];
await firstValueFrom(
from(
this.mainFrame()
.isolatedRealm()
.evaluate(() => {
return document.fonts.ready;
})
).pipe(raceWith(timeout(ms)))
);
const data = await firstValueFrom(
from(
this.#frame.browsingContext.print({
@ -489,8 +516,71 @@ export class BidiPage extends Page {
return [...this.#workers];
}
override setRequestInterception(): never {
throw new UnsupportedOperation();
#userInterception?: string;
override async setRequestInterception(enable: boolean): Promise<void> {
this.#userInterception = await this.#toggleInterception(
[Bidi.Network.InterceptPhase.BeforeRequestSent],
this.#userInterception,
enable
);
}
/**
* @internal
*/
_extraHTTPHeaders: Record<string, string> = {};
#extraHeadersInterception?: string;
override async setExtraHTTPHeaders(
headers: Record<string, string>
): Promise<void> {
const extraHTTPHeaders: Record<string, string> = {};
for (const [key, value] of Object.entries(headers)) {
assert(
isString(value),
`Expected value of header "${key}" to be String, but "${typeof value}" is found.`
);
extraHTTPHeaders[key.toLowerCase()] = value;
}
this._extraHTTPHeaders = extraHTTPHeaders;
this.#extraHeadersInterception = await this.#toggleInterception(
[Bidi.Network.InterceptPhase.BeforeRequestSent],
this.#extraHeadersInterception,
Boolean(Object.keys(this._extraHTTPHeaders).length)
);
}
/**
* @internal
*/
_credentials: Credentials | null = null;
#authInterception?: string;
override async authenticate(credentials: Credentials | null): Promise<void> {
this.#authInterception = await this.#toggleInterception(
[Bidi.Network.InterceptPhase.AuthRequired],
this.#authInterception,
Boolean(credentials)
);
this._credentials = credentials;
}
async #toggleInterception(
phases: [Bidi.Network.InterceptPhase, ...Bidi.Network.InterceptPhase[]],
interception: string | undefined,
expected: boolean
): Promise<string | undefined> {
if (expected && !interception) {
return await this.#frame.browsingContext.addIntercept({
phases,
});
} else if (!expected && interception) {
await this.#frame.browsingContext.userContext.browser.removeIntercept(
interception
);
return;
}
return interception;
}
override setDragInterception(): never {
@ -603,14 +693,6 @@ export class BidiPage extends Page {
await this.#frame.removeExposedFunction(name);
}
override authenticate(): never {
throw new UnsupportedOperation();
}
override setExtraHTTPHeaders(): never {
throw new UnsupportedOperation();
}
override metrics(): never {
throw new UnsupportedOperation();
}

View file

@ -51,20 +51,17 @@ export class Browser extends EventEmitter<{
return browser;
}
// keep-sorted start
#closed = false;
#reason: string | undefined;
readonly #disposables = new DisposableStack();
readonly #userContexts = new Map<string, UserContext>();
readonly session: Session;
readonly #sharedWorkers = new Map<string, SharedWorkerRealm>();
// keep-sorted end
private constructor(session: Session) {
super();
// keep-sorted start
this.session = session;
// keep-sorted end
}
async #initialize() {
@ -141,7 +138,6 @@ export class Browser extends EventEmitter<{
return userContext;
}
// keep-sorted start block=yes
get closed(): boolean {
return this.#closed;
}
@ -158,7 +154,6 @@ export class Browser extends EventEmitter<{
get userContexts(): Iterable<UserContext> {
return this.#userContexts.values();
}
// keep-sorted end
@inertIfDisposed
dispose(reason?: string, closed = false): void {
@ -199,6 +194,16 @@ export class Browser extends EventEmitter<{
return script;
}
@throwIfDisposed<Browser>(browser => {
// SAFETY: By definition of `disposed`, `#reason` is defined.
return browser.#reason!;
})
async removeIntercept(intercept: Bidi.Network.Intercept): Promise<void> {
await this.session.send('network.removeIntercept', {
intercept,
});
}
@throwIfDisposed<Browser>(browser => {
// SAFETY: By definition of `disposed`, `#reason` is defined.
return browser.#reason!;

View file

@ -18,6 +18,14 @@ import {Request} from './Request.js';
import type {UserContext} from './UserContext.js';
import {UserPrompt} from './UserPrompt.js';
/**
* @internal
*/
export type AddInterceptOptions = Omit<
Bidi.Network.AddInterceptParameters,
'contexts'
>;
/**
* @internal
*/
@ -121,7 +129,6 @@ export class BrowsingContext extends EventEmitter<{
return browsingContext;
}
// keep-sorted start
#navigation: Navigation | undefined;
#reason?: string;
#url: string;
@ -133,7 +140,6 @@ export class BrowsingContext extends EventEmitter<{
readonly id: string;
readonly parent: BrowsingContext | undefined;
readonly userContext: UserContext;
// keep-sorted end
private constructor(
context: UserContext,
@ -142,12 +148,11 @@ export class BrowsingContext extends EventEmitter<{
url: string
) {
super();
// keep-sorted start
this.#url = url;
this.id = id;
this.parent = parent;
this.userContext = context;
// keep-sorted end
this.defaultRealm = this.#createWindowRealm();
}
@ -275,7 +280,6 @@ export class BrowsingContext extends EventEmitter<{
});
}
// keep-sorted start block=yes
get #session() {
return this.userContext.browser.session;
}
@ -306,7 +310,6 @@ export class BrowsingContext extends EventEmitter<{
get url(): string {
return this.#url;
}
// keep-sorted end
#createWindowRealm(sandbox?: string) {
const realm = WindowRealm.from(this, sandbox);
@ -478,11 +481,26 @@ export class BrowsingContext extends EventEmitter<{
functionDeclaration,
{
...options,
contexts: [this, ...(options.contexts ?? [])],
contexts: [this],
}
);
}
@throwIfDisposed<BrowsingContext>(context => {
// SAFETY: Disposal implies this exists.
return context.#reason!;
})
async addIntercept(options: AddInterceptOptions): Promise<string> {
const {
result: {intercept},
} = await this.userContext.browser.session.send('network.addIntercept', {
...options,
contexts: [this.id],
});
return intercept;
}
@throwIfDisposed<BrowsingContext>(context => {
// SAFETY: Disposal implies this exists.
return context.#reason!;
@ -539,6 +557,22 @@ export class BrowsingContext extends EventEmitter<{
});
}
@throwIfDisposed<BrowsingContext>(context => {
// SAFETY: Disposal implies this exists.
return context.#reason!;
})
async subscribe(events: [string, ...string[]]): Promise<void> {
await this.#session.subscribe(events, [this.id]);
}
@throwIfDisposed<BrowsingContext>(context => {
// SAFETY: Disposal implies this exists.
return context.#reason!;
})
async addInterception(events: [string, ...string[]]): Promise<void> {
await this.#session.subscribe(events, [this.id]);
}
[disposeSymbol](): void {
this.#reason ??=
'Browsing context already closed, probably because the user context closed.';

View file

@ -149,6 +149,31 @@ export interface Commands {
params: Bidi.Storage.SetCookieParameters;
returnType: Bidi.Storage.SetCookieParameters;
};
'network.addIntercept': {
params: Bidi.Network.AddInterceptParameters;
returnType: Bidi.Network.AddInterceptResult;
};
'network.removeIntercept': {
params: Bidi.Network.RemoveInterceptParameters;
returnType: Bidi.EmptyResult;
};
'network.continueRequest': {
params: Bidi.Network.ContinueRequestParameters;
returnType: Bidi.EmptyResult;
};
'network.continueWithAuth': {
params: Bidi.Network.ContinueWithAuthParameters;
returnType: Bidi.EmptyResult;
};
'network.failRequest': {
params: Bidi.Network.FailRequestParameters;
returnType: Bidi.EmptyResult;
};
'network.provideResponse': {
params: Bidi.Network.ProvideResponseParameters;
returnType: Bidi.EmptyResult;
};
}
/**

View file

@ -6,7 +6,6 @@
import {EventEmitter} from '../../common/EventEmitter.js';
import {inertIfDisposed} from '../../util/decorators.js';
import {Deferred} from '../../util/Deferred.js';
import {DisposableStack, disposeSymbol} from '../../util/disposable.js';
import type {BrowsingContext} from './BrowsingContext.js';
@ -39,19 +38,16 @@ export class Navigation extends EventEmitter<{
return navigation;
}
// keep-sorted start
#request: Request | undefined;
#navigation: Navigation | undefined;
readonly #browsingContext: BrowsingContext;
readonly #disposables = new DisposableStack();
readonly #id = new Deferred<string | null>();
// keep-sorted end
#id?: string | null;
private constructor(context: BrowsingContext) {
super();
// keep-sorted start
this.#browsingContext = context;
// keep-sorted end
}
#initialize() {
@ -69,7 +65,6 @@ export class Navigation extends EventEmitter<{
browsingContextEmitter.on('request', ({request}) => {
if (
request.navigation === undefined ||
this.#request !== undefined ||
// If a request with a navigation ID comes in, then the navigation ID is
// for this navigation.
!this.#matches(request.navigation)
@ -79,6 +74,13 @@ export class Navigation extends EventEmitter<{
this.#request = request;
this.emit('request', request);
const requestEmitter = this.#disposables.use(
new EventEmitter(this.#request)
);
requestEmitter.on('redirect', request => {
this.#request = request;
});
});
const sessionEmitter = this.#disposables.use(
@ -139,14 +141,13 @@ export class Navigation extends EventEmitter<{
if (this.#navigation !== undefined && !this.#navigation.disposed) {
return false;
}
if (!this.#id.resolved()) {
this.#id.resolve(navigation);
if (this.#id === undefined) {
this.#id = navigation;
return true;
}
return this.#id.value() === navigation;
return this.#id === navigation;
}
// keep-sorted start block=yes
get #session() {
return this.#browsingContext.userContext.browser.session;
}
@ -159,7 +160,6 @@ export class Navigation extends EventEmitter<{
get navigation(): Navigation | undefined {
return this.#navigation;
}
// keep-sorted end
@inertIfDisposed
private dispose(): void {

View file

@ -44,22 +44,19 @@ export abstract class Realm extends EventEmitter<{
/** Emitted when a shared worker is created in the realm. */
sharedworker: SharedWorkerRealm;
}> {
// keep-sorted start
#reason?: string;
protected readonly disposables = new DisposableStack();
readonly id: string;
readonly origin: string;
// keep-sorted end
protected executionContextId?: number;
protected constructor(id: string, origin: string) {
super();
// keep-sorted start
this.id = id;
this.origin = origin;
// keep-sorted end
}
// keep-sorted start block=yes
get disposed(): boolean {
return this.#reason !== undefined;
}
@ -67,7 +64,6 @@ export abstract class Realm extends EventEmitter<{
get target(): Bidi.Script.Target {
return {realm: this.id};
}
// keep-sorted end
@inertIfDisposed
protected dispose(reason?: string): void {
@ -127,11 +123,15 @@ export abstract class Realm extends EventEmitter<{
return realm.#reason!;
})
async resolveExecutionContextId(): Promise<number> {
const {result} = await (this.session.connection as BidiConnection).send(
'cdp.resolveRealm',
{realm: this.id}
);
return result.executionContextId;
if (!this.executionContextId) {
const {result} = await (this.session.connection as BidiConnection).send(
'cdp.resolveRealm',
{realm: this.id}
);
this.executionContextId = result.executionContextId;
}
return this.executionContextId;
}
[disposeSymbol](): void {
@ -154,19 +154,16 @@ export class WindowRealm extends Realm {
return realm;
}
// keep-sorted start
readonly browsingContext: BrowsingContext;
readonly sandbox?: string;
// keep-sorted end
readonly #workers = new Map<string, DedicatedWorkerRealm>();
private constructor(context: BrowsingContext, sandbox?: string) {
super('', '');
// keep-sorted start
this.browsingContext = context;
this.sandbox = sandbox;
// keep-sorted end
}
#initialize(): void {
@ -188,6 +185,7 @@ export class WindowRealm extends Realm {
}
(this as any).id = info.realm;
(this as any).origin = info.origin;
this.executionContextId = undefined;
this.emit('updated', this);
});
sessionEmitter.on('script.realmCreated', info => {
@ -242,10 +240,8 @@ export class DedicatedWorkerRealm extends Realm {
return realm;
}
// keep-sorted start
readonly #workers = new Map<string, DedicatedWorkerRealm>();
readonly owners: Set<DedicatedWorkerOwnerRealm>;
// keep-sorted end
private constructor(
owner: DedicatedWorkerOwnerRealm,
@ -300,10 +296,8 @@ export class SharedWorkerRealm extends Realm {
return realm;
}
// keep-sorted start
readonly #workers = new Map<string, DedicatedWorkerRealm>();
readonly browser: Browser;
// keep-sorted end
private constructor(browser: Browser, id: string, origin: string) {
super(id, origin);

View file

@ -19,6 +19,8 @@ export class Request extends EventEmitter<{
/** Emitted when the request is redirected. */
redirect: Request;
/** Emitted when the request succeeds. */
authenticate: void;
/** Emitted when the request succeeds. */
success: Bidi.Network.ResponseData;
/** Emitted when the request fails. */
error: string;
@ -32,24 +34,21 @@ export class Request extends EventEmitter<{
return request;
}
// keep-sorted start
#error?: string;
#redirect?: Request;
#response?: Bidi.Network.ResponseData;
readonly #browsingContext: BrowsingContext;
readonly #disposables = new DisposableStack();
readonly #event: Bidi.Network.BeforeRequestSentParameters;
// keep-sorted end
private constructor(
browsingContext: BrowsingContext,
event: Bidi.Network.BeforeRequestSentParameters
) {
super();
// keep-sorted start
this.#browsingContext = browsingContext;
this.#event = event;
// keep-sorted end
}
#initialize() {
@ -77,6 +76,17 @@ export class Request extends EventEmitter<{
this.emit('redirect', this.#redirect);
this.dispose();
});
sessionEmitter.on('network.authRequired', event => {
if (
event.context !== this.#browsingContext.id ||
event.request.request !== this.id ||
// Don't try to authenticate for events that are not blocked
!event.isBlocked
) {
return;
}
this.emit('authenticate', undefined);
});
sessionEmitter.on('network.fetchError', event => {
if (
event.context !== this.#browsingContext.id ||
@ -107,7 +117,6 @@ export class Request extends EventEmitter<{
});
}
// keep-sorted start block=yes
get #session() {
return this.#browsingContext.userContext.browser.session;
}
@ -135,13 +144,82 @@ export class Request extends EventEmitter<{
get redirect(): Request | undefined {
return this.#redirect;
}
get lastRedirect(): Request | undefined {
let redirect = this.#redirect;
while (redirect) {
if (redirect && !redirect.#redirect) {
return redirect;
}
redirect = redirect.#redirect;
}
return redirect;
}
get response(): Bidi.Network.ResponseData | undefined {
return this.#response;
}
get url(): string {
return this.#event.request.url;
}
// keep-sorted end
get isBlocked(): boolean {
return this.#event.isBlocked;
}
async continueRequest({
url,
method,
headers,
cookies,
body,
}: Omit<Bidi.Network.ContinueRequestParameters, 'request'>): Promise<void> {
await this.#session.send('network.continueRequest', {
request: this.id,
url,
method,
headers,
body,
cookies,
});
}
async failRequest(): Promise<void> {
await this.#session.send('network.failRequest', {
request: this.id,
});
}
async provideResponse({
statusCode,
reasonPhrase,
headers,
body,
}: Omit<Bidi.Network.ProvideResponseParameters, 'request'>): Promise<void> {
await this.#session.send('network.provideResponse', {
request: this.id,
statusCode,
reasonPhrase,
headers,
body,
});
}
async continueWithAuth(
parameters:
| Bidi.Network.ContinueWithAuthCredentials
| Bidi.Network.ContinueWithAuthNoCredentials
): Promise<void> {
if (parameters.action === 'provideCredentials') {
await this.#session.send('network.continueWithAuth', {
request: this.id,
action: parameters.action,
credentials: parameters.credentials,
});
} else {
await this.#session.send('network.continueWithAuth', {
request: this.id,
action: parameters.action,
});
}
}
@inertIfDisposed
private dispose(): void {

View file

@ -71,8 +71,9 @@ export class Session
platformName: '',
setWindowRect: false,
webSocketUrl: '',
userAgent: '',
},
};
} satisfies Bidi.Session.NewResult;
}
const session = new Session(connection, result);
@ -80,21 +81,18 @@ export class Session
return session;
}
// keep-sorted start
#reason: string | undefined;
readonly #disposables = new DisposableStack();
readonly #info: Bidi.Session.NewResult;
readonly browser!: Browser;
@bubble()
accessor connection: Connection;
// keep-sorted end
private constructor(connection: Connection, info: Bidi.Session.NewResult) {
super();
// keep-sorted start
this.#info = info;
this.connection = connection;
// keep-sorted end
}
async #initialize(): Promise<void> {
@ -120,7 +118,6 @@ export class Session
});
}
// keep-sorted start block=yes
get capabilities(): Bidi.Session.NewResult['capabilities'] {
return this.#info.capabilities;
}
@ -133,7 +130,6 @@ export class Session
get id(): string {
return this.#info.sessionId;
}
// keep-sorted end
@inertIfDisposed
private dispose(reason?: string): void {
@ -163,9 +159,27 @@ export class Session
// SAFETY: By definition of `disposed`, `#reason` is defined.
return session.#reason!;
})
async subscribe(events: string[]): Promise<void> {
async subscribe(
events: [string, ...string[]],
contexts?: [string, ...string[]]
): Promise<void> {
await this.send('session.subscribe', {
events,
contexts,
});
}
@throwIfDisposed<Session>(session => {
// SAFETY: By definition of `disposed`, `#reason` is defined.
return session.#reason!;
})
async addIntercepts(
events: [string, ...string[]],
contexts?: [string, ...string[]]
): Promise<void> {
await this.send('session.subscribe', {
events,
contexts,
});
}

View file

@ -52,21 +52,18 @@ export class UserContext extends EventEmitter<{
return context;
}
// keep-sorted start
#reason?: string;
// Note these are only top-level contexts.
readonly #browsingContexts = new Map<string, BrowsingContext>();
readonly #disposables = new DisposableStack();
readonly #id: string;
readonly browser: Browser;
// keep-sorted end
private constructor(browser: Browser, id: string) {
super();
// keep-sorted start
this.#id = id;
this.browser = browser;
// keep-sorted end
}
#initialize() {
@ -110,7 +107,6 @@ export class UserContext extends EventEmitter<{
});
}
// keep-sorted start block=yes
get #session() {
return this.browser.session;
}
@ -126,7 +122,6 @@ export class UserContext extends EventEmitter<{
get id(): string {
return this.#id;
}
// keep-sorted end
@inertIfDisposed
private dispose(reason?: string): void {
@ -227,8 +222,7 @@ export class UserContext extends EventEmitter<{
origin,
descriptor,
state,
// @ts-expect-error not standard implementation.
'goog:userContext': this.#id,
userContext: this.#id,
});
}

View file

@ -49,23 +49,20 @@ export class UserPrompt extends EventEmitter<{
return userPrompt;
}
// keep-sorted start
#reason?: string;
#result?: UserPromptResult;
readonly #disposables = new DisposableStack();
readonly browsingContext: BrowsingContext;
readonly info: Bidi.BrowsingContext.UserPromptOpenedParameters;
// keep-sorted end
private constructor(
context: BrowsingContext,
info: Bidi.BrowsingContext.UserPromptOpenedParameters
) {
super();
// keep-sorted start
this.browsingContext = context;
this.info = info;
// keep-sorted end
}
#initialize() {
@ -89,7 +86,6 @@ export class UserPrompt extends EventEmitter<{
});
}
// keep-sorted start block=yes
get #session() {
return this.browsingContext.userContext.browser.session;
}
@ -105,7 +101,6 @@ export class UserPrompt extends EventEmitter<{
get result(): UserPromptResult | undefined {
return this.#result;
}
// keep-sorted end
@inertIfDisposed
private dispose(reason?: string): void {

View file

@ -27,7 +27,16 @@ const queryAXTree = async (
role,
});
return nodes.filter((node: Protocol.Accessibility.AXNode) => {
return !node.role || !NON_ELEMENT_NODE_ROLES.has(node.role.value);
if (node.ignored) {
return false;
}
if (!node.role) {
return false;
}
if (NON_ELEMENT_NODE_ROLES.has(node.role.value)) {
return false;
}
return true;
});
};

View file

@ -12,20 +12,18 @@ import type {DebugInfo} from '../api/Browser.js';
import {
Browser as BrowserBase,
BrowserEvent,
WEB_PERMISSION_TO_PROTOCOL_PERMISSION,
type BrowserCloseCallback,
type BrowserContextOptions,
type IsPageTargetCallback,
type Permission,
type TargetFilterCallback,
} from '../api/Browser.js';
import {BrowserContext, BrowserContextEvent} from '../api/BrowserContext.js';
import {BrowserContextEvent} from '../api/BrowserContext.js';
import {CDPSessionEvent, type CDPSession} from '../api/CDPSession.js';
import type {Page} from '../api/Page.js';
import type {Target} from '../api/Target.js';
import type {Viewport} from '../common/Viewport.js';
import {assert} from '../util/assert.js';
import {CdpBrowserContext} from './BrowserContext.js';
import {ChromeTargetManager} from './ChromeTargetManager.js';
import type {Connection} from './Connection.js';
import {FirefoxTargetManager} from './FirefoxTargetManager.js';
@ -424,90 +422,3 @@ export class CdpBrowser extends BrowserBase {
};
}
}
/**
* @internal
*/
export class CdpBrowserContext extends BrowserContext {
#connection: Connection;
#browser: CdpBrowser;
#id?: string;
constructor(connection: Connection, browser: CdpBrowser, contextId?: string) {
super();
this.#connection = connection;
this.#browser = browser;
this.#id = contextId;
}
override get id(): string | undefined {
return this.#id;
}
override targets(): CdpTarget[] {
return this.#browser.targets().filter(target => {
return target.browserContext() === this;
});
}
override async pages(): Promise<Page[]> {
const pages = await Promise.all(
this.targets()
.filter(target => {
return (
target.type() === 'page' ||
(target.type() === 'other' &&
this.#browser._getIsPageTargetCallback()?.(target))
);
})
.map(target => {
return target.page();
})
);
return pages.filter((page): page is Page => {
return !!page;
});
}
override isIncognito(): boolean {
return !!this.#id;
}
override async overridePermissions(
origin: string,
permissions: Permission[]
): Promise<void> {
const protocolPermissions = permissions.map(permission => {
const protocolPermission =
WEB_PERMISSION_TO_PROTOCOL_PERMISSION.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,
});
}
override async clearPermissionOverrides(): Promise<void> {
await this.#connection.send('Browser.resetPermissions', {
browserContextId: this.#id || undefined,
});
}
override newPage(): Promise<Page> {
return this.#browser._createPageInContext(this.#id);
}
override browser(): CdpBrowser {
return this.#browser;
}
override async close(): Promise<void> {
assert(this.#id, 'Non-incognito profiles cannot be closed!');
await this.#browser._disposeContext(this.#id);
}
}

View file

@ -0,0 +1,104 @@
/**
* @license
* Copyright 2024 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import {
WEB_PERMISSION_TO_PROTOCOL_PERMISSION,
type Permission,
} from '../api/Browser.js';
import {BrowserContext} from '../api/BrowserContext.js';
import type {Page} from '../api/Page.js';
import {assert} from '../util/assert.js';
import type {CdpBrowser} from './Browser.js';
import type {Connection} from './Connection.js';
import type {CdpTarget} from './Target.js';
/**
* @internal
*/
export class CdpBrowserContext extends BrowserContext {
#connection: Connection;
#browser: CdpBrowser;
#id?: string;
constructor(connection: Connection, browser: CdpBrowser, contextId?: string) {
super();
this.#connection = connection;
this.#browser = browser;
this.#id = contextId;
}
override get id(): string | undefined {
return this.#id;
}
override targets(): CdpTarget[] {
return this.#browser.targets().filter(target => {
return target.browserContext() === this;
});
}
override async pages(): Promise<Page[]> {
const pages = await Promise.all(
this.targets()
.filter(target => {
return (
target.type() === 'page' ||
(target.type() === 'other' &&
this.#browser._getIsPageTargetCallback()?.(target))
);
})
.map(target => {
return target.page();
})
);
return pages.filter((page): page is Page => {
return !!page;
});
}
override isIncognito(): boolean {
return !!this.#id;
}
override async overridePermissions(
origin: string,
permissions: Permission[]
): Promise<void> {
const protocolPermissions = permissions.map(permission => {
const protocolPermission =
WEB_PERMISSION_TO_PROTOCOL_PERMISSION.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,
});
}
override async clearPermissionOverrides(): Promise<void> {
await this.#connection.send('Browser.resetPermissions', {
browserContextId: this.#id || undefined,
});
}
override newPage(): Promise<Page> {
return this.#browser._createPageInContext(this.#id);
}
override browser(): CdpBrowser {
return this.#browser;
}
override async close(): Promise<void> {
assert(this.#id, 'Non-incognito profiles cannot be closed!');
await this.#browser._disposeContext(this.#id);
}
}

View file

@ -206,6 +206,7 @@ export class CdpFrame extends Frame {
options: {
timeout?: number;
waitUntil?: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[];
ignoreSameDocumentNavigation?: boolean;
} = {}
): Promise<HTTPResponse | null> {
const {
@ -220,14 +221,22 @@ export class CdpFrame extends Frame {
);
const error = await Deferred.race([
watcher.terminationPromise(),
watcher.sameDocumentNavigationPromise(),
...(options.ignoreSameDocumentNavigation
? []
: [watcher.sameDocumentNavigationPromise()]),
watcher.newDocumentNavigationPromise(),
]);
try {
if (error) {
throw error;
}
return await watcher.navigationResponse();
const result = await Deferred.race<
Error | HTTPResponse | null | undefined
>([watcher.terminationPromise(), watcher.navigationResponse()]);
if (result instanceof Error) {
throw error;
}
return result || null;
} finally {
watcher.dispose();
}

View file

@ -21,6 +21,7 @@ export class FrameTree<FrameType extends Frame> {
// frameID -> childFrameIDs
#childIds = new Map<string, Set<string>>();
#mainFrame?: FrameType;
#isMainFrameStale = false;
#waitRequests = new Map<string, Set<Deferred<FrameType>>>();
getMainFrame(): FrameType | undefined {
@ -59,8 +60,9 @@ export class FrameTree<FrameType extends Frame> {
this.#childIds.set(frame._parentId, new Set());
}
this.#childIds.get(frame._parentId)!.add(frame._id);
} else if (!this.#mainFrame) {
} else if (!this.#mainFrame || this.#isMainFrameStale) {
this.#mainFrame = frame;
this.#isMainFrameStale = false;
}
this.#waitRequests.get(frame._id)?.forEach(request => {
return request.resolve(frame);
@ -73,7 +75,7 @@ export class FrameTree<FrameType extends Frame> {
if (frame._parentId) {
this.#childIds.get(frame._parentId)?.delete(frame._id);
} else {
this.#mainFrame = undefined;
this.#isMainFrameStale = true;
}
}

View file

@ -9,18 +9,14 @@ import type {CDPSession} from '../api/CDPSession.js';
import type {Frame} from '../api/Frame.js';
import {
type ContinueRequestOverrides,
type ErrorCode,
headersArray,
HTTPRequest,
InterceptResolutionAction,
type InterceptResolutionState,
type ResourceType,
type ResponseForRequest,
STATUS_TEXTS,
handleError,
} from '../api/HTTPRequest.js';
import type {ProtocolError} from '../common/Errors.js';
import {debugError, isString} from '../common/util.js';
import {assert} from '../util/assert.js';
import type {CdpHTTPResponse} from './HTTPResponse.js';
@ -34,8 +30,7 @@ export class CdpHTTPRequest extends HTTPRequest {
#client: CDPSession;
#isNavigationRequest: boolean;
#allowInterception: boolean;
#interceptionHandled = false;
#url: string;
#resourceType: ResourceType;
@ -44,13 +39,6 @@ export class CdpHTTPRequest extends HTTPRequest {
#postData?: string;
#headers: Record<string, string> = {};
#frame: Frame | null;
#continueRequestOverrides: ContinueRequestOverrides;
#responseForRequest: Partial<ResponseForRequest> | null = null;
#abortErrorReason: Protocol.Network.ErrorReason | null = null;
#interceptResolutionState: InterceptResolutionState = {
action: InterceptResolutionAction.None,
};
#interceptHandlers: Array<() => void | PromiseLike<any>>;
#initiator?: Protocol.Network.Initiator;
override get client(): CDPSession {
@ -96,7 +84,6 @@ export class CdpHTTPRequest extends HTTPRequest {
this.#isNavigationRequest =
data.requestId === data.loaderId && data.type === 'Document';
this._interceptionId = interceptionId;
this.#allowInterception = allowInterception;
this.#url = data.request.url;
this.#resourceType = (data.type || 'other').toLowerCase() as ResourceType;
this.#method = data.request.method;
@ -104,10 +91,10 @@ export class CdpHTTPRequest extends HTTPRequest {
this.#hasPostData = data.request.hasPostData ?? false;
this.#frame = frame;
this._redirectChain = redirectChain;
this.#continueRequestOverrides = {};
this.#interceptHandlers = [];
this.#initiator = data.initiator;
this.interception.enabled = allowInterception;
for (const [key, value] of Object.entries(data.request.headers)) {
this.#headers[key.toLowerCase()] = value;
}
@ -117,59 +104,6 @@ export class CdpHTTPRequest extends HTTPRequest {
return this.#url;
}
override continueRequestOverrides(): ContinueRequestOverrides {
assert(this.#allowInterception, 'Request Interception is not enabled!');
return this.#continueRequestOverrides;
}
override responseForRequest(): Partial<ResponseForRequest> | null {
assert(this.#allowInterception, 'Request Interception is not enabled!');
return this.#responseForRequest;
}
override abortErrorReason(): Protocol.Network.ErrorReason | null {
assert(this.#allowInterception, 'Request Interception is not enabled!');
return this.#abortErrorReason;
}
override interceptResolutionState(): InterceptResolutionState {
if (!this.#allowInterception) {
return {action: InterceptResolutionAction.Disabled};
}
if (this.#interceptionHandled) {
return {action: InterceptResolutionAction.AlreadyHandled};
}
return {...this.#interceptResolutionState};
}
override isInterceptResolutionHandled(): boolean {
return this.#interceptionHandled;
}
enqueueInterceptAction(
pendingHandler: () => void | PromiseLike<unknown>
): void {
this.#interceptHandlers.push(pendingHandler);
}
override async finalizeInterceptions(): Promise<void> {
await this.#interceptHandlers.reduce((promiseChain, interceptAction) => {
return promiseChain.then(interceptAction);
}, Promise.resolve());
const {action} = this.interceptResolutionState();
switch (action) {
case 'abort':
return await this.#abort(this.#abortErrorReason);
case 'respond':
if (this.#responseForRequest === null) {
throw new Error('Response is missing for the interception');
}
return await this.#respond(this.#responseForRequest);
case 'continue':
return await this.#continue(this.#continueRequestOverrides);
}
}
override resourceType(): ResourceType {
return this.#resourceType;
}
@ -231,46 +165,12 @@ export class CdpHTTPRequest extends HTTPRequest {
};
}
override async continue(
overrides: ContinueRequestOverrides = {},
priority?: number
): 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!');
if (priority === undefined) {
return await this.#continue(overrides);
}
this.#continueRequestOverrides = overrides;
if (
this.#interceptResolutionState.priority === undefined ||
priority > this.#interceptResolutionState.priority
) {
this.#interceptResolutionState = {
action: InterceptResolutionAction.Continue,
priority,
};
return;
}
if (priority === this.#interceptResolutionState.priority) {
if (
this.#interceptResolutionState.action === 'abort' ||
this.#interceptResolutionState.action === 'respond'
) {
return;
}
this.#interceptResolutionState.action =
InterceptResolutionAction.Continue;
}
return;
}
async #continue(overrides: ContinueRequestOverrides = {}): Promise<void> {
/**
* @internal
*/
async _continue(overrides: ContinueRequestOverrides = {}): Promise<void> {
const {url, method, postData, headers} = overrides;
this.#interceptionHandled = true;
this.interception.handled = true;
const postDataBinaryBase64 = postData
? Buffer.from(postData).toString('base64')
@ -290,45 +190,13 @@ export class CdpHTTPRequest extends HTTPRequest {
headers: headers ? headersArray(headers) : undefined,
})
.catch(error => {
this.#interceptionHandled = false;
this.interception.handled = false;
return handleError(error);
});
}
override async respond(
response: Partial<ResponseForRequest>,
priority?: number
): 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!');
if (priority === undefined) {
return await this.#respond(response);
}
this.#responseForRequest = response;
if (
this.#interceptResolutionState.priority === undefined ||
priority > this.#interceptResolutionState.priority
) {
this.#interceptResolutionState = {
action: InterceptResolutionAction.Respond,
priority,
};
return;
}
if (priority === this.#interceptResolutionState.priority) {
if (this.#interceptResolutionState.action === 'abort') {
return;
}
this.#interceptResolutionState.action = InterceptResolutionAction.Respond;
}
}
async #respond(response: Partial<ResponseForRequest>): Promise<void> {
this.#interceptionHandled = true;
async _respond(response: Partial<ResponseForRequest>): Promise<void> {
this.interception.handled = true;
const responseBody: Buffer | null =
response.body && isString(response.body)
@ -371,43 +239,15 @@ export class CdpHTTPRequest extends HTTPRequest {
body: responseBody ? responseBody.toString('base64') : undefined,
})
.catch(error => {
this.#interceptionHandled = false;
this.interception.handled = false;
return handleError(error);
});
}
override async abort(
errorCode: ErrorCode = 'failed',
priority?: number
): 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!');
if (priority === undefined) {
return await this.#abort(errorReason);
}
this.#abortErrorReason = errorReason;
if (
this.#interceptResolutionState.priority === undefined ||
priority >= this.#interceptResolutionState.priority
) {
this.#interceptResolutionState = {
action: InterceptResolutionAction.Abort,
priority,
};
return;
}
}
async #abort(
async _abort(
errorReason: Protocol.Network.ErrorReason | null
): Promise<void> {
this.#interceptionHandled = true;
this.interception.handled = true;
if (this._interceptionId === undefined) {
throw new Error(
'HTTPRequest is missing _interceptionId needed for Fetch.failRequest'
@ -421,30 +261,3 @@ export class CdpHTTPRequest extends HTTPRequest {
.catch(handleError);
}
}
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;
async function handleError(error: ProtocolError) {
if (['Invalid header'].includes(error.originalMessage)) {
throw 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);
}

View file

@ -8,6 +8,7 @@ import type {Protocol} from 'devtools-protocol';
import {CDPSessionEvent, type CDPSession} from '../api/CDPSession.js';
import type {Frame} from '../api/Frame.js';
import type {Credentials} from '../api/Page.js';
import {EventEmitter, EventSubscription} from '../common/EventEmitter.js';
import {
NetworkManagerEvent,
@ -24,14 +25,6 @@ import {
type FetchRequestId,
} from './NetworkEventManager.js';
/**
* @public
*/
export interface Credentials {
username: string;
password: string;
}
/**
* @public
*/
@ -147,18 +140,16 @@ export class NetworkManager extends EventEmitter<NetworkManagerEvents> {
);
}
async setExtraHTTPHeaders(
extraHTTPHeaders: Record<string, string>
): Promise<void> {
this.#extraHTTPHeaders = {};
for (const key of Object.keys(extraHTTPHeaders)) {
const value = extraHTTPHeaders[key];
async setExtraHTTPHeaders(headers: Record<string, string>): Promise<void> {
const extraHTTPHeaders: Record<string, string> = {};
for (const [key, value] of Object.entries(headers)) {
assert(
isString(value),
`Expected value of header "${key}" to be String, but "${typeof value}" is found.`
);
this.#extraHTTPHeaders[key.toLowerCase()] = value;
extraHTTPHeaders[key.toLowerCase()] = value;
}
this.#extraHTTPHeaders = extraHTTPHeaders;
await this.#applyToAllClients(this.#applyExtraHTTPHeaders.bind(this));
}

View file

@ -15,6 +15,7 @@ import type {Frame, WaitForOptions} from '../api/Frame.js';
import type {HTTPRequest} from '../api/HTTPRequest.js';
import type {HTTPResponse} from '../api/HTTPResponse.js';
import type {JSHandle} from '../api/JSHandle.js';
import type {Credentials} from '../api/Page.js';
import {
Page,
PageEvent,
@ -71,7 +72,7 @@ import {FrameManagerEvent} from './FrameManagerEvents.js';
import {CdpKeyboard, CdpMouse, CdpTouchscreen} from './Input.js';
import {MAIN_WORLD} from './IsolatedWorlds.js';
import {releaseObject} from './JSHandle.js';
import type {Credentials, NetworkConditions} from './NetworkManager.js';
import type {NetworkConditions} from './NetworkManager.js';
import type {CdpTarget} from './Target.js';
import type {TargetManager} from './TargetManager.js';
import {TargetManagerEvent} from './TargetManager.js';
@ -916,7 +917,10 @@ export class CdpPage extends Page {
options?: WaitForOptions
): Promise<HTTPResponse | null> {
const [result] = await Promise.all([
this.waitForNavigation(options),
this.waitForNavigation({
...options,
ignoreSameDocumentNavigation: true,
}),
this.#primaryTargetClient.send('Page.reload'),
]);
@ -1130,6 +1134,16 @@ export class CdpPage extends Page {
await this.#emulationManager.setTransparentBackgroundColor();
}
await firstValueFrom(
from(
this.mainFrame()
.isolatedRealm()
.evaluate(() => {
return document.fonts.ready;
})
).pipe(raceWith(timeout(ms)))
);
const printCommandPromise = this.#primaryTargetClient.send(
'Page.printToPDF',
{

View file

@ -31,17 +31,14 @@ export class CallbackRegistry {
} catch (error) {
// We still throw sync errors synchronously and clean up the scheduled
// callback.
callback.promise
.valueOrThrow()
.catch(debugError)
.finally(() => {
this.#callbacks.delete(callback.id);
});
callback.promise.catch(debugError).finally(() => {
this.#callbacks.delete(callback.id);
});
callback.reject(error as Error);
throw error;
}
// Must only have sync code up until here.
return callback.promise.valueOrThrow().finally(() => {
return callback.promise.finally(() => {
this.#callbacks.delete(callback.id);
});
}
@ -148,8 +145,8 @@ export class Callback {
return this.#id;
}
get promise(): Deferred<unknown> {
return this.#deferred;
get promise(): Promise<unknown> {
return this.#deferred.valueOrThrow();
}
get error(): ProtocolError {

View file

@ -102,7 +102,7 @@ export interface Configuration {
/**
* Tells Puppeteer to not chrome-headless-shell download during installation.
*
* Can be overridden by `PUPPETEER_SKIP_CHROME_HEADLESSS_HELL_DOWNLOAD`.
* Can be overridden by `PUPPETEER_SKIP_CHROME_HEADLESS_SHELL_DOWNLOAD`.
*/
skipChromeHeadlessShellDownload?: boolean;
/**

View file

@ -13,8 +13,8 @@ export class PuppeteerError extends Error {
/**
* @internal
*/
constructor(message?: string) {
super(message);
constructor(message?: string, options?: ErrorOptions) {
super(message, options);
this.name = this.constructor.name;
}

View file

@ -98,7 +98,7 @@ export interface PDFOptions {
headerTemplate?: string;
/**
* HTML template for the print footer. Has the same constraints and support
* for special classes as {@link PDFOptions | PDFOptions.headerTemplate}.
* for special classes as {@link PDFOptions.headerTemplate}.
*/
footerTemplate?: string;
/**

View file

@ -312,12 +312,12 @@ export function validateDialogType(
/**
* @internal
*/
export function timeout(ms: number): Observable<never> {
export function timeout(ms: number, cause?: Error): Observable<never> {
return ms === 0
? NEVER
: timer(ms).pipe(
map(() => {
throw new TimeoutError(`Timed out after waiting ${ms}ms`);
throw new TimeoutError(`Timed out after waiting ${ms}ms`, {cause});
})
);
}

View file

@ -166,6 +166,9 @@ export class ChromeLauncher extends ProductLauncher {
removeMatchingFlags(options.args, '--disable-features');
}
const turnOnExperimentalFeaturesForTesting =
process.env['PUPPETEER_TEST_EXPERIMENTAL_CHROME_FEATURES'] === 'true';
// Merge default disabled features with user-provided ones, if any.
const disabledFeatures = [
'Translate',
@ -174,9 +177,13 @@ export class ChromeLauncher extends ProductLauncher {
'MediaRouter',
'OptimizationHints',
// https://crbug.com/1492053
'ProcessPerSiteUpToMainFrameThreshold',
turnOnExperimentalFeaturesForTesting
? ''
: 'ProcessPerSiteUpToMainFrameThreshold',
...userDisabledFeatures,
];
].filter(feature => {
return feature !== '';
});
const userEnabledFeatures = getFeatures('--enable-features', options.args);
if (options.args && userEnabledFeatures.length > 0) {
@ -185,9 +192,11 @@ export class ChromeLauncher extends ProductLauncher {
// Merge default enabled features with user-provided ones, if any.
const enabledFeatures = [
'NetworkServiceInProcess2',
// Add features to enable by default here.
...userEnabledFeatures,
];
].filter(feature => {
return feature !== '';
});
const chromeArguments = [
'--allow-pre-commit-input',
@ -201,7 +210,9 @@ export class ChromeLauncher extends ProductLauncher {
'--disable-default-apps',
'--disable-dev-shm-usage',
'--disable-extensions',
'--disable-field-trial-config', // https://source.chromium.org/chromium/chromium/src/+/main:testing/variations/README.md
turnOnExperimentalFeaturesForTesting
? ''
: '--disable-field-trial-config', // https://source.chromium.org/chromium/chromium/src/+/main:testing/variations/README.md
'--disable-hang-monitor',
'--disable-infobars',
'--disable-ipc-flooding-protection',
@ -220,7 +231,9 @@ export class ChromeLauncher extends ProductLauncher {
'--use-mock-keychain',
`--disable-features=${disabledFeatures.join(',')}`,
`--enable-features=${enabledFeatures.join(',')}`,
];
].filter(arg => {
return arg !== '';
});
const {
devtools = false,
headless = !devtools,

View file

@ -41,14 +41,18 @@ export class NodeWebSocketTransport implements ConnectionTransport {
constructor(ws: NodeWebSocket) {
this.#ws = ws;
this.#ws.addEventListener('message', event => {
if (this.onmessage) {
this.onmessage.call(null, event.data);
}
setImmediate(() => {
if (this.onmessage) {
this.onmessage.call(null, event.data);
}
});
});
this.#ws.addEventListener('close', () => {
if (this.onclose) {
this.onclose.call(null);
}
setImmediate(() => {
if (this.onclose) {
this.onclose.call(null);
}
});
});
// Silently ignore all errors - we don't know what to do with them.
this.#ws.addEventListener('error', () => {});

View file

@ -98,6 +98,12 @@ export abstract class ProductLauncher {
const launchArgs = await this.computeLaunchArguments(options);
if (!existsSync(launchArgs.executablePath)) {
throw new Error(
`Browser was not found at the configured executablePath (${launchArgs.executablePath})`
);
}
const usePipe = launchArgs.args.includes('--remote-debugging-pipe');
const onProcessExit = async () => {

View file

@ -136,11 +136,11 @@ export class PuppeteerNode extends Puppeteer {
* specified.
*
* When using with `puppeteer-core`,
* {@link LaunchOptions | options.executablePath} or
* {@link LaunchOptions | options.channel} must be provided.
* {@link LaunchOptions.executablePath | options.executablePath} or
* {@link LaunchOptions.channel | options.channel} must be provided.
*
* @example
* You can use {@link LaunchOptions | options.ignoreDefaultArgs}
* You can use {@link LaunchOptions.ignoreDefaultArgs | options.ignoreDefaultArgs}
* to filter out `--mute-audio` from default arguments:
*
* ```ts

View file

@ -8,7 +8,7 @@
* @internal
*/
export const PUPPETEER_REVISIONS = Object.freeze({
chrome: '122.0.6261.94',
'chrome-headless-shell': '122.0.6261.94',
chrome: '123.0.6312.122',
'chrome-headless-shell': '123.0.6312.122',
firefox: 'latest',
});

View file

@ -72,7 +72,7 @@ export const interpolateFunction = <T extends (...args: never[]) => unknown>(
for (const [name, jsValue] of Object.entries(replacements)) {
value = value.replace(
new RegExp(`PLACEHOLDER\\(\\s*(?:'${name}'|"${name}")\\s*\\)`, 'g'),
// Wrapping this ensures tersers that accidently inline PLACEHOLDER calls
// Wrapping this ensures tersers that accidentally inline PLACEHOLDER calls
// are still valid. Without, we may get calls like ()=>{...}() which is
// not valid.
`(${jsValue})`

View file

@ -29,6 +29,121 @@ All notable changes to this project will be documented in this file. See [standa
* puppeteer-core bumped from 21.0.2 to 21.0.3
* @puppeteer/browsers bumped from 1.5.1 to 1.6.0
## [22.6.5](https://github.com/puppeteer/puppeteer/compare/puppeteer-v22.6.4...puppeteer-v22.6.5) (2024-04-15)
### Miscellaneous Chores
* **puppeteer:** Synchronize puppeteer versions
### Dependencies
* The following workspace dependencies were updated
* dependencies
* puppeteer-core bumped from 22.6.4 to 22.6.5
* @puppeteer/browsers bumped from 2.2.1 to 2.2.2
## [22.6.4](https://github.com/puppeteer/puppeteer/compare/puppeteer-v22.6.3...puppeteer-v22.6.4) (2024-04-11)
### Miscellaneous Chores
* **puppeteer:** Synchronize puppeteer versions
### Dependencies
* The following workspace dependencies were updated
* dependencies
* puppeteer-core bumped from 22.6.3 to 22.6.4
## [22.6.3](https://github.com/puppeteer/puppeteer/compare/puppeteer-v22.6.2...puppeteer-v22.6.3) (2024-04-05)
### Bug Fixes
* deprecate configuration via package.json ([#12176](https://github.com/puppeteer/puppeteer/issues/12176)) ([c96c762](https://github.com/puppeteer/puppeteer/commit/c96c7623bc2258ba7419812333ec42cdf83bf432))
### Dependencies
* The following workspace dependencies were updated
* dependencies
* puppeteer-core bumped from 22.6.2 to 22.6.3
* @puppeteer/browsers bumped from 2.2.0 to 2.2.1
## [22.6.2](https://github.com/puppeteer/puppeteer/compare/puppeteer-v22.6.1...puppeteer-v22.6.2) (2024-03-28)
### Miscellaneous Chores
* **puppeteer:** Synchronize puppeteer versions
### Dependencies
* The following workspace dependencies were updated
* dependencies
* puppeteer-core bumped from 22.6.1 to 22.6.2
## [22.6.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-v22.6.0...puppeteer-v22.6.1) (2024-03-25)
### Miscellaneous Chores
* **puppeteer:** Synchronize puppeteer versions
### Dependencies
* The following workspace dependencies were updated
* dependencies
* puppeteer-core bumped from 22.6.0 to 22.6.1
## [22.6.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v22.5.0...puppeteer-v22.6.0) (2024-03-20)
### Features
* roll to Chrome 123.0.6312.58 (r1262506) ([#12110](https://github.com/puppeteer/puppeteer/issues/12110)) ([6f5b3bc](https://github.com/puppeteer/puppeteer/commit/6f5b3bc9b88c6d3204dda396f8963591ea6eb883))
### Dependencies
* The following workspace dependencies were updated
* dependencies
* puppeteer-core bumped from 22.5.0 to 22.6.0
## [22.5.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v22.4.1...puppeteer-v22.5.0) (2024-03-15)
### Miscellaneous Chores
* **puppeteer:** Synchronize puppeteer versions
### Dependencies
* The following workspace dependencies were updated
* dependencies
* puppeteer-core bumped from 22.4.1 to 22.5.0
* @puppeteer/browsers bumped from 2.1.0 to 2.2.0
## [22.4.1](https://github.com/puppeteer/puppeteer/compare/puppeteer-v22.4.0...puppeteer-v22.4.1) (2024-03-08)
### Miscellaneous Chores
* **puppeteer:** Synchronize puppeteer versions
### Dependencies
* The following workspace dependencies were updated
* dependencies
* puppeteer-core bumped from 22.4.0 to 22.4.1
## [22.4.0](https://github.com/puppeteer/puppeteer/compare/puppeteer-v22.3.0...puppeteer-v22.4.0) (2024-03-05)

View file

@ -1,6 +1,6 @@
{
"name": "puppeteer",
"version": "22.4.0",
"version": "22.6.5",
"description": "A high-level API to control headless Chrome over the DevTools Protocol",
"keywords": [
"puppeteer",
@ -124,8 +124,9 @@
"license": "Apache-2.0",
"dependencies": {
"cosmiconfig": "9.0.0",
"puppeteer-core": "22.4.0",
"@puppeteer/browsers": "2.1.0"
"puppeteer-core": "22.6.5",
"@puppeteer/browsers": "2.2.2",
"devtools-protocol": "0.0.1262051"
},
"devDependencies": {
"@types/node": "18.17.15"

View file

@ -127,6 +127,17 @@ export const getConfiguration = (): Configuration => {
downloadHost;
}
if (
Object.keys(process.env).some(key => {
return key.startsWith('npm_package_config_puppeteer_');
}) &&
configuration.logLevel === 'warn'
) {
console.warn(
`Configuring Puppeteer via npm/package.json is deprecated. Use https://pptr.dev/guides/configuration instead.`
);
}
configuration.cacheDirectory =
process.env['PUPPETEER_CACHE_DIR'] ??
process.env['npm_config_puppeteer_cache_dir'] ??

View file

@ -36,6 +36,12 @@ module.exports = {
selector:
'CallExpression[callee.object.name="it"] > MemberExpression > Identifier[name="deflake"], CallExpression[callee.object.name="it"] > MemberExpression > Identifier[name="deflakeOnly"]',
},
{
message:
'No `expect` in EventHandler. They will never throw errors',
selector:
'CallExpression[callee.property.name="on"] BlockStatement > :not(TryStatement) > ExpressionStatement > CallExpression[callee.object.callee.name="expect"]',
},
],
},
},

File diff suppressed because it is too large Load diff

View file

@ -45,7 +45,7 @@
{
"id": "chrome-bidi",
"platforms": ["linux"],
"parameters": ["chrome", "chrome-headless-shell", "webDriverBiDi"],
"parameters": ["chrome", "headless", "webDriverBiDi"],
"expectedLineCoverage": 56
}
],

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 B

View file

@ -13,7 +13,11 @@ import puppeteer from 'puppeteer-core';
executablePath: 'node',
});
} catch (error) {
if (error.message.includes('Failed to launch the browser process')) {
if (
error.message.includes(
'Browser was not found at the configured executablePath (node)'
)
) {
process.exit(0);
}
console.error(error);

View file

@ -44,7 +44,7 @@
"assets"
],
"dependencies": {
"glob": "10.3.10",
"mocha": "10.3.0"
"glob": "10.3.12",
"mocha": "10.4.0"
}
}

View file

@ -5,6 +5,8 @@
*/
import assert from 'assert';
import {spawnSync} from 'child_process';
import {existsSync} from 'fs';
import {readdir} from 'fs/promises';
import {platform} from 'os';
import {join} from 'path';
@ -49,3 +51,36 @@ import {readAsset} from './util.js';
});
}
);
describe('Firefox download', () => {
configureSandbox({
dependencies: ['@puppeteer/browsers', 'puppeteer-core', 'puppeteer'],
env: cwd => {
return {
PUPPETEER_CACHE_DIR: join(cwd, '.cache', 'puppeteer'),
PUPPETEER_SKIP_DOWNLOAD: 'true',
};
},
});
it('can download Firefox stable', async function () {
assert.ok(!existsSync(join(this.sandbox, '.cache', 'puppeteer')));
const result = spawnSync(
'npx',
['puppeteer', 'browsers', 'install', 'firefox@stable'],
{
// npx is not found without the shell flag on Windows.
shell: process.platform === 'win32',
cwd: this.sandbox,
env: {
...process.env,
PUPPETEER_CACHE_DIR: join(this.sandbox, '.cache', 'puppeteer'),
},
}
);
assert.strictEqual(result.status, 0);
const files = await readdir(join(this.sandbox, '.cache', 'puppeteer'));
assert.equal(files.length, 1);
assert.equal(files[0], 'firefox');
});
});

View file

@ -697,20 +697,13 @@ describe('AriaQueryHandler', () => {
ElementHandle<HTMLButtonElement>
>;
const ids = await getIds(found);
expect(ids).toEqual([
'node5',
'node6',
'node7',
'node8',
'node10',
'node21',
]);
expect(ids).toEqual(['node5', 'node6', 'node8', 'node10', 'node21']);
});
it('should find by role "heading"', async () => {
const {page} = await setupPage();
const found = await page.$$('aria/[role="heading"]');
const ids = await getIds(found);
expect(ids).toEqual(['shown', 'hidden', 'node11', 'node13']);
expect(ids).toEqual(['shown', 'node11', 'node13']);
});
it('should find both ignored and unignored', async () => {
const {page} = await setupPage();

View file

@ -6,7 +6,7 @@
import expect from 'expect';
import {getTestState, setupTestBrowserHooks} from './mocha-utils.js';
import {getTestState, launch, setupTestBrowserHooks} from './mocha-utils.js';
describe('Browser specs', function () {
setupTestBrowserHooks();
@ -64,6 +64,23 @@ describe('Browser specs', function () {
expect(remoteBrowser.process()).toBe(null);
await remoteBrowser.disconnect();
});
it('should keep connected after the last page is closed', async () => {
const {browser, close} = await launch({}, {createContext: false});
try {
const pages = await browser.pages();
await Promise.all(
pages.map(page => {
return page.close();
})
);
// Verify the browser is still connected.
expect(browser.connected).toBe(true);
// Verify the browser can open a new page.
await browser.newPage();
} finally {
await close();
}
});
});
describe('Browser.isConnected', () => {

View file

@ -134,7 +134,7 @@ describe('Target.createCDPSession', function () {
}
)
).rejects.toThrowError(
`Runtime.evaluate timed out. Increase the 'protocolTimeout' setting in launch/connect calls for a higher timeout if needed.`
/Increase the 'protocolTimeout' setting in launch\/connect calls for a higher timeout if needed./gi
);
});

View file

@ -67,9 +67,9 @@ describe('DevTools', function () {
return 2 * 3;
})
).toBe(6);
expect(await browser.pages()).toContainEqual(page);
expect(await browser.pages()).toContain(page);
});
it('target.page() should return a DevTools page if asPage is used', async function () {
it('target.page() should return Page when calling asPage on DevTools target', async function () {
const {puppeteer} = await getTestState({skipLaunch: true});
const originalBrowser = await launchBrowser(launchOptions);
@ -87,7 +87,8 @@ describe('DevTools', function () {
return 2 * 3;
})
).toBe(6);
expect(await browser.pages()).toContainEqual(page);
// The page won't be part of browser.pages() if a custom isPageTarget is not provided
expect(await browser.pages()).not.toContain(page);
});
it('should open devtools when "devtools: true" option is given', async () => {
const browser = await launchBrowser(

View file

@ -385,8 +385,15 @@ describe('ElementHandle specs', function () {
await page.setContent(
`<iframe name='frame' style='position: absolute; left: -100px' srcdoc="<button style='width: 10px; height: 10px;'></button>"></iframe>`
);
const frame = await page.waitForFrame(frame => {
return frame.name() === 'frame';
const frame = await page.waitForFrame(async frame => {
using element = await frame.frameElement();
if (!element) {
return false;
}
const name = await element.evaluate(frame => {
return frame.name;
});
return name === 'frame';
});
using handle = await frame.locator('button').waitHandle();
@ -395,8 +402,15 @@ describe('ElementHandle specs', function () {
await page.setContent(
`<iframe name='frame2' style='position: absolute; top: -100px' srcdoc="<button style='width: 10px; height: 10px;'></button>"></iframe>`
);
const frame2 = await page.waitForFrame(frame => {
return frame.name() === 'frame2';
const frame2 = await page.waitForFrame(async frame => {
using element = await frame.frameElement();
if (!element) {
return false;
}
const name = await element.evaluate(frame => {
return frame.name;
});
return name === 'frame2';
});
using handle2 = await frame2.locator('button').waitHandle();

View file

@ -7,6 +7,7 @@
import expect from 'expect';
import {CDPSession} from 'puppeteer-core/internal/api/CDPSession.js';
import type {Frame} from 'puppeteer-core/internal/api/Frame.js';
import {assert} from 'puppeteer-core/internal/util/assert.js';
import {getTestState, setupTestBrowserHooks} from './mocha-utils.js';
import {
@ -78,7 +79,7 @@ describe('Frame specs', function () {
const {page, server} = await getTestState();
await page.goto(server.PREFIX + '/frames/nested-frames.html');
expect(dumpFrames(page.mainFrame())).toEqual([
expect(await 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)',
@ -232,23 +233,6 @@ describe('Frame specs', function () {
expect(page.frames()).toHaveLength(2);
expect(page.frames()[1]!.url()).toBe(server.EMPTY_PAGE);
});
it('should report frame.name()', async () => {
const {page, server} = await getTestState();
await 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 => {
return (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} = await getTestState();
@ -306,4 +290,35 @@ describe('Frame specs', function () {
expect(page.mainFrame().client).toBeInstanceOf(CDPSession);
});
});
describe('Frame.prototype.frameElement', function () {
it('should work', async () => {
const {page, server} = await getTestState();
await 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 => {
return (frame.onload = x);
});
}, server.EMPTY_PAGE);
using frame0 = await page.frames()[0]?.frameElement();
assert(!frame0);
using frame1 = await page.frames()[1]?.frameElement();
assert(frame1);
using frame2 = await page.frames()[2]?.frameElement();
assert(frame2);
const name1 = await frame1.evaluate(frame => {
return frame.id;
});
expect(name1).toBe('theFrameId');
const name2 = await frame2.evaluate(frame => {
return frame.name;
});
expect(name2).toBe('theFrameName');
});
});
});

View file

@ -326,6 +326,16 @@ describe('JSHandle', function () {
'JSHandle@proxy'
);
});
it('should work with window subtypes', async () => {
const {page} = await getTestState();
expect((await page.evaluateHandle('window')).toString()).toBe(
'JSHandle@window'
);
expect((await page.evaluateHandle('globalThis')).toString()).toBe(
'JSHandle@window'
);
});
});
describe('JSHandle[Symbol.dispose]', () => {

View file

@ -186,8 +186,15 @@ describe('Keyboard', function () {
await page.setContent(`
<iframe srcdoc="<iframe name='test' srcdoc='<textarea></textarea>'></iframe>"</iframe>
`);
const frame = await page.waitForFrame(frame => {
return frame.name() === 'test';
const frame = await page.waitForFrame(async frame => {
using element = await frame.frameElement();
if (!element) {
return false;
}
const name = await element.evaluate(frame => {
return frame.name;
});
return name === 'test';
});
await frame.focus('textarea');

View file

@ -116,6 +116,30 @@ describe('Launcher specs', function () {
const {close} = await launch({});
await close();
});
it('can launch multiple instances without node warnings', async () => {
const instances = [];
let warning = null;
const warningHandler: NodeJS.WarningListener = w => {
return (warning = w);
};
process.on('warning', warningHandler);
process.setMaxListeners(1);
try {
for (let i = 0; i < 2; i++) {
instances.push(launch({}));
}
await Promise.all(
(await Promise.all(instances)).map(instance => {
return instance.close();
})
);
} finally {
process.setMaxListeners(10);
}
process.off('warning', warningHandler);
expect(warning).toBe(null);
});
it('should have default url when launching browser', async function () {
const {browser, close} = await launch({}, {createContext: false});
try {
@ -166,7 +190,9 @@ describe('Launcher specs', function () {
}).catch(error => {
return (waitError = error);
});
expect(waitError.message).toContain('Failed to launch');
expect(waitError.message).toBe(
'Browser was not found at the configured executablePath (random-invalid-path)'
);
});
it('userDataDir option', async () => {
const userDataDir = await mkdtemp(TMP_FOLDER);
@ -591,6 +617,20 @@ describe('Launcher specs', function () {
});
expect(error.message).toContain('either pipe or debugging port');
});
it('throws an error if executable path is not valid with pipe=true', async () => {
const options = {
executablePath: '/tmp/does-not-exist',
pipe: true,
};
let error!: Error;
await launch(options).catch(error_ => {
return (error = error_);
});
expect(error.message).toContain(
'Browser was not found at the configured executablePath (/tmp/does-not-exist)'
);
});
});
describe('Puppeteer.launch', function () {
@ -793,7 +833,7 @@ describe('Launcher specs', function () {
const restoredPage = pages.find(page => {
return page.url() === server.PREFIX + '/frames/nested-frames.html';
})!;
expect(dumpFrames(restoredPage.mainFrame())).toEqual([
expect(await 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)',

View file

@ -112,6 +112,27 @@ describe('navigation', function () {
const response = await page.goto(server.PREFIX + '/grid.html');
expect(response!.status()).toBe(200);
});
it('should work when reload causes history API in beforeunload', async () => {
const {page, server} = await getTestState();
await page.goto(server.EMPTY_PAGE);
await page.evaluate(() => {
window.addEventListener(
'beforeunload',
() => {
return history.replaceState(null, 'initial', window.location.href);
},
false
);
});
await page.reload();
// Evaluate still works.
expect(
await page.evaluate(() => {
return 1;
})
).toBe(1);
});
it('should navigate to empty page with networkidle0', async () => {
const {page, server} = await getTestState();

View file

@ -722,7 +722,7 @@ describe('network', function () {
} catch (error) {
// In headful, an error is thrown instead of 401.
if (
!(error as Error).message.startsWith(
!(error as Error).message?.includes(
'net::ERR_INVALID_AUTH_CREDENTIALS'
)
) {
@ -772,7 +772,7 @@ describe('network', function () {
} catch (error) {
// In headful, an error is thrown instead of 401.
if (
!(error as Error).message.startsWith(
!(error as Error).message?.includes(
'net::ERR_INVALID_AUTH_CREDENTIALS'
)
) {

View file

@ -506,11 +506,14 @@ describe('Page', function () {
console.log(1, 2, 3, globalThis);
});
const log = await logPromise;
expect(log.text()).toBe('1 2 3 JSHandle@object');
expect(log.text()).atLeastOneToContain([
'1 2 3 JSHandle@object',
'1 2 3 JSHandle@window',
]);
expect(log.args()).toHaveLength(4);
expect(await (await log.args()[3]!.getProperty('test')).jsonValue()).toBe(
1
);
using property = await log.args()[3]!.getProperty('test');
expect(await property.jsonValue()).toBe(1);
});
it('should trigger correct Log', async () => {
const {page, server, isChrome} = await getTestState();
@ -1210,13 +1213,15 @@ describe('Page', function () {
expect(result).toBe(36);
await page.removeExposedFunction('compute');
let error: Error | null = null;
await page
const error = await page
.evaluate(async function () {
return (globalThis as any).compute(9, 4);
})
.catch(_error => {
return (error = _error);
.then(() => {
return null;
})
.catch(error => {
return error;
});
expect(error).toBeTruthy();
});

View file

@ -23,8 +23,7 @@ describe('cooperative request interception', function () {
describe('Page.setRequestInterception', function () {
const expectedActions: ActionResult[] = ['abort', 'continue', 'respond'];
while (expectedActions.length > 0) {
const expectedAction = expectedActions.pop();
for (const expectedAction of expectedActions) {
it(`should cooperatively ${expectedAction} by priority`, async () => {
const {page, server} = await getTestState();
@ -94,24 +93,36 @@ describe('cooperative request interception', function () {
const {page, server} = await getTestState();
await page.setRequestInterception(true);
let requestError;
page.on('request', request => {
if (isFavicon(request)) {
void request.continue({}, 0);
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');
void request.continue({}, 0);
try {
expect(request).toBeTruthy();
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()!.url()).toBe('about:blank');
expect(request.frame() === page.mainFrame()).toBe(true);
} catch (error) {
requestError = error;
} finally {
void request.continue({}, 0);
}
});
const response = (await page.goto(server.EMPTY_PAGE))!;
expect(response!.ok()).toBe(true);
expect(response!.remoteAddress().port).toBe(server.PORT);
if (requestError) {
throw requestError;
}
expect(response.ok()).toBe(true);
expect(response.remoteAddress().port).toBe(server.PORT);
});
// @see https://github.com/puppeteer/puppeteer/pull/3105
it('should work when POST is redirected with 302', async () => {
@ -141,16 +152,24 @@ describe('cooperative request interception', function () {
server.setRedirect('/rrredirect', '/empty.html');
await page.setRequestInterception(true);
let requestError;
page.on('request', request => {
const headers = Object.assign({}, request.headers(), {
foo: 'bar',
});
void request.continue({headers}, 0);
expect(request.continueRequestOverrides()).toEqual({headers});
try {
expect(request.continueRequestOverrides()).toEqual({headers});
} catch (error) {
requestError = error;
}
});
// Make sure that the goto does not time out.
await page.goto(server.PREFIX + '/rrredirect');
if (requestError) {
throw requestError;
}
});
// @see https://github.com/puppeteer/puppeteer/issues/4743
it('should be able to remove headers', async () => {
@ -220,11 +239,20 @@ describe('cooperative request interception', function () {
foo: 'bar',
});
await page.setRequestInterception(true);
let requestError;
page.on('request', request => {
expect(request.headers()['foo']).toBe('bar');
void request.continue({}, 0);
try {
expect(request.headers()['foo']).toBe('bar');
} catch (error) {
requestError = error;
} finally {
void request.continue({}, 0);
}
});
const response = await page.goto(server.EMPTY_PAGE);
if (requestError) {
throw requestError;
}
expect(response!.ok()).toBe(true);
});
// @see https://github.com/puppeteer/puppeteer/issues/4337
@ -250,11 +278,20 @@ describe('cooperative request interception', function () {
await page.setExtraHTTPHeaders({referer: server.EMPTY_PAGE});
await page.setRequestInterception(true);
let requestError;
page.on('request', request => {
expect(request.headers()['referer']).toBe(server.EMPTY_PAGE);
void request.continue({}, 0);
try {
expect(request.headers()['referer']).toBe(server.EMPTY_PAGE);
} catch (error) {
requestError = error;
} finally {
void request.continue({}, 0);
}
});
const response = await page.goto(server.EMPTY_PAGE);
if (requestError) {
throw requestError;
}
expect(response!.ok()).toBe(true);
});
it('should be abortable', async () => {
@ -340,7 +377,7 @@ describe('cooperative request interception', function () {
if (isChrome) {
expect(error.message).toContain('net::ERR_FAILED');
} else {
expect(error.message).toContain('NS_ERROR_FAILURE');
expect(error.message).toContain('NS_ERROR_ABORT');
}
});
it('should work with redirects', async () => {
@ -947,14 +984,26 @@ describe('cooperative request interception', function () {
page.on('request', request => {
void request.continue();
});
let requestError;
page.on('request', request => {
expect(request.isInterceptResolutionHandled()).toBeTruthy();
try {
expect(request.isInterceptResolutionHandled()).toBeTruthy();
} catch (error) {
requestError = error;
}
});
page.on('request', request => {
const {action} = request.interceptResolutionState();
expect(action).toBe(InterceptResolutionAction.AlreadyHandled);
try {
expect(action).toBe(InterceptResolutionAction.AlreadyHandled);
} catch (error) {
requestError = error;
}
});
await page.goto(server.EMPTY_PAGE);
if (requestError) {
throw requestError;
}
});
});
});

View file

@ -22,23 +22,34 @@ describe('request interception', function () {
const {page, server} = await getTestState();
await page.setRequestInterception(true);
let requestError;
page.on('request', request => {
if (isFavicon(request)) {
void request.continue();
return;
}
expect(request.url()).toContain('empty.html');
expect(request.headers()['user-agent']).toBeTruthy();
expect(request.headers()['accept']).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');
void request.continue();
try {
expect(request).toBeTruthy();
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()!.url()).toBe('about:blank');
expect(request.frame() === page.mainFrame()).toBe(true);
} catch (error) {
requestError = error;
} finally {
void request.continue();
}
});
const response = (await page.goto(server.EMPTY_PAGE))!;
if (requestError) {
throw requestError;
}
expect(response.ok()).toBe(true);
expect(response.remoteAddress().port).toBe(server.PORT);
});
@ -76,7 +87,11 @@ describe('request interception', function () {
});
void request.continue({headers});
});
await page.goto(server.PREFIX + '/rrredirect');
const [request] = await Promise.all([
server.waitForRequest('/empty.html'),
page.goto(server.PREFIX + '/rrredirect'),
]);
expect(request.headers['foo']).toBe('bar');
});
// @see https://github.com/puppeteer/puppeteer/issues/4743
it('should be able to remove headers', async () => {
@ -162,11 +177,21 @@ describe('request interception', function () {
foo: 'bar',
});
await page.setRequestInterception(true);
let requestError;
page.on('request', request => {
expect(request.headers()['foo']).toBe('bar');
void request.continue();
try {
expect(request.headers()['foo']).toBe('bar');
} catch (error) {
requestError = error;
} finally {
void request.continue();
}
});
const response = (await page.goto(server.EMPTY_PAGE))!;
if (requestError) {
throw requestError;
}
expect(response.ok()).toBe(true);
});
// @see https://github.com/puppeteer/puppeteer/issues/4337
@ -192,11 +217,13 @@ describe('request interception', function () {
await page.setExtraHTTPHeaders({referer: server.EMPTY_PAGE});
await page.setRequestInterception(true);
page.on('request', request => {
expect(request.headers()['referer']).toBe(server.EMPTY_PAGE);
let request!: HTTPRequest;
page.on('request', req => {
request = req;
void request.continue();
});
const response = (await page.goto(server.EMPTY_PAGE))!;
expect(request.headers()['referer']).toBe(server.EMPTY_PAGE);
expect(response.ok()).toBe(true);
});
it('should be abortable', async () => {
@ -267,7 +294,7 @@ describe('request interception', function () {
if (isChrome) {
expect(error.message).toContain('net::ERR_FAILED');
} else {
expect(error.message).toContain('NS_ERROR_FAILURE');
expect(error.message).toContain('NS_ERROR_ABORT');
}
});
it('should work with redirects', async () => {
@ -493,7 +520,7 @@ describe('request interception', function () {
))!;
expect(response.status()).toBe(200);
});
it('should work wit h encoded server - 2', async () => {
it('should work with encoded server - 2', async () => {
const {page, server} = await getTestState();
// The requestWillBeSent will report URL as-is, whereas interception will

View file

@ -393,6 +393,33 @@ describe('Screenshots', function () {
await context.close();
});
it('should use element clip', async () => {
const {page} = await 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>
`);
using elementHandle = (await page.$('div'))!;
const screenshot = await elementHandle.screenshot({
clip: {
x: 10,
y: 10,
width: 20,
height: 20,
},
});
expect(screenshot).toBeGolden('screenshot-element-clip.png');
});
});
describe('Cdp', () => {

Some files were not shown because too many files have changed in this diff Show more