From 118d2aa6076d82840decca15d96b48611b08e392 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Thu, 7 May 2026 10:31:27 +0200 Subject: [PATCH 01/13] cherry-pick(#40693): chore(python): formdata path type --- docs/src/api/class-formdata.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/src/api/class-formdata.md b/docs/src/api/class-formdata.md index ad93869411125..5781fb6707fee 100644 --- a/docs/src/api/class-formdata.md +++ b/docs/src/api/class-formdata.md @@ -114,7 +114,7 @@ Field name. ### param: FormData.append.value * since: v1.44 -- `value` <[string]|[boolean]|[int]|[Path]|[Object]> +- `value` <[string]|[boolean]|[int]|[path]|[Object]> - alias: FilePayload - `name` <[string]> File name - `mimeType` <[string]> File type @@ -215,7 +215,7 @@ Field name. ### param: FormData.set.value * since: v1.18 -- `value` <[string]|[boolean]|[int]|[Path]|[Object]> +- `value` <[string]|[boolean]|[int]|[path]|[Object]> - alias: FilePayload - `name` <[string]> File name - `mimeType` <[string]> File type From 7eb6918afadfb0dd5c7e94ca9ffbddd84d8fbb39 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Thu, 7 May 2026 11:43:16 -0700 Subject: [PATCH 02/13] cherry-pick(#40710): docs: release notes v1.60 --- docs/src/release-notes-js.md | 107 +++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/docs/src/release-notes-js.md b/docs/src/release-notes-js.md index fd86883c571c2..cf1f32e9665fd 100644 --- a/docs/src/release-notes-js.md +++ b/docs/src/release-notes-js.md @@ -6,6 +6,113 @@ toc_max_heading_level: 2 import LiteYouTube from '@site/src/components/LiteYouTube'; +## Version 1.60 + +### 🌐 HAR recording on Tracing + +[`method: Tracing.startHar`] / [`method: Tracing.stopHar`] expose HAR recording as a first-class tracing API, with the same `content`, `mode` and `urlFilter` options as `recordHar`. The returned [Disposable] makes it easy to scope a recording with `await using`: + +```js +await using har = await context.tracing.startHar('trace.har'); +const page = await context.newPage(); +await page.goto('https://playwright.dev'); +// HAR is finalized when `har` goes out of scope. +``` + +### 🪝 Drop API + +New [`method: Locator.drop`] simulates an external drag-and-drop of files or clipboard-like data onto an element. Playwright dispatches `dragenter`, `dragover`, and `drop` with a synthetic [DataTransfer] in the page context — works cross-browser and is great for testing upload zones: + +```js +await page.locator('#dropzone').drop({ + files: { name: 'note.txt', mimeType: 'text/plain', buffer: Buffer.from('hello') }, +}); + +await page.locator('#dropzone').drop({ + data: { + 'text/plain': 'hello world', + 'text/uri-list': 'https://example.com', + }, +}); +``` + +### 🎯 Aria snapshots + +- [`method: PageAssertions.toMatchAriaSnapshot`] now works on a [Page], in addition to a [Locator] — equivalent to asserting against `page.locator('body')`. +- New `boxes` option on [`method: Locator.ariaSnapshot`] / [`method: Page.ariaSnapshot`] appends each element's bounding box as `[box=x,y,width,height]`, useful for AI consumption. + +### 🛑 test.abort() + +New [`method: Test.abort`] aborts the currently running test from a fixture, hook, or route handler with an optional message. Use it when you have detected an unrecoverable misuse and want to fail the test right away: + +```js +test('does not publish to the shared page', async ({ page }) => { + await page.route('**/publish', route => { + test.abort('Tests must not publish to the shared page. Use the `clone` option.'); + return route.abort(); + }); + // ... +}); +``` + +### New APIs + +#### Browser, Context and Page + +- Event [`event: Browser.context`] — fired when a new context is created on the browser. +- [BrowserContext] now mirrors lifecycle events from its pages: [`event: BrowserContext.download`], [`event: BrowserContext.frameAttached`], [`event: BrowserContext.frameDetached`], [`event: BrowserContext.frameNavigated`], [`event: BrowserContext.pageClose`], [`event: BrowserContext.pageLoad`]. + +#### Locators and Assertions + +- New option `description` in [`method: Page.getByRole`] / [`method: Locator.getByRole`] / [`method: Frame.getByRole`] / [`method: FrameLocator.getByRole`] for matching the [accessible description](https://www.w3.org/TR/wai-aria-1.2/#dfn-accessible-description). +- New option `pseudo` in [`method: LocatorAssertions.toHaveCSS`] reads computed styles from `::before` or `::after`. +- New option `style` in [`method: Locator.highlight`] applies extra inline CSS to the highlight overlay, plus new [`method: Page.hideHighlight`] to clear all highlights. + +#### Network + +- [`method: WebSocketRoute.protocols`] returns the WebSocket subprotocols requested by the page. +- New option `noDefaults` in [`method: BrowserType.connectOverCDP`] disables Playwright's default overrides on the default context (download behavior, focus emulation, media emulation), so attaching to a user's daily-driver browser doesn't disturb its state. + +#### Errors and Reporting + +- New [`method: WebError.location`] mirrors [`method: ConsoleMessage.location`]. +- [`method: ConsoleMessage.location`] now exposes `line` / `column` properties (`lineNumber` / `columnNumber` are deprecated). +- New [`property: TestInfoError.errorContext`] surfaces additional diagnostic context, such as the aria snapshot of the receiver at the time of an `expect(...)` matcher failure. +- [`method: Reporter.onError`] now receives a `workerInfo` argument with details about the worker for fixture teardown errors. + +#### Test runner + +- New `{testFileBaseName}` token in [`property: TestProject.snapshotPathTemplate`] — file name without extension. +- Test runner now errors when a config tries to override a non-option fixture, and rejects `workers: 0` or negative values. + +### 🛠️ Other improvements + +- HTML reporter: + - `npx playwright show-report` accepts `.zip` files directly — no need to unzip first. + - Steps that contain attachments inside nested children show an indicator on the parent step. + - The `repeatEachIndex` is shown in the test header when non-zero. +- Trace Viewer adds a pretty-print toggle for JSON / form request and response bodies in the network details panel. + +### Breaking Changes ⚠️ + +- Removed long-deprecated APIs: + - `Locator.ariaRef()` — use the standard [`method: Locator.ariaSnapshot`] pipeline. + - `handle` option on `BrowserContext.exposeBinding` and `Page.exposeBinding`. + - `logger` option on `BrowserType.connect` and `BrowserType.connectOverCDP` — use [tracing](./trace-viewer.md) instead. + - Context options `videosPath` / `videoSize` — use `recordVideo` instead. + +### Browser Versions + +- Chromium 148.0.7778.96 +- Mozilla Firefox 150.0.2 +- WebKit 26.4 + +This version was also tested against the following stable channels: + +- Google Chrome 147 +- Microsoft Edge 147 + + ## Version 1.59 ### 🎬 Screencast From f869f96bbe6607cc3b88b4ca96fd82f17b301b50 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Thu, 7 May 2026 15:04:59 -0700 Subject: [PATCH 03/13] chore: bump version to v1.60.0 (#40714) --- package-lock.json | 58 +++++++++---------- package.json | 2 +- .../playwright-browser-chromium/package.json | 4 +- .../playwright-browser-firefox/package.json | 4 +- .../playwright-browser-webkit/package.json | 4 +- packages/playwright-chromium/package.json | 4 +- packages/playwright-client/package.json | 2 +- packages/playwright-core/package.json | 2 +- packages/playwright-ct-core/package.json | 6 +- packages/playwright-ct-react/package.json | 4 +- packages/playwright-ct-react17/package.json | 4 +- packages/playwright-ct-vue/package.json | 4 +- packages/playwright-firefox/package.json | 4 +- packages/playwright-test/package.json | 4 +- packages/playwright-webkit/package.json | 4 +- packages/playwright/package.json | 4 +- 16 files changed, 57 insertions(+), 57 deletions(-) diff --git a/package-lock.json b/package-lock.json index 64b1613bddd68..0436d5cbaa90c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "playwright-internal", - "version": "1.60.0-next", + "version": "1.60.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "playwright-internal", - "version": "1.60.0-next", + "version": "1.60.0", "license": "Apache-2.0", "workspaces": [ "packages/*" @@ -9600,10 +9600,10 @@ "version": "0.0.0" }, "packages/playwright": { - "version": "1.60.0-next", + "version": "1.60.0", "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.60.0-next" + "playwright-core": "1.60.0" }, "bin": { "playwright": "cli.js" @@ -9617,11 +9617,11 @@ }, "packages/playwright-browser-chromium": { "name": "@playwright/browser-chromium", - "version": "1.60.0-next", + "version": "1.60.0", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.60.0-next" + "playwright-core": "1.60.0" }, "engines": { "node": ">=18" @@ -9629,11 +9629,11 @@ }, "packages/playwright-browser-firefox": { "name": "@playwright/browser-firefox", - "version": "1.60.0-next", + "version": "1.60.0", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.60.0-next" + "playwright-core": "1.60.0" }, "engines": { "node": ">=18" @@ -9641,22 +9641,22 @@ }, "packages/playwright-browser-webkit": { "name": "@playwright/browser-webkit", - "version": "1.60.0-next", + "version": "1.60.0", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.60.0-next" + "playwright-core": "1.60.0" }, "engines": { "node": ">=18" } }, "packages/playwright-chromium": { - "version": "1.60.0-next", + "version": "1.60.0", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.60.0-next" + "playwright-core": "1.60.0" }, "bin": { "playwright": "cli.js" @@ -9676,14 +9676,14 @@ "version": "0.0.0", "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.60.0-next" + "playwright-core": "1.60.0" }, "engines": { "node": ">=18" } }, "packages/playwright-core": { - "version": "1.60.0-next", + "version": "1.60.0", "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" @@ -9694,11 +9694,11 @@ }, "packages/playwright-ct-core": { "name": "@playwright/experimental-ct-core", - "version": "1.60.0-next", + "version": "1.60.0", "license": "Apache-2.0", "dependencies": { - "playwright": "1.60.0-next", - "playwright-core": "1.60.0-next", + "playwright": "1.60.0", + "playwright-core": "1.60.0", "vite": "^6.4.1" }, "engines": { @@ -9707,10 +9707,10 @@ }, "packages/playwright-ct-react": { "name": "@playwright/experimental-ct-react", - "version": "1.60.0-next", + "version": "1.60.0", "license": "Apache-2.0", "dependencies": { - "@playwright/experimental-ct-core": "1.60.0-next", + "@playwright/experimental-ct-core": "1.60.0", "@vitejs/plugin-react": "^4.2.1" }, "bin": { @@ -9722,10 +9722,10 @@ }, "packages/playwright-ct-react17": { "name": "@playwright/experimental-ct-react17", - "version": "1.60.0-next", + "version": "1.60.0", "license": "Apache-2.0", "dependencies": { - "@playwright/experimental-ct-core": "1.60.0-next", + "@playwright/experimental-ct-core": "1.60.0", "@vitejs/plugin-react": "^4.2.1" }, "bin": { @@ -9737,10 +9737,10 @@ }, "packages/playwright-ct-vue": { "name": "@playwright/experimental-ct-vue", - "version": "1.60.0-next", + "version": "1.60.0", "license": "Apache-2.0", "dependencies": { - "@playwright/experimental-ct-core": "1.60.0-next", + "@playwright/experimental-ct-core": "1.60.0", "@vitejs/plugin-vue": "^5.2.0" }, "bin": { @@ -9762,11 +9762,11 @@ } }, "packages/playwright-firefox": { - "version": "1.60.0-next", + "version": "1.60.0", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.60.0-next" + "playwright-core": "1.60.0" }, "bin": { "playwright": "cli.js" @@ -9777,10 +9777,10 @@ }, "packages/playwright-test": { "name": "@playwright/test", - "version": "1.60.0-next", + "version": "1.60.0", "license": "Apache-2.0", "dependencies": { - "playwright": "1.60.0-next" + "playwright": "1.60.0" }, "bin": { "playwright": "cli.js" @@ -9790,11 +9790,11 @@ } }, "packages/playwright-webkit": { - "version": "1.60.0-next", + "version": "1.60.0", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.60.0-next" + "playwright-core": "1.60.0" }, "bin": { "playwright": "cli.js" diff --git a/package.json b/package.json index 9b26ef6399638..6a41d8735879f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "playwright-internal", "private": true, - "version": "1.60.0-next", + "version": "1.60.0", "description": "A high-level API to automate web browsers", "repository": { "type": "git", diff --git a/packages/playwright-browser-chromium/package.json b/packages/playwright-browser-chromium/package.json index daaff35d01fc8..124758cd77a98 100644 --- a/packages/playwright-browser-chromium/package.json +++ b/packages/playwright-browser-chromium/package.json @@ -1,6 +1,6 @@ { "name": "@playwright/browser-chromium", - "version": "1.60.0-next", + "version": "1.60.0", "description": "Playwright package that automatically installs Chromium", "repository": { "type": "git", @@ -27,6 +27,6 @@ "install": "node install.js" }, "dependencies": { - "playwright-core": "1.60.0-next" + "playwright-core": "1.60.0" } } diff --git a/packages/playwright-browser-firefox/package.json b/packages/playwright-browser-firefox/package.json index ceca9ca239ac4..afdbecccfa894 100644 --- a/packages/playwright-browser-firefox/package.json +++ b/packages/playwright-browser-firefox/package.json @@ -1,6 +1,6 @@ { "name": "@playwright/browser-firefox", - "version": "1.60.0-next", + "version": "1.60.0", "description": "Playwright package that automatically installs Firefox", "repository": { "type": "git", @@ -27,6 +27,6 @@ "install": "node install.js" }, "dependencies": { - "playwright-core": "1.60.0-next" + "playwright-core": "1.60.0" } } diff --git a/packages/playwright-browser-webkit/package.json b/packages/playwright-browser-webkit/package.json index 14424ff437ebe..5f4aa571cb2bd 100644 --- a/packages/playwright-browser-webkit/package.json +++ b/packages/playwright-browser-webkit/package.json @@ -1,6 +1,6 @@ { "name": "@playwright/browser-webkit", - "version": "1.60.0-next", + "version": "1.60.0", "description": "Playwright package that automatically installs WebKit", "repository": { "type": "git", @@ -27,6 +27,6 @@ "install": "node install.js" }, "dependencies": { - "playwright-core": "1.60.0-next" + "playwright-core": "1.60.0" } } diff --git a/packages/playwright-chromium/package.json b/packages/playwright-chromium/package.json index 9bf76911f4010..e436c60b0ab74 100644 --- a/packages/playwright-chromium/package.json +++ b/packages/playwright-chromium/package.json @@ -1,6 +1,6 @@ { "name": "playwright-chromium", - "version": "1.60.0-next", + "version": "1.60.0", "description": "A high-level API to automate Chromium", "repository": { "type": "git", @@ -30,6 +30,6 @@ "install": "node install.js" }, "dependencies": { - "playwright-core": "1.60.0-next" + "playwright-core": "1.60.0" } } diff --git a/packages/playwright-client/package.json b/packages/playwright-client/package.json index 08bbe45a0b9d7..bfe1d89348359 100644 --- a/packages/playwright-client/package.json +++ b/packages/playwright-client/package.json @@ -30,6 +30,6 @@ "watch": "npm run esbuild -- --watch" }, "dependencies": { - "playwright-core": "1.60.0-next" + "playwright-core": "1.60.0" } } diff --git a/packages/playwright-core/package.json b/packages/playwright-core/package.json index b6633902a61e7..2ac39606dc94f 100644 --- a/packages/playwright-core/package.json +++ b/packages/playwright-core/package.json @@ -1,6 +1,6 @@ { "name": "playwright-core", - "version": "1.60.0-next", + "version": "1.60.0", "description": "A high-level API to automate web browsers", "repository": { "type": "git", diff --git a/packages/playwright-ct-core/package.json b/packages/playwright-ct-core/package.json index c5737fd33ead1..2de47e7abe8ed 100644 --- a/packages/playwright-ct-core/package.json +++ b/packages/playwright-ct-core/package.json @@ -1,6 +1,6 @@ { "name": "@playwright/experimental-ct-core", - "version": "1.60.0-next", + "version": "1.60.0", "description": "Playwright Component Testing Helpers", "repository": { "type": "git", @@ -26,8 +26,8 @@ } }, "dependencies": { - "playwright-core": "1.60.0-next", + "playwright-core": "1.60.0", "vite": "^6.4.1", - "playwright": "1.60.0-next" + "playwright": "1.60.0" } } diff --git a/packages/playwright-ct-react/package.json b/packages/playwright-ct-react/package.json index a8dbbc00f1680..41a773c29ab60 100644 --- a/packages/playwright-ct-react/package.json +++ b/packages/playwright-ct-react/package.json @@ -1,6 +1,6 @@ { "name": "@playwright/experimental-ct-react", - "version": "1.60.0-next", + "version": "1.60.0", "description": "Playwright Component Testing for React", "repository": { "type": "git", @@ -30,7 +30,7 @@ "./package.json": "./package.json" }, "dependencies": { - "@playwright/experimental-ct-core": "1.60.0-next", + "@playwright/experimental-ct-core": "1.60.0", "@vitejs/plugin-react": "^4.2.1" }, "bin": { diff --git a/packages/playwright-ct-react17/package.json b/packages/playwright-ct-react17/package.json index 970c6664d8cf7..216824aa203eb 100644 --- a/packages/playwright-ct-react17/package.json +++ b/packages/playwright-ct-react17/package.json @@ -1,6 +1,6 @@ { "name": "@playwright/experimental-ct-react17", - "version": "1.60.0-next", + "version": "1.60.0", "description": "Playwright Component Testing for React", "repository": { "type": "git", @@ -30,7 +30,7 @@ "./package.json": "./package.json" }, "dependencies": { - "@playwright/experimental-ct-core": "1.60.0-next", + "@playwright/experimental-ct-core": "1.60.0", "@vitejs/plugin-react": "^4.2.1" }, "bin": { diff --git a/packages/playwright-ct-vue/package.json b/packages/playwright-ct-vue/package.json index c4b64d43f61ff..40269a3550664 100644 --- a/packages/playwright-ct-vue/package.json +++ b/packages/playwright-ct-vue/package.json @@ -1,6 +1,6 @@ { "name": "@playwright/experimental-ct-vue", - "version": "1.60.0-next", + "version": "1.60.0", "description": "Playwright Component Testing for Vue", "repository": { "type": "git", @@ -30,7 +30,7 @@ "./package.json": "./package.json" }, "dependencies": { - "@playwright/experimental-ct-core": "1.60.0-next", + "@playwright/experimental-ct-core": "1.60.0", "@vitejs/plugin-vue": "^5.2.0" }, "bin": { diff --git a/packages/playwright-firefox/package.json b/packages/playwright-firefox/package.json index 2d94187169cc0..5f2fa0487c0fa 100644 --- a/packages/playwright-firefox/package.json +++ b/packages/playwright-firefox/package.json @@ -1,6 +1,6 @@ { "name": "playwright-firefox", - "version": "1.60.0-next", + "version": "1.60.0", "description": "A high-level API to automate Firefox", "repository": { "type": "git", @@ -30,6 +30,6 @@ "install": "node install.js" }, "dependencies": { - "playwright-core": "1.60.0-next" + "playwright-core": "1.60.0" } } diff --git a/packages/playwright-test/package.json b/packages/playwright-test/package.json index 0ad8fd27067ed..2898afa2fef73 100644 --- a/packages/playwright-test/package.json +++ b/packages/playwright-test/package.json @@ -1,6 +1,6 @@ { "name": "@playwright/test", - "version": "1.60.0-next", + "version": "1.60.0", "description": "A high-level API to automate web browsers", "repository": { "type": "git", @@ -30,6 +30,6 @@ }, "scripts": {}, "dependencies": { - "playwright": "1.60.0-next" + "playwright": "1.60.0" } } diff --git a/packages/playwright-webkit/package.json b/packages/playwright-webkit/package.json index cb3d2001485ca..a0640578e0976 100644 --- a/packages/playwright-webkit/package.json +++ b/packages/playwright-webkit/package.json @@ -1,6 +1,6 @@ { "name": "playwright-webkit", - "version": "1.60.0-next", + "version": "1.60.0", "description": "A high-level API to automate WebKit", "repository": { "type": "git", @@ -30,6 +30,6 @@ "install": "node install.js" }, "dependencies": { - "playwright-core": "1.60.0-next" + "playwright-core": "1.60.0" } } diff --git a/packages/playwright/package.json b/packages/playwright/package.json index cca051e44129a..ab52c2b8dfa7f 100644 --- a/packages/playwright/package.json +++ b/packages/playwright/package.json @@ -1,6 +1,6 @@ { "name": "playwright", - "version": "1.60.0-next", + "version": "1.60.0", "description": "A high-level API to automate web browsers", "repository": { "type": "git", @@ -53,7 +53,7 @@ }, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.60.0-next" + "playwright-core": "1.60.0" }, "optionalDependencies": { "fsevents": "2.3.2" From 4b3b628663031bcaaeca907e337892263524634d Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 8 May 2026 14:18:05 -0700 Subject: [PATCH 04/13] cherry-pick(#40736): Revert "feat(electron): add timeout option to electronApp.close() for force-kill escalation" --- .../electron-api/class-electronapplication.md | 10 ------- packages/playwright-client/types/types.d.ts | 13 +--------- .../playwright-core/src/electron/electron.ts | 19 +++----------- packages/playwright-core/types/types.d.ts | 13 +--------- tests/electron/electron-app-hang-on-close.js | 14 ---------- tests/electron/electron-app.spec.ts | 26 ------------------- 6 files changed, 5 insertions(+), 90 deletions(-) delete mode 100644 tests/electron/electron-app-hang-on-close.js diff --git a/docs/src/electron-api/class-electronapplication.md b/docs/src/electron-api/class-electronapplication.md index 725eef0da6e67..3006801d262f9 100644 --- a/docs/src/electron-api/class-electronapplication.md +++ b/docs/src/electron-api/class-electronapplication.md @@ -85,16 +85,6 @@ Page to retrieve the window for. Closes Electron application. -### option: ElectronApplication.close.timeout -* since: v1.60 -- `timeout` <[float]> - -Maximum time in milliseconds to wait for the Electron application to gracefully close before forcefully terminating it. -By default, [`method: ElectronApplication.close`] waits indefinitely for the application to exit, which can hang if the -app has `before-quit` handlers that prevent shutdown, leaky IPC handlers, or child processes that keep it alive. When -specified, the underlying process is force-killed (SIGKILL on POSIX, `taskkill /T /F` on Windows) if it does not exit -within the given timeout. - ## method: ElectronApplication.context * since: v1.9 - returns: <[BrowserContext]> diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index db9b9739919a0..cb4cfb3767467 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -17045,19 +17045,8 @@ export interface ElectronApplication { /** * Closes Electron application. - * @param options */ - close(options?: { - /** - * Maximum time in milliseconds to wait for the Electron application to gracefully close before forcefully terminating - * it. By default, - * [electronApplication.close([options])](https://playwright.dev/docs/api/class-electronapplication#electron-application-close) - * waits indefinitely for the application to exit, which can hang if the app has `before-quit` handlers that prevent - * shutdown, leaky IPC handlers, or child processes that keep it alive. When specified, the underlying process is - * force-killed (SIGKILL on POSIX, `taskkill /T /F` on Windows) if it does not exit within the given timeout. - */ - timeout?: number; - }): Promise; + close(): Promise; /** * This method returns browser context that can be used for setting up context-wide routing, etc. diff --git a/packages/playwright-core/src/electron/electron.ts b/packages/playwright-core/src/electron/electron.ts index 4a5d5a478563b..cc493b7ed1222 100644 --- a/packages/playwright-core/src/electron/electron.ts +++ b/packages/playwright-core/src/electron/electron.ts @@ -180,7 +180,7 @@ export class Electron implements api.Electron { const chromeMatch = await Promise.race([chromeMatchPromise, waitForXserverError]); const browser = await chromium.connectOverCDP(chromeMatch[1], { timeout: progress.timeUntilDeadline(), isLocal: true }); - app = new ElectronApplication(worker, browser, launchedProcess, kill); + app = new ElectronApplication(worker, browser, launchedProcess); await progress.race(app._initialize()); return app; } catch (error) { @@ -194,13 +194,12 @@ export class ElectronApplication extends EventEmitter implements api.ElectronApp private _worker: Worker; private _browser: Browser; private _process: childProcess.ChildProcess; - private _kill: () => Promise; private _context: BrowserContext; private _windows = new Map | undefined>(); private _appHandlePromise = new ManualPromise>(); private _closedPromise: Promise | undefined; - constructor(worker: Worker, browser: Browser, process: childProcess.ChildProcess, kill: () => Promise) { + constructor(worker: Worker, browser: Browser, process: childProcess.ChildProcess) { super(); this._worker = worker; @@ -215,7 +214,6 @@ export class ElectronApplication extends EventEmitter implements api.ElectronApp this._context.close = () => this.close(); this._process = process; - this._kill = kill; } _onClose() { @@ -251,7 +249,7 @@ export class ElectronApplication extends EventEmitter implements api.ElectronApp await this.close(); } - async close(options: { timeout?: number } = {}) { + async close() { if (!this._closedPromise) { this._closedPromise = new Promise(f => this.once(Events.ElectronApplication.Close, f)); await this._browser.close(); @@ -259,17 +257,6 @@ export class ElectronApplication extends EventEmitter implements api.ElectronApp await appHandle.evaluate(({ app }) => app.quit()).catch(() => {}); await this._worker._disconnect(); } - if (options.timeout) { - let timer: NodeJS.Timeout | undefined; - const timedOut = new Promise(resolve => { - timer = setTimeout(() => resolve(true), options.timeout); - }); - const exited = this._closedPromise.then(() => false); - const didTimeout = await Promise.race([exited, timedOut]); - clearTimeout(timer); - if (didTimeout) - await this._kill(); - } await this._closedPromise; } diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index db9b9739919a0..cb4cfb3767467 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -17045,19 +17045,8 @@ export interface ElectronApplication { /** * Closes Electron application. - * @param options */ - close(options?: { - /** - * Maximum time in milliseconds to wait for the Electron application to gracefully close before forcefully terminating - * it. By default, - * [electronApplication.close([options])](https://playwright.dev/docs/api/class-electronapplication#electron-application-close) - * waits indefinitely for the application to exit, which can hang if the app has `before-quit` handlers that prevent - * shutdown, leaky IPC handlers, or child processes that keep it alive. When specified, the underlying process is - * force-killed (SIGKILL on POSIX, `taskkill /T /F` on Windows) if it does not exit within the given timeout. - */ - timeout?: number; - }): Promise; + close(): Promise; /** * This method returns browser context that can be used for setting up context-wide routing, etc. diff --git a/tests/electron/electron-app-hang-on-close.js b/tests/electron/electron-app-hang-on-close.js deleted file mode 100644 index d469a914f2821..0000000000000 --- a/tests/electron/electron-app-hang-on-close.js +++ /dev/null @@ -1,14 +0,0 @@ -const assert = require('node:assert/strict'); -const { app } = require('electron'); - -assert(process.env.PWTEST_ELECTRON_USER_DATA_DIR, 'PWTEST_ELECTRON_USER_DATA_DIR env var is not set'); -app.setPath('appData', process.env.PWTEST_ELECTRON_USER_DATA_DIR); - -app.on('window-all-closed', e => e.preventDefault()); - -// Prevent the app from quitting gracefully to simulate a hung Electron app -// that requires force-kill escalation. -app.on('before-quit', e => e.preventDefault()); - -// Keep the event loop busy so the process never exits on its own. -setInterval(() => {}, 1000); diff --git a/tests/electron/electron-app.spec.ts b/tests/electron/electron-app.spec.ts index 41fd99a3dfc16..77dc7e0b9aeef 100644 --- a/tests/electron/electron-app.spec.ts +++ b/tests/electron/electron-app.spec.ts @@ -69,32 +69,6 @@ test('should fire close event when the app quits itself', async ({ launchElectro expect([...events].sort()).toEqual(['application(close)', 'context(close)', 'process(exit)']); }); -test('should force-kill the app when close timeout fires', async ({ launchElectronApp }) => { - const electronApp = await launchElectronApp('electron-app-hang-on-close.js'); - const events = []; - electronApp.on('close', () => events.push('application(close)')); - electronApp.process().on('exit', () => events.push('process(exit)')); - const start = Date.now(); - await electronApp.close({ timeout: 1000 }); - const elapsed = Date.now() - start; - // Should not wait much longer than the timeout itself. - expect(elapsed).toBeLessThan(10000); - expect([...events].sort()).toEqual(['application(close)', 'process(exit)']); - // A second call should be a noop and not throw. - await electronApp.close({ timeout: 1000 }); -}); - -test('should not force-kill the app when close completes within timeout', async ({ launchElectronApp }) => { - const electronApp = await launchElectronApp('electron-app.js'); - const events = []; - electronApp.on('close', () => events.push('application(close)')); - electronApp.process().on('exit', code => events.push(`process(exit:${code})`)); - await electronApp.close({ timeout: 30000 }); - expect(events).toContain('application(close)'); - // Exit code 0 indicates a graceful shutdown (force-kill would yield null). - expect(events).toContain('process(exit:0)'); -}); - test('should fire console events', async ({ launchElectronApp }) => { const electronApp = await launchElectronApp('electron-app.js'); const messages = []; From 9a9c51cb7d1b39fab51ca288e59f8ca38fd19910 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 8 May 2026 18:38:05 -0700 Subject: [PATCH 05/13] cherry-pick(#40733): chore(electron): revert #40184 (move Electron API to a separate package) --- docs/src/electron-api/class-electron.md | 167 ++-- .../electron-api/class-electronapplication.md | 2 +- package-lock.json | 15 - packages/isomorphic/protocolMetainfo.ts | 6 + packages/playwright-client/types/types.d.ts | 928 +++++++++++++++--- packages/playwright-core/index.mjs | 2 +- packages/playwright-core/src/client/api.ts | 1 + .../playwright-core/src/client/connection.ts | 3 + .../src/client/consoleMessage.ts | 4 +- .../playwright-core/src/client/electron.ts | 169 ++++ packages/playwright-core/src/client/events.ts | 6 + .../playwright-core/src/client/playwright.ts | 4 + .../playwright-core/src/electron/DEPS.list | 5 - .../playwright-core/src/electron/electron.ts | 358 ------- .../playwright-core/src/electron/loader.ts | 103 -- packages/playwright-core/src/inprocess.ts | 2 - .../playwright-core/src/protocol/validator.ts | 92 ++ packages/playwright-core/src/server/DEPS.list | 1 + .../src/server/browserContext.ts | 7 +- .../server/dispatchers/electronDispatcher.ts | 95 ++ .../server/dispatchers/jsHandleDispatcher.ts | 3 +- .../dispatchers/playwrightDispatcher.ts | 2 + .../src/server/electron/DEPS.list | 6 + .../src/server/electron/electron.ts | 329 +++++++ .../src/server/electron/loader.ts | 54 + .../playwright-core/src/server/playwright.ts | 3 + packages/playwright-core/types/types.d.ts | 928 +++++++++++++++--- packages/playwright-electron/.npmignore | 8 - packages/playwright-electron/README.md | 170 ---- .../class-electronfixtures.md | 202 ---- packages/playwright-electron/index.d.ts | 65 -- packages/playwright-electron/index.js | 17 - packages/playwright-electron/package.json | 27 - packages/playwright-electron/src/DEPS.list | 2 - packages/playwright-electron/src/index.ts | 46 - packages/playwright-electron/types.d.ts | 17 - packages/protocol/spec/electron.yml | 140 +++ packages/protocol/spec/playwright.yml | 1 + packages/protocol/src/channels.d.ts | 179 ++++ tests/electron/electron-app.spec.ts | 2 +- tests/electron/electron-screenshot.spec.ts | 32 - tests/electron/electron-tracing.spec.ts | 16 + tests/electron/electronTest.ts | 6 +- tests/installation/globalSetup.ts | 1 - tests/library/channels.spec.ts | 11 + tests/page/page-leaks.spec.ts | 2 +- utils/build/build.js | 32 +- utils/doclint/cli.js | 1 + utils/generate_types/overrides.d.ts | 21 +- 49 files changed, 2804 insertions(+), 1489 deletions(-) create mode 100644 packages/playwright-core/src/client/electron.ts delete mode 100644 packages/playwright-core/src/electron/DEPS.list delete mode 100644 packages/playwright-core/src/electron/electron.ts delete mode 100644 packages/playwright-core/src/electron/loader.ts create mode 100644 packages/playwright-core/src/server/dispatchers/electronDispatcher.ts create mode 100644 packages/playwright-core/src/server/electron/DEPS.list create mode 100644 packages/playwright-core/src/server/electron/electron.ts create mode 100644 packages/playwright-core/src/server/electron/loader.ts delete mode 100644 packages/playwright-electron/.npmignore delete mode 100644 packages/playwright-electron/README.md delete mode 100644 packages/playwright-electron/class-electronfixtures.md delete mode 100644 packages/playwright-electron/index.d.ts delete mode 100644 packages/playwright-electron/index.js delete mode 100644 packages/playwright-electron/package.json delete mode 100644 packages/playwright-electron/src/DEPS.list delete mode 100644 packages/playwright-electron/src/index.ts delete mode 100644 packages/playwright-electron/types.d.ts create mode 100644 packages/protocol/spec/electron.yml delete mode 100644 tests/electron/electron-screenshot.spec.ts diff --git a/docs/src/electron-api/class-electron.md b/docs/src/electron-api/class-electron.md index 3c11689c40243..1df55a214a252 100644 --- a/docs/src/electron-api/class-electron.md +++ b/docs/src/electron-api/class-electron.md @@ -2,10 +2,16 @@ * since: v1.9 * langs: js -Playwright has **experimental** support for Electron automation, exposed as `_electron`. An example of the Electron automation script would be: +Playwright has **experimental** support for Electron automation. You can access electron namespace via: ```js -import { _electron as electron } from 'playwright'; +const { _electron } = require('playwright'); +``` + +An example of the Electron automation script would be: + +```js +const { _electron: electron } = require('playwright'); (async () => { // Launch Electron app. @@ -45,109 +51,6 @@ If you are not able to launch Electron and it will end up in timeouts during lau * Ensure that `nodeCliInspect` ([FuseV1Options.EnableNodeCliInspectArguments](https://www.electronjs.org/docs/latest/tutorial/fuses#nodecliinspect)) fuse is **not** set to `false`. -**Migrating from v1.59** - -A number of launch options have been removed after v1.59. See below for alternatives. - -* `recordHar` - use [`method: Tracing.startHar`]. - ```js - const electronApp = await electron.launch({ args: ['main.js'] }); - await electronApp.context().tracing.startHar('network.har'); - // ... drive the app ... - await electronApp.context().tracing.stopHar(); - await electronApp.close(); - ``` - -* `recordVideo` - use [`method: Screencast.start`] on each window. - ```js - const electronApp = await electron.launch({ args: ['main.js'] }); - const window = await electronApp.firstWindow(); - await window.screencast.start({ path: 'video.webm' }); - // ... drive the window ... - await window.screencast.stop(); - await electronApp.close(); - ``` - -* `colorScheme` - use [`method: Page.emulateMedia`] on each window. - ```js - const window = await electronApp.firstWindow(); - await window.emulateMedia({ colorScheme: 'dark' }); - ``` - -* `extraHTTPHeaders` - use [`method: BrowserContext.setExtraHTTPHeaders`]. - ```js - await electronApp.context().setExtraHTTPHeaders({ 'X-My-Header': 'value' }); - ``` - -* `geolocation` - use [`method: BrowserContext.setGeolocation`]. - ```js - await electronApp.context().setGeolocation({ latitude: 48.858455, longitude: 2.294474 }); - ``` - -* `httpCredentials` - use [`method: BrowserContext.setHTTPCredentials`]. - ```js - await electronApp.context().setHTTPCredentials({ username: 'user', password: 'pass' }); - ``` - -* `offline` - use [`method: BrowserContext.setOffline`]. - ```js - await electronApp.context().setOffline(true); - ``` - -* `bypassCSP` - disable CSP at the `BrowserWindow` level via Electron's [web preferences](https://www.electronjs.org/docs/latest/api/structures/web-preferences). Note that `webSecurity: false` also disables CORS and the Same-Origin Policy. - - ```js - const win = new BrowserWindow({ - webPreferences: { - webSecurity: false, - }, - }); - ``` - -* `ignoreHTTPSErrors` - - There are several ways to relax HTTPS checks in Electron. Pick the one that matches the scope you need. - - Per-window, allow mixed content through [web preferences](https://www.electronjs.org/docs/latest/api/structures/web-preferences): - - ```js - const win = new BrowserWindow({ - webPreferences: { - allowRunningInsecureContent: true, - }, - }); - ``` - - Process-wide, ignore certificate errors via Chromium command-line switches - (must run before the `ready` event): - - ```js - const { app } = require('electron'); - app.commandLine.appendSwitch('ignore-certificate-errors'); - // Optional: also ignore localhost certificate errors when testing on an IP. - app.commandLine.appendSwitch('allow-insecure-localhost', 'true'); - ``` - - Per-request, accept the certificate manually via the - [`certificate-error`](https://www.electronjs.org/docs/latest/api/app#event-certificate-error) - event: - - ```js - app.on('certificate-error', (event, webContents, url, error, certificate, callback) => { - event.preventDefault(); - callback(true); - }); - ``` - -* `timezoneId` - set an environment variable at the very top of the main file, before any other logic or Chromium windows are initialized. - ```js - // main.js - process.env.TZ = 'Europe/London'; - - const { app } = require('electron'); - // ... rest of your app logic - ``` - ## async method: Electron.launch * since: v1.9 - returns: <[ElectronApplication]> @@ -186,5 +89,59 @@ Specifies environment variables that will be visible to Electron. Defaults to `p Maximum time in milliseconds to wait for the application to start. Defaults to `30000` (30 seconds). Pass `0` to disable timeout. +### option: Electron.launch.acceptdownloads = %%-context-option-acceptdownloads-%% +* since: v1.12 + +### option: Electron.launch.bypassCSP = %%-context-option-bypasscsp-%% +* since: v1.12 + +### option: Electron.launch.colorScheme = %%-context-option-colorscheme-%% +* since: v1.12 + +### option: Electron.launch.extraHTTPHeaders = %%-context-option-extrahttpheaders-%% +* since: v1.12 + +### option: Electron.launch.geolocation = %%-context-option-geolocation-%% +* since: v1.12 + +### option: Electron.launch.httpcredentials = %%-context-option-httpcredentials-%% +* since: v1.12 + +### option: Electron.launch.ignoreHTTPSErrors = %%-context-option-ignorehttpserrors-%% +* since: v1.12 + +### option: Electron.launch.locale = %%-context-option-locale-%% +* since: v1.12 + +### option: Electron.launch.offline = %%-context-option-offline-%% +* since: v1.12 + +### option: Electron.launch.recordhar = %%-context-option-recordhar-%% +* since: v1.12 + +### option: Electron.launch.recordharpath = %%-context-option-recordhar-path-%% +* since: v1.12 + +### option: Electron.launch.recordHarOmitContent = %%-context-option-recordhar-omit-content-%% +* since: v1.12 + +### option: Electron.launch.recordvideo = %%-context-option-recordvideo-%% +* since: v1.12 + +### option: Electron.launch.recordvideodir = %%-context-option-recordvideo-dir-%% +* since: v1.12 + +### option: Electron.launch.recordvideosize = %%-context-option-recordvideo-size-%% +* since: v1.12 + +### option: Electron.launch.timezoneId = %%-context-option-timezoneid-%% +* since: v1.12 + +### option: Electron.launch.tracesDir = %%-browser-option-tracesdir-%% +* since: v1.36 + +### option: Electron.launch.artifactsDir = %%-browser-option-artifactsdir-%% +* since: v1.59 + ### option: Electron.launch.chromiumSandbox = %%-browser-option-chromiumsandbox-%% * since: v1.59 diff --git a/docs/src/electron-api/class-electronapplication.md b/docs/src/electron-api/class-electronapplication.md index 3006801d262f9..87e7975b352d7 100644 --- a/docs/src/electron-api/class-electronapplication.md +++ b/docs/src/electron-api/class-electronapplication.md @@ -7,7 +7,7 @@ obtain the application instance. This instance you can control main electron pro as well as work with Electron windows: ```js -import { _electron as electron } from 'playwright'; +const { _electron: electron } = require('playwright'); (async () => { // Launch Electron app. diff --git a/package-lock.json b/package-lock.json index 0436d5cbaa90c..c19b5c6b955b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2044,10 +2044,6 @@ "resolved": "packages/dashboard", "link": true }, - "node_modules/@playwright/electron": { - "resolved": "packages/playwright-electron", - "link": true - }, "node_modules/@playwright/experimental-ct-core": { "resolved": "packages/playwright-ct-core", "link": true @@ -9750,17 +9746,6 @@ "node": ">=18" } }, - "packages/playwright-electron": { - "name": "@playwright/electron", - "version": "0.0.0", - "license": "Apache-2.0", - "dependencies": { - "playwright": "*" - }, - "engines": { - "node": ">=18" - } - }, "packages/playwright-firefox": { "version": "1.60.0", "hasInstallScript": true, diff --git a/packages/isomorphic/protocolMetainfo.ts b/packages/isomorphic/protocolMetainfo.ts index 6fac0420d9486..8a0dcb4ab1e11 100644 --- a/packages/isomorphic/protocolMetainfo.ts +++ b/packages/isomorphic/protocolMetainfo.ts @@ -115,10 +115,16 @@ export const methodMetainfo = new Map([ ['EventTarget.waitForEventInfo', { title: 'Wait for event "{info.event}"', snapshot: true, }], ['AndroidDevice.waitForEventInfo', { title: 'Wait for event "{info.event}"', snapshot: true, }], ['BrowserContext.waitForEventInfo', { title: 'Wait for event "{info.event}"', snapshot: true, }], + ['ElectronApplication.waitForEventInfo', { title: 'Wait for event "{info.event}"', snapshot: true, }], ['WebSocket.waitForEventInfo', { title: 'Wait for event "{info.event}"', snapshot: true, }], ['Page.waitForEventInfo', { title: 'Wait for event "{info.event}"', snapshot: true, }], ['Debugger.waitForEventInfo', { title: 'Wait for event "{info.event}"', snapshot: true, }], ['Worker.waitForEventInfo', { title: 'Wait for event "{info.event}"', snapshot: true, }], + ['Electron.launch', { title: 'Launch electron', }], + ['ElectronApplication.browserWindow', { internal: true, }], + ['ElectronApplication.evaluateExpression', { title: 'Evaluate', }], + ['ElectronApplication.evaluateExpressionHandle', { title: 'Evaluate', }], + ['ElectronApplication.updateSubscription', { internal: true, }], ['Frame.evalOnSelector', { title: 'Evaluate', snapshot: true, pause: true, }], ['Frame.evalOnSelectorAll', { title: 'Evaluate', snapshot: true, pause: true, }], ['Frame.addScriptTag', { title: 'Add script tag', snapshot: true, pause: true, }], diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index cb4cfb3767467..f3488dd71ad80 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -16702,6 +16702,450 @@ class TimeoutError extends Error { export const devices: Devices; +//@ts-ignore this will be any if electron is not installed +type ElectronType = typeof import('electron'); + +/** + * Electron application representation. You can use + * [electron.launch([options])](https://playwright.dev/docs/api/class-electron#electron-launch) to obtain the + * application instance. This instance you can control main electron process as well as work with Electron windows: + * + * ```js + * const { _electron: electron } = require('playwright'); + * + * (async () => { + * // Launch Electron app. + * const electronApp = await electron.launch({ args: ['main.js'] }); + * + * // Evaluation expression in the Electron context. + * const appPath = await electronApp.evaluate(async ({ app }) => { + * // This runs in the main Electron process, parameter here is always + * // the result of the require('electron') in the main app script. + * return app.getAppPath(); + * }); + * console.log(appPath); + * + * // Get the first window that the app opens, wait if necessary. + * const window = await electronApp.firstWindow(); + * // Print the title. + * console.log(await window.title()); + * // Capture a screenshot. + * await window.screenshot({ path: 'intro.png' }); + * // Direct Electron console to Node terminal. + * window.on('console', console.log); + * // Click button. + * await window.click('text=Click me'); + * // Exit app. + * await electronApp.close(); + * })(); + * ``` + * + */ +export interface ElectronApplication { + /** + * Returns the return value of + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-option-expression). + * + * If the function passed to the + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * returns a [Promise], then + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * would wait for the promise to resolve and return its value. + * + * If the function passed to the + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * returns a non-[Serializable] value, then + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * returns `undefined`. Playwright also supports transferring some additional values that are not serializable by + * `JSON`: `-0`, `NaN`, `Infinity`, `-Infinity`. + * @param pageFunction Function to be evaluated in the main Electron process. + * @param arg Optional argument to pass to + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-option-expression). + */ + /** + * Returns the return value of + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-option-expression). + * + * If the function passed to the + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * returns a [Promise], then + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * would wait for the promise to resolve and return its value. + * + * If the function passed to the + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * returns a non-[Serializable] value, then + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * returns `undefined`. Playwright also supports transferring some additional values that are not serializable by + * `JSON`: `-0`, `NaN`, `Infinity`, `-Infinity`. + * @param pageFunction Function to be evaluated in the main Electron process. + * @param arg Optional argument to pass to + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-option-expression). + */ + evaluate(pageFunction: PageFunctionOn, arg: Arg): Promise; + /** + * Returns the return value of + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-option-expression). + * + * If the function passed to the + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * returns a [Promise], then + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * would wait for the promise to resolve and return its value. + * + * If the function passed to the + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * returns a non-[Serializable] value, then + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * returns `undefined`. Playwright also supports transferring some additional values that are not serializable by + * `JSON`: `-0`, `NaN`, `Infinity`, `-Infinity`. + * @param pageFunction Function to be evaluated in the main Electron process. + * @param arg Optional argument to pass to + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-option-expression). + */ + /** + * Returns the return value of + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-option-expression). + * + * If the function passed to the + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * returns a [Promise], then + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * would wait for the promise to resolve and return its value. + * + * If the function passed to the + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * returns a non-[Serializable] value, then + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * returns `undefined`. Playwright also supports transferring some additional values that are not serializable by + * `JSON`: `-0`, `NaN`, `Infinity`, `-Infinity`. + * @param pageFunction Function to be evaluated in the main Electron process. + * @param arg Optional argument to pass to + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-option-expression). + */ + evaluate(pageFunction: PageFunctionOn, arg?: any): Promise; + + /** + * Returns the return value of + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle-option-expression) + * as a [JSHandle](https://playwright.dev/docs/api/class-jshandle). + * + * The only difference between + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * and + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) + * is that + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) + * returns [JSHandle](https://playwright.dev/docs/api/class-jshandle). + * + * If the function passed to the + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) + * returns a [Promise], then + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) + * would wait for the promise to resolve and return its value. + * @param pageFunction Function to be evaluated in the main Electron process. + * @param arg Optional argument to pass to + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle-option-expression). + */ + /** + * Returns the return value of + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle-option-expression) + * as a [JSHandle](https://playwright.dev/docs/api/class-jshandle). + * + * The only difference between + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * and + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) + * is that + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) + * returns [JSHandle](https://playwright.dev/docs/api/class-jshandle). + * + * If the function passed to the + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) + * returns a [Promise], then + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) + * would wait for the promise to resolve and return its value. + * @param pageFunction Function to be evaluated in the main Electron process. + * @param arg Optional argument to pass to + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle-option-expression). + */ + evaluateHandle(pageFunction: PageFunctionOn, arg: Arg): Promise>; + /** + * Returns the return value of + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle-option-expression) + * as a [JSHandle](https://playwright.dev/docs/api/class-jshandle). + * + * The only difference between + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * and + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) + * is that + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) + * returns [JSHandle](https://playwright.dev/docs/api/class-jshandle). + * + * If the function passed to the + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) + * returns a [Promise], then + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) + * would wait for the promise to resolve and return its value. + * @param pageFunction Function to be evaluated in the main Electron process. + * @param arg Optional argument to pass to + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle-option-expression). + */ + /** + * Returns the return value of + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle-option-expression) + * as a [JSHandle](https://playwright.dev/docs/api/class-jshandle). + * + * The only difference between + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * and + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) + * is that + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) + * returns [JSHandle](https://playwright.dev/docs/api/class-jshandle). + * + * If the function passed to the + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) + * returns a [Promise], then + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) + * would wait for the promise to resolve and return its value. + * @param pageFunction Function to be evaluated in the main Electron process. + * @param arg Optional argument to pass to + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle-option-expression). + */ + evaluateHandle(pageFunction: PageFunctionOn, arg?: any): Promise>; + /** + * This event is issued when the application process has been terminated. + */ + on(event: 'close', listener: () => any): this; + + /** + * Emitted when JavaScript within the Electron main process calls one of console API methods, e.g. `console.log` or + * `console.dir`. + * + * The arguments passed into `console.log` are available on the + * [ConsoleMessage](https://playwright.dev/docs/api/class-consolemessage) event handler argument. + * + * **Usage** + * + * ```js + * electronApp.on('console', async msg => { + * const values = []; + * for (const arg of msg.args()) + * values.push(await arg.jsonValue()); + * console.log(...values); + * }); + * await electronApp.evaluate(() => console.log('hello', 5, { foo: 'bar' })); + * ``` + * + */ + on(event: 'console', listener: (consoleMessage: ConsoleMessage) => any): this; + + /** + * This event is issued for every window that is created **and loaded** in Electron. It contains a + * [Page](https://playwright.dev/docs/api/class-page) that can be used for Playwright automation. + */ + on(event: 'window', listener: (page: Page) => any): this; + + /** + * Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event. + */ + once(event: 'close', listener: () => any): this; + + /** + * Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event. + */ + once(event: 'console', listener: (consoleMessage: ConsoleMessage) => any): this; + + /** + * Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event. + */ + once(event: 'window', listener: (page: Page) => any): this; + + /** + * This event is issued when the application process has been terminated. + */ + addListener(event: 'close', listener: () => any): this; + + /** + * Emitted when JavaScript within the Electron main process calls one of console API methods, e.g. `console.log` or + * `console.dir`. + * + * The arguments passed into `console.log` are available on the + * [ConsoleMessage](https://playwright.dev/docs/api/class-consolemessage) event handler argument. + * + * **Usage** + * + * ```js + * electronApp.on('console', async msg => { + * const values = []; + * for (const arg of msg.args()) + * values.push(await arg.jsonValue()); + * console.log(...values); + * }); + * await electronApp.evaluate(() => console.log('hello', 5, { foo: 'bar' })); + * ``` + * + */ + addListener(event: 'console', listener: (consoleMessage: ConsoleMessage) => any): this; + + /** + * This event is issued for every window that is created **and loaded** in Electron. It contains a + * [Page](https://playwright.dev/docs/api/class-page) that can be used for Playwright automation. + */ + addListener(event: 'window', listener: (page: Page) => any): this; + + /** + * Removes an event listener added by `on` or `addListener`. + */ + removeListener(event: 'close', listener: () => any): this; + + /** + * Removes an event listener added by `on` or `addListener`. + */ + removeListener(event: 'console', listener: (consoleMessage: ConsoleMessage) => any): this; + + /** + * Removes an event listener added by `on` or `addListener`. + */ + removeListener(event: 'window', listener: (page: Page) => any): this; + + /** + * Removes an event listener added by `on` or `addListener`. + */ + off(event: 'close', listener: () => any): this; + + /** + * Removes an event listener added by `on` or `addListener`. + */ + off(event: 'console', listener: (consoleMessage: ConsoleMessage) => any): this; + + /** + * Removes an event listener added by `on` or `addListener`. + */ + off(event: 'window', listener: (page: Page) => any): this; + + /** + * This event is issued when the application process has been terminated. + */ + prependListener(event: 'close', listener: () => any): this; + + /** + * Emitted when JavaScript within the Electron main process calls one of console API methods, e.g. `console.log` or + * `console.dir`. + * + * The arguments passed into `console.log` are available on the + * [ConsoleMessage](https://playwright.dev/docs/api/class-consolemessage) event handler argument. + * + * **Usage** + * + * ```js + * electronApp.on('console', async msg => { + * const values = []; + * for (const arg of msg.args()) + * values.push(await arg.jsonValue()); + * console.log(...values); + * }); + * await electronApp.evaluate(() => console.log('hello', 5, { foo: 'bar' })); + * ``` + * + */ + prependListener(event: 'console', listener: (consoleMessage: ConsoleMessage) => any): this; + + /** + * This event is issued for every window that is created **and loaded** in Electron. It contains a + * [Page](https://playwright.dev/docs/api/class-page) that can be used for Playwright automation. + */ + prependListener(event: 'window', listener: (page: Page) => any): this; + + /** + * Returns the BrowserWindow object that corresponds to the given Playwright page. + * @param page Page to retrieve the window for. + */ + browserWindow(page: Page): Promise; + + /** + * Closes Electron application. + */ + close(): Promise; + + /** + * This method returns browser context that can be used for setting up context-wide routing, etc. + */ + context(): BrowserContext; + + /** + * Convenience method that waits for the first application window to be opened. + * + * **Usage** + * + * ```js + * const electronApp = await electron.launch({ + * args: ['main.js'] + * }); + * const window = await electronApp.firstWindow(); + * // ... + * ``` + * + * @param options + */ + firstWindow(options?: { + /** + * Maximum time to wait for in milliseconds. Defaults to `30000` (30 seconds). Pass `0` to disable timeout. The + * default value can be changed by using the + * [browserContext.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-default-timeout). + */ + timeout?: number; + }): Promise; + + /** + * Returns the main process for this Electron Application. + */ + process(): ChildProcess; + + /** + * This event is issued when the application process has been terminated. + */ + waitForEvent(event: 'close', optionsOrPredicate?: { predicate?: () => boolean | Promise, timeout?: number } | (() => boolean | Promise)): Promise; + + /** + * Emitted when JavaScript within the Electron main process calls one of console API methods, e.g. `console.log` or + * `console.dir`. + * + * The arguments passed into `console.log` are available on the + * [ConsoleMessage](https://playwright.dev/docs/api/class-consolemessage) event handler argument. + * + * **Usage** + * + * ```js + * electronApp.on('console', async msg => { + * const values = []; + * for (const arg of msg.args()) + * values.push(await arg.jsonValue()); + * console.log(...values); + * }); + * await electronApp.evaluate(() => console.log('hello', 5, { foo: 'bar' })); + * ``` + * + */ + waitForEvent(event: 'console', optionsOrPredicate?: { predicate?: (consoleMessage: ConsoleMessage) => boolean | Promise, timeout?: number } | ((consoleMessage: ConsoleMessage) => boolean | Promise)): Promise; + + /** + * This event is issued for every window that is created **and loaded** in Electron. It contains a + * [Page](https://playwright.dev/docs/api/class-page) that can be used for Playwright automation. + */ + waitForEvent(event: 'window', optionsOrPredicate?: { predicate?: (page: Page) => boolean | Promise, timeout?: number } | ((page: Page) => boolean | Promise)): Promise; + + + /** + * Convenience method that returns all the opened windows. + */ + windows(): Array; + + [Symbol.asyncDispose](): Promise; +} + export type AndroidElementInfo = { clazz: string; desc: string; @@ -16807,6 +17251,7 @@ export type AndroidKey = 'Copy' | 'Paste'; +export const _electron: Electron; export const _android: Android; //@ts-ignore this will be any if electron is not installed @@ -16818,7 +17263,7 @@ type ElectronType = typeof import('electron'); * application instance. This instance you can control main electron process as well as work with Electron windows: * * ```js - * import { _electron as electron } from 'playwright'; + * const { _electron: electron } = require('playwright'); * * (async () => { * // Launch Electron app. @@ -16854,22 +17299,151 @@ export interface ElectronApplication { * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-option-expression). * * If the function passed to the - * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * returns a [Promise], then + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * would wait for the promise to resolve and return its value. + * + * If the function passed to the + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * returns a non-[Serializable] value, then + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * returns `undefined`. Playwright also supports transferring some additional values that are not serializable by + * `JSON`: `-0`, `NaN`, `Infinity`, `-Infinity`. + * @param pageFunction Function to be evaluated in the main Electron process. + * @param arg Optional argument to pass to + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-option-expression). + */ + /** + * Returns the return value of + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-option-expression). + * + * If the function passed to the + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * returns a [Promise], then + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * would wait for the promise to resolve and return its value. + * + * If the function passed to the + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * returns a non-[Serializable] value, then + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * returns `undefined`. Playwright also supports transferring some additional values that are not serializable by + * `JSON`: `-0`, `NaN`, `Infinity`, `-Infinity`. + * @param pageFunction Function to be evaluated in the main Electron process. + * @param arg Optional argument to pass to + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-option-expression). + */ + evaluate(pageFunction: PageFunctionOn, arg: Arg): Promise; + /** + * Returns the return value of + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-option-expression). + * + * If the function passed to the + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * returns a [Promise], then + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * would wait for the promise to resolve and return its value. + * + * If the function passed to the + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * returns a non-[Serializable] value, then + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * returns `undefined`. Playwright also supports transferring some additional values that are not serializable by + * `JSON`: `-0`, `NaN`, `Infinity`, `-Infinity`. + * @param pageFunction Function to be evaluated in the main Electron process. + * @param arg Optional argument to pass to + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-option-expression). + */ + /** + * Returns the return value of + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-option-expression). + * + * If the function passed to the + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * returns a [Promise], then + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * would wait for the promise to resolve and return its value. + * + * If the function passed to the + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * returns a non-[Serializable] value, then + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * returns `undefined`. Playwright also supports transferring some additional values that are not serializable by + * `JSON`: `-0`, `NaN`, `Infinity`, `-Infinity`. + * @param pageFunction Function to be evaluated in the main Electron process. + * @param arg Optional argument to pass to + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-option-expression). + */ + evaluate(pageFunction: PageFunctionOn, arg?: any): Promise; + + /** + * Returns the return value of + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle-option-expression) + * as a [JSHandle](https://playwright.dev/docs/api/class-jshandle). + * + * The only difference between + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * and + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) + * is that + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) + * returns [JSHandle](https://playwright.dev/docs/api/class-jshandle). + * + * If the function passed to the + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) + * returns a [Promise], then + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) + * would wait for the promise to resolve and return its value. + * @param pageFunction Function to be evaluated in the main Electron process. + * @param arg Optional argument to pass to + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle-option-expression). + */ + /** + * Returns the return value of + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle-option-expression) + * as a [JSHandle](https://playwright.dev/docs/api/class-jshandle). + * + * The only difference between + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * and + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) + * is that + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) + * returns [JSHandle](https://playwright.dev/docs/api/class-jshandle). + * + * If the function passed to the + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) + * returns a [Promise], then + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) + * would wait for the promise to resolve and return its value. + * @param pageFunction Function to be evaluated in the main Electron process. + * @param arg Optional argument to pass to + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle-option-expression). + */ + evaluateHandle(pageFunction: PageFunctionOn, arg: Arg): Promise>; + /** + * Returns the return value of + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle-option-expression) + * as a [JSHandle](https://playwright.dev/docs/api/class-jshandle). + * + * The only difference between + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * and + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) + * is that + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) + * returns [JSHandle](https://playwright.dev/docs/api/class-jshandle). + * + * If the function passed to the + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) * returns a [Promise], then - * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) * would wait for the promise to resolve and return its value. - * - * If the function passed to the - * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) - * returns a non-[Serializable] value, then - * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) - * returns `undefined`. Playwright also supports transferring some additional values that are not serializable by - * `JSON`: `-0`, `NaN`, `Infinity`, `-Infinity`. * @param pageFunction Function to be evaluated in the main Electron process. * @param arg Optional argument to pass to - * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-option-expression). + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle-option-expression). */ - evaluate: JSHandle['evaluate']; /** * Returns the return value of * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle-option-expression) @@ -16892,7 +17466,7 @@ export interface ElectronApplication { * @param arg Optional argument to pass to * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle-option-expression). */ - evaluateHandle: JSHandle['evaluateHandle']; + evaluateHandle(pageFunction: PageFunctionOn, arg?: any): Promise>; /** * This event is issued when the application process has been terminated. */ @@ -17124,8 +17698,6 @@ export interface ElectronApplication { [Symbol.asyncDispose](): Promise; } -export const _electron: Electron; - // This is required to not export everything by default. See https://github.com/Microsoft/TypeScript/issues/19545#issuecomment-340490459 export {}; @@ -21317,11 +21889,16 @@ export interface WebSocket { } /** - * Playwright has **experimental** support for Electron automation, exposed as `_electron`. An example of the Electron - * automation script would be: + * Playwright has **experimental** support for Electron automation. You can access electron namespace via: + * + * ```js + * const { _electron } = require('playwright'); + * ``` + * + * An example of the Electron automation script would be: * * ```js - * import { _electron as electron } from 'playwright'; + * const { _electron: electron } = require('playwright'); * * (async () => { * // Launch Electron app. @@ -21361,127 +21938,6 @@ export interface WebSocket { * - Ensure that `nodeCliInspect` * ([FuseV1Options.EnableNodeCliInspectArguments](https://www.electronjs.org/docs/latest/tutorial/fuses#nodecliinspect)) * fuse is **not** set to `false`. - * - * **Migrating from v1.59** - * - * A number of launch options have been removed after v1.59. See below for alternatives. - * - `recordHar` - use - * [tracing.startHar(path[, options])](https://playwright.dev/docs/api/class-tracing#tracing-start-har). - * - * ```js - * const electronApp = await electron.launch({ args: ['main.js'] }); - * await electronApp.context().tracing.startHar('network.har'); - * // ... drive the app ... - * await electronApp.context().tracing.stopHar(); - * await electronApp.close(); - * ``` - * - * - `recordVideo` - use - * [screencast.start([options])](https://playwright.dev/docs/api/class-screencast#screencast-start) on each - * window. - * - * ```js - * const electronApp = await electron.launch({ args: ['main.js'] }); - * const window = await electronApp.firstWindow(); - * await window.screencast.start({ path: 'video.webm' }); - * // ... drive the window ... - * await window.screencast.stop(); - * await electronApp.close(); - * ``` - * - * - `colorScheme` - use - * [page.emulateMedia([options])](https://playwright.dev/docs/api/class-page#page-emulate-media) on each window. - * - * ```js - * const window = await electronApp.firstWindow(); - * await window.emulateMedia({ colorScheme: 'dark' }); - * ``` - * - * - `extraHTTPHeaders` - use - * [browserContext.setExtraHTTPHeaders(headers)](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-extra-http-headers). - * - * ```js - * await electronApp.context().setExtraHTTPHeaders({ 'X-My-Header': 'value' }); - * ``` - * - * - `geolocation` - use - * [browserContext.setGeolocation(geolocation)](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-geolocation). - * - * ```js - * await electronApp.context().setGeolocation({ latitude: 48.858455, longitude: 2.294474 }); - * ``` - * - * - `httpCredentials` - use - * [browserContext.setHTTPCredentials(httpCredentials)](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-http-credentials). - * - * ```js - * await electronApp.context().setHTTPCredentials({ username: 'user', password: 'pass' }); - * ``` - * - * - `offline` - use - * [browserContext.setOffline(offline)](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-offline). - * - * ```js - * await electronApp.context().setOffline(true); - * ``` - * - * - `bypassCSP` - disable CSP at the `BrowserWindow` level via Electron's - * [web preferences](https://www.electronjs.org/docs/latest/api/structures/web-preferences). Note that - * `webSecurity: false` also disables CORS and the Same-Origin Policy. - * - * ```js - * const win = new BrowserWindow({ - * webPreferences: { - * webSecurity: false, - * }, - * }); - * ``` - * - * - `ignoreHTTPSErrors` - * - * There are several ways to relax HTTPS checks in Electron. Pick the one that matches the scope you need. - * - * Per-window, allow mixed content through - * [web preferences](https://www.electronjs.org/docs/latest/api/structures/web-preferences): - * - * ```js - * const win = new BrowserWindow({ - * webPreferences: { - * allowRunningInsecureContent: true, - * }, - * }); - * ``` - * - * Process-wide, ignore certificate errors via Chromium command-line switches (must run before the `ready` event): - * - * ```js - * const { app } = require('electron'); - * app.commandLine.appendSwitch('ignore-certificate-errors'); - * // Optional: also ignore localhost certificate errors when testing on an IP. - * app.commandLine.appendSwitch('allow-insecure-localhost', 'true'); - * ``` - * - * Per-request, accept the certificate manually via the - * [`certificate-error`](https://www.electronjs.org/docs/latest/api/app#event-certificate-error) event: - * - * ```js - * app.on('certificate-error', (event, webContents, url, error, certificate, callback) => { - * event.preventDefault(); - * callback(true); - * }); - * ``` - * - * - `timezoneId` - set an environment variable at the very top of the main file, before any other logic or Chromium - * windows are initialized. - * - * ```js - * // main.js - * process.env.TZ = 'Europe/London'; - * - * const { app } = require('electron'); - * // ... rest of your app logic - * ``` - * */ export interface Electron { /** @@ -21490,16 +21946,41 @@ export interface Electron { * @param options */ launch(options?: { + /** + * Whether to automatically download all the attachments. Defaults to `true` where all the downloads are accepted. + */ + acceptDownloads?: boolean; + /** * Additional arguments to pass to the application when launching. You typically pass the main script name here. */ args?: Array; + /** + * If specified, artifacts (traces, videos, downloads, HAR files, etc.) are saved into this directory. The directory + * is not cleaned up when the browser closes. If not specified, a temporary directory is used and cleaned up when the + * browser closes. + */ + artifactsDir?: string; + + /** + * Toggles bypassing page's Content-Security-Policy. Defaults to `false`. + */ + bypassCSP?: boolean; + /** * Enable Chromium sandboxing. Defaults to `false`. */ chromiumSandbox?: boolean; + /** + * Emulates [prefers-colors-scheme](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme) + * media feature, supported values are `'light'` and `'dark'`. See + * [page.emulateMedia([options])](https://playwright.dev/docs/api/class-page#page-emulate-media) for more details. + * Passing `null` resets emulation to system defaults. Defaults to `'light'`. + */ + colorScheme?: null|"light"|"dark"|"no-preference"; + /** * Current working directory to launch application from. */ @@ -21516,11 +21997,180 @@ export interface Electron { */ executablePath?: string; + /** + * An object containing additional HTTP headers to be sent with every request. Defaults to none. + */ + extraHTTPHeaders?: { [key: string]: string; }; + + geolocation?: { + /** + * Latitude between -90 and 90. + */ + latitude: number; + + /** + * Longitude between -180 and 180. + */ + longitude: number; + + /** + * Non-negative accuracy value. Defaults to `0`. + */ + accuracy?: number; + }; + + /** + * Credentials for [HTTP authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication). If no + * origin is specified, the username and password are sent to any servers upon unauthorized responses. + */ + httpCredentials?: { + username: string; + + password: string; + + /** + * Restrain sending http credentials on specific origin (scheme://host:port). + */ + origin?: string; + + /** + * This option only applies to the requests sent from corresponding + * [APIRequestContext](https://playwright.dev/docs/api/class-apirequestcontext) and does not affect requests sent from + * the browser. `'always'` - `Authorization` header with basic authentication credentials will be sent with the each + * API request. `'unauthorized` - the credentials are only sent when 401 (Unauthorized) response with + * `WWW-Authenticate` header is received. Defaults to `'unauthorized'`. + */ + send?: "unauthorized"|"always"; + }; + + /** + * Whether to ignore HTTPS errors when sending network requests. Defaults to `false`. + */ + ignoreHTTPSErrors?: boolean; + + /** + * Specify user locale, for example `en-GB`, `de-DE`, etc. Locale will affect `navigator.language` value, + * `Accept-Language` request header value as well as number and date formatting rules. Defaults to the system default + * locale. Learn more about emulation in our [emulation guide](https://playwright.dev/docs/emulation#locale--timezone). + */ + locale?: string; + + /** + * Whether to emulate network being offline. Defaults to `false`. Learn more about + * [network emulation](https://playwright.dev/docs/emulation#offline). + */ + offline?: boolean; + + /** + * Enables [HAR](http://www.softwareishard.com/blog/har-12-spec) recording for all pages into `recordHar.path` file. + * If not specified, the HAR is not recorded. Make sure to await + * [browserContext.close([options])](https://playwright.dev/docs/api/class-browsercontext#browser-context-close) for + * the HAR to be saved. + */ + recordHar?: { + /** + * Optional setting to control whether to omit request content from the HAR. Defaults to `false`. Deprecated, use + * `content` policy instead. + */ + omitContent?: boolean; + + /** + * Optional setting to control resource content management. If `omit` is specified, content is not persisted. If + * `attach` is specified, resources are persisted as separate files or entries in the ZIP archive. If `embed` is + * specified, content is stored inline the HAR file as per HAR specification. Defaults to `attach` for `.zip` output + * files and to `embed` for all other file extensions. + */ + content?: "omit"|"embed"|"attach"; + + /** + * Path on the filesystem to write the HAR file to. If the file name ends with `.zip`, `content: 'attach'` is used by + * default. + */ + path: string; + + /** + * When set to `minimal`, only record information necessary for routing from HAR. This omits sizes, timing, page, + * cookies, security and other types of HAR information that are not used when replaying from HAR. Defaults to `full`. + */ + mode?: "full"|"minimal"; + + /** + * A glob or regex pattern to filter requests that are stored in the HAR. When a + * [`baseURL`](https://playwright.dev/docs/api/class-browser#browser-new-context-option-base-url) via the context + * options was provided and the passed URL is a path, it gets merged via the + * [`new URL()`](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL) constructor. Defaults to none. + */ + urlFilter?: string|RegExp; + }; + + /** + * Enables video recording for all pages into `recordVideo.dir` directory. If not specified videos are not recorded. + * Make sure to await + * [browserContext.close([options])](https://playwright.dev/docs/api/class-browsercontext#browser-context-close) for + * videos to be saved. + */ + recordVideo?: { + /** + * Path to the directory to put videos into. If not specified, the videos will be stored in `artifactsDir` (see + * [browserType.launch([options])](https://playwright.dev/docs/api/class-browsertype#browser-type-launch) options). + */ + dir?: string; + + /** + * Optional dimensions of the recorded videos. If not specified the size will be equal to `viewport` scaled down to + * fit into 800x800. If `viewport` is not configured explicitly the video size defaults to 800x450. Actual picture of + * each page will be scaled down if necessary to fit the specified size. + */ + size?: { + /** + * Video frame width. + */ + width: number; + + /** + * Video frame height. + */ + height: number; + }; + + /** + * If specified, enables visual annotations on interacted elements during video recording. + */ + showActions?: { + /** + * How long each annotation is displayed in milliseconds. Defaults to `500`. + */ + duration?: number; + + /** + * Position of the action title overlay. Defaults to `"top-right"`. + */ + position?: "top-left"|"top"|"top-right"|"bottom-left"|"bottom"|"bottom-right"; + + /** + * Font size of the action title in pixels. Defaults to `24`. + */ + fontSize?: number; + }; + }; + /** * Maximum time in milliseconds to wait for the application to start. Defaults to `30000` (30 seconds). Pass `0` to * disable timeout. */ timeout?: number; + + /** + * Changes the timezone of the context. See + * [ICU's metaZones.txt](https://cs.chromium.org/chromium/src/third_party/icu/source/data/misc/metaZones.txt?rcl=faee8bc70570192d82d2978a71e2a615788597d1) + * for a list of supported timezone IDs. Defaults to the system timezone. + */ + timezoneId?: string; + + /** + * If specified, traces are saved into this directory. + */ + tracesDir?: string; }): Promise; } diff --git a/packages/playwright-core/index.mjs b/packages/playwright-core/index.mjs index 0878c2161fbb8..3b3c75b0f7fa7 100644 --- a/packages/playwright-core/index.mjs +++ b/packages/playwright-core/index.mjs @@ -23,6 +23,6 @@ export const selectors = playwright.selectors; export const devices = playwright.devices; export const errors = playwright.errors; export const request = playwright.request; -export const _android = playwright._android; export const _electron = playwright._electron; +export const _android = playwright._android; export default playwright; diff --git a/packages/playwright-core/src/client/api.ts b/packages/playwright-core/src/client/api.ts index 53f3888a9de84..3a36d03285cef 100644 --- a/packages/playwright-core/src/client/api.ts +++ b/packages/playwright-core/src/client/api.ts @@ -26,6 +26,7 @@ export { Debugger } from './debugger'; export { Dialog } from './dialog'; export type { Disposable } from './disposable'; export { Download } from './download'; +export { Electron, ElectronApplication } from './electron'; export { FrameLocator, Locator } from './locator'; export { ElementHandle } from './elementHandle'; export { FileChooser } from './fileChooser'; diff --git a/packages/playwright-core/src/client/connection.ts b/packages/playwright-core/src/client/connection.ts index dbc80c21d05ae..f54c07948cb46 100644 --- a/packages/playwright-core/src/client/connection.ts +++ b/packages/playwright-core/src/client/connection.ts @@ -27,6 +27,7 @@ import { createInstrumentation } from './clientInstrumentation'; import { Debugger } from './debugger'; import { Dialog } from './dialog'; import { DisposableObject } from './disposable'; +import { Electron, ElectronApplication } from './electron'; import { ElementHandle } from './elementHandle'; import { TargetClosedError, parseError } from './errors'; import { APIRequestContext } from './fetch'; @@ -103,6 +104,8 @@ export class Connection extends EventEmitter { Debugger: (parent, type, guid, init) => new Debugger(parent, type, guid, init), Dialog: (parent, type, guid, init) => new Dialog(parent, type, guid, init), Disposable: (parent, type, guid, init) => new DisposableObject(parent, type, guid, init), + Electron: (parent, type, guid, init) => new Electron(parent, type, guid, init), + ElectronApplication: (parent, type, guid, init) => new ElectronApplication(parent, type, guid, init), ElementHandle: (parent, type, guid, init) => new ElementHandle(parent, type, guid, init), Frame: (parent, type, guid, init) => new Frame(parent, type, guid, init), JSHandle: (parent, type, guid, init) => new JSHandle(parent, type, guid, init), diff --git a/packages/playwright-core/src/client/consoleMessage.ts b/packages/playwright-core/src/client/consoleMessage.ts index 2cbf124dcb15d..4b4e213293cdb 100644 --- a/packages/playwright-core/src/client/consoleMessage.ts +++ b/packages/playwright-core/src/client/consoleMessage.ts @@ -26,9 +26,9 @@ export class ConsoleMessage implements api.ConsoleMessage { private _page: Page | null; private _worker: Worker | null; - private _event: channels.BrowserContextConsoleEvent | channels.WorkerConsoleEvent; + private _event: channels.BrowserContextConsoleEvent | channels.WorkerConsoleEvent | channels.ElectronApplicationConsoleEvent; - constructor(platform: Platform, event: channels.BrowserContextConsoleEvent | channels.WorkerConsoleEvent, page: Page | null, worker: Worker | null) { + constructor(platform: Platform, event: channels.BrowserContextConsoleEvent | channels.WorkerConsoleEvent | channels.ElectronApplicationConsoleEvent, page: Page | null, worker: Worker | null) { this._page = page; this._worker = worker; this._event = event; diff --git a/packages/playwright-core/src/client/electron.ts b/packages/playwright-core/src/client/electron.ts new file mode 100644 index 0000000000000..2399aa61ff1ce --- /dev/null +++ b/packages/playwright-core/src/client/electron.ts @@ -0,0 +1,169 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { BrowserContext, prepareBrowserContextParams } from './browserContext'; +import { ChannelOwner } from './channelOwner'; +import { envObjectToArray } from './clientHelper'; +import { ConsoleMessage } from './consoleMessage'; +import { TargetClosedError, isTargetClosedError } from './errors'; +import { Events } from './events'; +import { JSHandle, parseResult, serializeArgument } from './jsHandle'; +import { Waiter } from './waiter'; +import { TimeoutSettings } from './timeoutSettings'; + +import type { Page } from './page'; +import type { BrowserContextOptions, Headers, WaitForEventOptions } from './types'; +import type * as structs from '../../types/structs'; +import type * as api from '../../types/types'; +import type * as channels from '@protocol/channels'; +import type * as childProcess from 'child_process'; +import type { BrowserWindow } from 'electron'; +import type { Playwright } from './playwright'; + +type ElectronOptions = Omit & { + env?: NodeJS.ProcessEnv, + extraHTTPHeaders?: Headers, + recordHar?: BrowserContextOptions['recordHar'], + colorScheme?: 'dark' | 'light' | 'no-preference' | null, + acceptDownloads?: boolean, + timeout?: number, +}; + +type ElectronAppType = typeof import('electron'); + +export class Electron extends ChannelOwner implements api.Electron { + _playwright!: Playwright; + + static from(electron: channels.ElectronChannel): Electron { + return (electron as any)._object; + } + + constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.ElectronInitializer) { + super(parent, type, guid, initializer); + } + + async launch(options: ElectronOptions = {}): Promise { + options = this._playwright.selectors._withSelectorOptions(options); + const params: channels.ElectronLaunchParams = { + ...await prepareBrowserContextParams(this._platform, options), + env: envObjectToArray(options.env ? options.env : this._platform.env), + tracesDir: options.tracesDir, + artifactsDir: options.artifactsDir, + timeout: new TimeoutSettings(this._platform).launchTimeout(options), + }; + const app = ElectronApplication.from((await this._channel.launch(params)).electronApplication); + this._playwright.selectors._contextsForSelectors.add(app._context); + app.once(Events.ElectronApplication.Close, () => this._playwright.selectors._contextsForSelectors.delete(app._context)); + await app._context._initializeHarFromOptions(options.recordHar); + app._context.tracing._tracesDir = options.tracesDir; + return app; + } +} + +export class ElectronApplication extends ChannelOwner implements api.ElectronApplication { + readonly _context: BrowserContext; + private _windows = new Set(); + private _timeoutSettings: TimeoutSettings; + + static from(electronApplication: channels.ElectronApplicationChannel): ElectronApplication { + return (electronApplication as any)._object; + } + + constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.ElectronApplicationInitializer) { + super(parent, type, guid, initializer); + + this._timeoutSettings = new TimeoutSettings(this._platform); + this._context = BrowserContext.from(initializer.context); + for (const page of this._context._pages) + this._onPage(page); + this._context.on(Events.BrowserContext.Page, page => this._onPage(page)); + this._channel.on('close', () => { + this.emit(Events.ElectronApplication.Close); + }); + this._channel.on('console', event => this.emit(Events.ElectronApplication.Console, new ConsoleMessage(this._platform, event, null, null))); + this._setEventToSubscriptionMapping(new Map([ + [Events.ElectronApplication.Console, 'console'], + ])); + } + + process(): childProcess.ChildProcess { + return this._connection.toImpl?.(this)?.process(); + } + + _onPage(page: Page) { + this._windows.add(page); + this.emit(Events.ElectronApplication.Window, page); + page.once(Events.Page.Close, () => this._windows.delete(page)); + } + + windows(): Page[] { + // TODO: add ElectronPage class inheriting from Page. + return [...this._windows]; + } + + async firstWindow(options?: { timeout?: number }): Promise { + if (this._windows.size) + return this._windows.values().next().value!; + return await this.waitForEvent('window', options); + } + + context(): BrowserContext { + return this._context; + } + + async [Symbol.asyncDispose]() { + await this.close(); + } + + async close() { + try { + await this._context.close(); + } catch (e) { + if (isTargetClosedError(e)) + return; + throw e; + } + } + + async waitForEvent(event: string, optionsOrPredicate: WaitForEventOptions = {}): Promise { + return await this._wrapApiCall(async () => { + const timeout = this._timeoutSettings.timeout(typeof optionsOrPredicate === 'function' ? {} : optionsOrPredicate); + const predicate = typeof optionsOrPredicate === 'function' ? optionsOrPredicate : optionsOrPredicate.predicate; + const waiter = Waiter.createForEvent(this, event); + waiter.rejectOnTimeout(timeout, `Timeout ${timeout}ms exceeded while waiting for event "${event}"`); + if (event !== Events.ElectronApplication.Close) + waiter.rejectOnEvent(this, Events.ElectronApplication.Close, () => new TargetClosedError()); + const result = await waiter.waitForEvent(this, event, predicate as any); + waiter.dispose(); + return result; + }); + } + + async browserWindow(page: Page): Promise> { + const result = await this._channel.browserWindow({ page: page._channel }); + return JSHandle.from(result.handle); + } + + async evaluate(pageFunction: structs.PageFunctionOn, arg: Arg): Promise { + const result = await this._channel.evaluateExpression({ expression: String(pageFunction), isFunction: typeof pageFunction === 'function', arg: serializeArgument(arg) }); + return parseResult(result.value); + } + + async evaluateHandle(pageFunction: structs.PageFunctionOn, arg: Arg): Promise> { + const result = await this._channel.evaluateExpressionHandle({ expression: String(pageFunction), isFunction: typeof pageFunction === 'function', arg: serializeArgument(arg) }); + return JSHandle.from(result.handle) as any as structs.SmartHandle; + } +} diff --git a/packages/playwright-core/src/client/events.ts b/packages/playwright-core/src/client/events.ts index 85dfc561fd84a..c69b3aa5a47b6 100644 --- a/packages/playwright-core/src/client/events.ts +++ b/packages/playwright-core/src/client/events.ts @@ -100,4 +100,10 @@ export const Events = { Close: 'close', Console: 'console', }, + + ElectronApplication: { + Close: 'close', + Console: 'console', + Window: 'window', + }, }; diff --git a/packages/playwright-core/src/client/playwright.ts b/packages/playwright-core/src/client/playwright.ts index e777e79832a0b..c5f2074aee984 100644 --- a/packages/playwright-core/src/client/playwright.ts +++ b/packages/playwright-core/src/client/playwright.ts @@ -18,6 +18,7 @@ import { Android } from './android'; import { Browser } from './browser'; import { BrowserType } from './browserType'; import { ChannelOwner } from './channelOwner'; +import { Electron } from './electron'; import { TimeoutError } from './errors'; import { APIRequest } from './fetch'; import { Selectors } from './selectors'; @@ -27,6 +28,7 @@ import type { LaunchOptions } from 'playwright-core'; export class Playwright extends ChannelOwner { readonly _android: Android; + readonly _electron: Electron; readonly chromium: BrowserType; readonly firefox: BrowserType; readonly webkit: BrowserType; @@ -51,6 +53,8 @@ export class Playwright extends ChannelOwner { this.webkit._playwright = this; this._android = Android.from(initializer.android); this._android._playwright = this; + this._electron = Electron.from(initializer.electron); + this._electron._playwright = this; this.devices = this._connection.localUtils()?.devices ?? {}; this.selectors = new Selectors(this._connection._platform); this.errors = { TimeoutError }; diff --git a/packages/playwright-core/src/electron/DEPS.list b/packages/playwright-core/src/electron/DEPS.list deleted file mode 100644 index 33bd5201b0630..0000000000000 --- a/packages/playwright-core/src/electron/DEPS.list +++ /dev/null @@ -1,5 +0,0 @@ -[*] -../package.ts -@isomorphic/** -@utils/** -node_modules/debug diff --git a/packages/playwright-core/src/electron/electron.ts b/packages/playwright-core/src/electron/electron.ts deleted file mode 100644 index cc493b7ed1222..0000000000000 --- a/packages/playwright-core/src/electron/electron.ts +++ /dev/null @@ -1,358 +0,0 @@ -/** - * Copyright (c) Microsoft Corporation. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import os from 'os'; -import readline from 'readline'; -import { EventEmitter } from 'events'; -import debug from 'debug'; - -import { launchProcess } from '@utils/processLauncher'; -import { wrapInASCIIBox } from '@utils/ascii'; -import { debugMode } from '@utils/debug'; -import { ManualPromise } from '@isomorphic/manualPromise'; -import { monotonicTime } from '@isomorphic/time'; - -import { libPath } from '../package'; - -import type { BrowserWindow } from 'electron'; -import type { Browser, BrowserContext, JSHandle, Page } from '../../types/types'; -import type * as api from '../../types/types'; -import type { Playwright } from '../client/playwright'; -import type { Worker } from '../client/worker'; -import type childProcess from 'child_process'; - -const debugLogger = debug('pw:electron'); - -export const Events = { - ElectronApplication: { - Close: 'close', - Console: 'console', - Window: 'window', - }, -}; - -type ElectronLaunchOptions = NonNullable[0]>; - -type ElectronAppType = typeof import('electron'); - -class Progress { - private _deadline: number; - private _timeoutError: Error; - - constructor(timeout: number, timeoutMessage: string) { - this._deadline = timeout ? monotonicTime() + timeout : 0; - this._timeoutError = new Error(timeoutMessage); - } - - async race(promise: Promise): Promise { - const timeoutPromise = new ManualPromise(); - const timeout = this.timeUntilDeadline(); - const timer = timeout ? setTimeout(() => timeoutPromise.reject(this._timeoutError), timeout) : undefined; - try { - return await Promise.race([promise, timeoutPromise]); - } finally { - clearTimeout(timer); - } - } - - timeUntilDeadline() { - return this._deadline ? this._deadline - monotonicTime() : 0; - } -} - -export class Electron implements api.Electron { - _playwright: Playwright; - - constructor(playwright: Playwright) { - this._playwright = playwright; - } - - async launch(options: ElectronLaunchOptions = {}): Promise { - const timeout = options.timeout ?? (debugMode() === 'inspector' ? 0 : 3 * 60 * 1000); - const progress = new Progress(timeout, `electron.launch: Timeout ${timeout}ms exceeded`); - let app: ElectronApplication | undefined; - - // --remote-debugging-port=0 must be the last playwright argument; loader.ts relies on it. - let electronArguments = ['--inspect=0', '--remote-debugging-port=0', ...(options.args || [])]; - - if (os.platform() === 'linux') { - if (!options.chromiumSandbox && !electronArguments.includes('--no-sandbox')) - electronArguments.unshift('--no-sandbox'); - } - - let command: string; - if (options.executablePath) { - command = options.executablePath; - } else { - try { - // 'electron/index.js' resolves to the Electron App executable shim. - command = require('electron/index.js'); - } catch (error: any) { - if ((error as NodeJS.ErrnoException)?.code === 'MODULE_NOT_FOUND') { - throw new Error('\n' + wrapInASCIIBox([ - 'Electron executablePath not found!', - 'Please install it using `npm install -D electron` or set the executablePath to your Electron executable.', - ].join('\n'), 1)); - } - throw error; - } - // Only inject our loader for non-packaged apps; packaged apps may have - // their own command-line handling. loader.js is emitted under - // lib/electron/ as a per-file build (see utils/build/build.js). - electronArguments.unshift('-r', libPath('electron', 'loader.js')); - } - - let shell = false; - if (process.platform === 'win32') { - // shell: true is required to launch .cmd files. We pass the entire - // command as a single string to dodge DEP0190 and Windows quoting bugs. - // https://github.com/nodejs/node/issues/52554 - // https://github.com/microsoft/playwright/issues/38278 - shell = true; - command = [command, ...electronArguments].map(arg => `"${arg.replace(/"/g, '\\"')}"`).join(' '); - electronArguments = []; - } - - // When debugging Playwright tests that drive Electron, NODE_OPTIONS - // would make the user's debugger latch onto Electron's Node first. - // Strip it so Playwright can attach. - const env = { ...(options.env ?? process.env) }; - delete env.NODE_OPTIONS; - - const logCollector: string[] = []; - const { launchedProcess, kill } = await launchProcess({ - command, - args: electronArguments, - env, - log: (message: string) => { - debugLogger(message); - logCollector.push(message); - }, - shell, - stdio: 'pipe', - cwd: options.cwd, - tempDirectories: [], - attemptToGracefullyClose: async () => { await app?.close(); }, - handleSIGINT: true, - handleSIGTERM: true, - handleSIGHUP: true, - onExit: () => app?._onClose(), - }); - - // Start every line listener immediately — the lines may arrive before we - // are ready to await them. - const waitForXserverError = waitForLine(progress, launchedProcess, /Unable to open X display/).then(() => { - throw new Error([ - 'Unable to open X display!', - '================================', - 'Most likely this is because there is no X server available.', - "Use 'xvfb-run' on Linux to launch your tests with an emulated display server.", - "For example: 'xvfb-run npm run test:e2e'", - '================================', - ...logCollector, - ].join('\n')); - }); - const nodeMatchPromise = waitForLine(progress, launchedProcess, /^Debugger listening on (ws:\/\/.*)$/); - const chromeMatchPromise = waitForLine(progress, launchedProcess, /^DevTools listening on (ws:\/\/.*)$/); - const debuggerDisconnectPromise = waitForLine(progress, launchedProcess, /Waiting for the debugger to disconnect\.\.\./); - - try { - const chromium = this._playwright.chromium; - const nodeMatch = await nodeMatchPromise; - const worker = await chromium._connectToWorker(nodeMatch[1], { timeout: progress.timeUntilDeadline() }); - - // Release the Electron process immediately if the user is debugging it. - debuggerDisconnectPromise.then(() => worker._disconnect()).catch(() => {}); - - const chromeMatch = await Promise.race([chromeMatchPromise, waitForXserverError]); - const browser = await chromium.connectOverCDP(chromeMatch[1], { timeout: progress.timeUntilDeadline(), isLocal: true }); - - app = new ElectronApplication(worker, browser, launchedProcess); - await progress.race(app._initialize()); - return app; - } catch (error) { - await kill(); - throw error; - } - } -} - -export class ElectronApplication extends EventEmitter implements api.ElectronApplication { - private _worker: Worker; - private _browser: Browser; - private _process: childProcess.ChildProcess; - private _context: BrowserContext; - private _windows = new Map | undefined>(); - private _appHandlePromise = new ManualPromise>(); - private _closedPromise: Promise | undefined; - - constructor(worker: Worker, browser: Browser, process: childProcess.ChildProcess) { - super(); - - this._worker = worker; - this._worker.on('console', message => this.emit(Events.ElectronApplication.Console, message)); - - this._browser = browser; - this._context = browser.contexts()[0]; - for (const page of this._context.pages()) - this._onPage(page); - this._context.on('page', page => this._onPage(page)); - // Closing the BrowserContext should close the entire app; route both through close(). - this._context.close = () => this.close(); - - this._process = process; - } - - _onClose() { - this.emit(Events.ElectronApplication.Close); - this._closedPromise ??= Promise.resolve(); - } - - process(): childProcess.ChildProcess { - return this._process; - } - - _onPage(page: Page) { - this._windows.set(page, undefined); - this.emit(Events.ElectronApplication.Window, page); - page.once('close', () => this._windows.delete(page)); - } - - windows(): Page[] { - return [...this._windows.keys()]; - } - - async firstWindow(options?: { timeout?: number }): Promise { - if (this._windows.size) - return this._windows.keys().next().value!; - return await this.waitForEvent('window', options); - } - - context(): BrowserContext { - return this._context; - } - - async [Symbol.asyncDispose]() { - await this.close(); - } - - async close() { - if (!this._closedPromise) { - this._closedPromise = new Promise(f => this.once(Events.ElectronApplication.Close, f)); - await this._browser.close(); - const appHandle = await this._appHandlePromise; - await appHandle.evaluate(({ app }) => app.quit()).catch(() => {}); - await this._worker._disconnect(); - } - await this._closedPromise; - } - - async waitForEvent(event: string, optionsOrPredicate: Function | { timeout?: number, predicate?: Function } = {}): Promise { - const promise = new ManualPromise(); - - const onEvent = async (eventArg: any) => { - try { - const predicate = typeof optionsOrPredicate === 'function' ? optionsOrPredicate : optionsOrPredicate.predicate; - if (predicate && !(await predicate(eventArg))) - return; - promise.resolve(eventArg); - } catch (e) { - promise.reject(e); - } - }; - this.addListener(event, onEvent); - - const onClose = () => promise.reject(new Error('Electron application has been closed')); - if (event !== Events.ElectronApplication.Close) - this.addListener(Events.ElectronApplication.Close, onClose); - - try { - const timeout = typeof optionsOrPredicate === 'function' ? 30000 : (optionsOrPredicate?.timeout ?? 30000); - const progress = new Progress(timeout, `Timeout ${timeout}ms exceeded while waiting for event "${event}"`); - return await progress.race(promise); - } finally { - this.removeListener(event, onEvent); - this.removeListener(Events.ElectronApplication.Close, onClose); - } - } - - async _initialize() { - await Promise.all([ - this._worker.evaluateHandle('__playwright_electron').then(handle => { - this._appHandlePromise.resolve(handle as any); - // Best-effort: in-process clients can rename the preview to make stack traces nicer. - (handle as any)._connection?.toImpl?.(handle)?._setPreview('ElectronModule'); - }), - // Defer Electron's `ready` until the browser side is wired up for auto-attach. - this._worker.evaluate('__playwright_run()'), - ]); - } - - async browserWindow(page: Page): Promise> { - let browserWindow = this._windows.get(page); - if (!browserWindow) { - const cdpSession = await this._context.newCDPSession(page); - const { targetInfo } = await cdpSession.send('Target.getTargetInfo'); - const appHandle = await this._appHandlePromise; - browserWindow = await appHandle.evaluateHandle(({ BrowserWindow, webContents }, targetId) => { - const wc = webContents.fromDevToolsTargetId(targetId); - return BrowserWindow.fromWebContents(wc!)!; - }, targetInfo.targetId); - this._windows.set(page, browserWindow); - } - return browserWindow; - } - - async evaluate(pageFunction: any, arg: Arg): Promise { - const appHandle = await this._appHandlePromise; - return appHandle.evaluate(pageFunction, arg); - } - - async evaluateHandle(pageFunction: any, arg: Arg): Promise { - const appHandle = await this._appHandlePromise; - return await appHandle.evaluateHandle(pageFunction, arg); - } -} - -async function waitForLine(progress: Progress, process: childProcess.ChildProcess, regex: RegExp) { - const promise = new ManualPromise(); - - // eslint-disable-next-line no-restricted-properties - const rl = readline.createInterface({ input: process.stderr! }); - - const failError = new Error('Process failed to launch!'); - const onFail = () => promise.reject(failError); - const onLine = (line: string) => { - const match = line.match(regex); - if (match) - promise.resolve(match); - }; - - rl.addListener('line', onLine); - rl.addListener('close', onFail); - process.addListener('exit', onFail); - // Safe to add a listener — launchProcess attached its own error handler already. - process.addListener('error', onFail); - - try { - return await progress.race(promise); - } finally { - rl.removeListener('line', onLine); - rl.removeListener('close', onFail); - process.removeListener('exit', onFail); - process.removeListener('error', onFail); - } -} diff --git a/packages/playwright-core/src/electron/loader.ts b/packages/playwright-core/src/electron/loader.ts deleted file mode 100644 index 2cc15ec3b51a9..0000000000000 --- a/packages/playwright-core/src/electron/loader.ts +++ /dev/null @@ -1,103 +0,0 @@ -/** - * Copyright (c) Microsoft Corporation. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// Runs inside the Electron main process via `electron -r loader.js`. -// Must be self-contained — Electron's main process does not have access -// to playwright-core's bundled deps. Keep the chromium switches list in sync -// with packages/playwright-core/src/server/chromium/chromiumSwitches.ts. - -const electronModule = require('electron') as typeof import('electron'); - -const { app } = electronModule; - -const chromiumSwitches = [ - '--disable-field-trial-config', - '--disable-background-networking', - '--disable-background-timer-throttling', - '--disable-backgrounding-occluded-windows', - '--disable-back-forward-cache', - '--disable-breakpad', - '--disable-client-side-phishing-detection', - '--disable-component-extensions-with-background-pages', - '--disable-component-update', - '--no-default-browser-check', - '--disable-default-apps', - '--disable-dev-shm-usage', - '--disable-edgeupdater', - '--disable-extensions', - '--disable-features=AvoidUnnecessaryBeforeUnloadCheckSync,BoundaryEventDispatchTracksNodeRemoval,DestroyProfileOnBrowserClose,DialMediaRouteProvider,GlobalMediaControls,HttpsUpgrades,LensOverlay,MediaRouter,PaintHolding,ThirdPartyStoragePartitioning,Translate,AutoDeElevate,RenderDocument,OptimizationHints,msForceBrowserSignIn,msEdgeUpdateLaunchServicesPreferredVersion', - '--enable-features=CDPScreenshotNewSurface', - '--allow-pre-commit-input', - '--disable-hang-monitor', - '--disable-ipc-flooding-protection', - '--disable-popup-blocking', - '--disable-prompt-on-repost', - '--disable-renderer-backgrounding', - '--force-color-profile=srgb', - '--metrics-recording-only', - '--no-first-run', - '--password-store=basic', - '--use-mock-keychain', - '--no-service-autorun', - '--export-tagged-pdf', - '--disable-search-engine-choice-screen', - '--unsafely-disable-devtools-self-xss-warnings', - '--edge-skip-compat-layer-relaunch', - '--enable-automation', - '--disable-infobars', - '--disable-sync', -]; - -// The new `chromium._connectToWorker`-based client reads these globals via -// the Node debugger to bootstrap the Electron app. -(globalThis as any).__playwright_electron = electronModule; - -// Always pass user arguments first. -// https://github.com/microsoft/playwright/issues/16614 -// https://github.com/microsoft/playwright/issues/29198 -// argv layout: [Electron, -r, loader.js[, --no-sandbox], --inspect=0, --remote-debugging-port=0, ...userArgs] -process.argv.splice(1, process.argv.indexOf('--remote-debugging-port=0')); - -for (const arg of chromiumSwitches) { - const match = arg.match(/--([^=]*)=?(.*)/)!; - app.commandLine.appendSwitch(match[1], match[2]); -} - -// Defer the `ready` event until the Playwright client has wired up auto-attach. -const originalWhenReady = app.whenReady(); -const originalEmit = app.emit.bind(app); -let readyEventArgs: any[]; -app.emit = (event: string | symbol, ...args: any[]): boolean => { - if (event === 'ready') { - readyEventArgs = args; - return app.listenerCount('ready') > 0; - } - return originalEmit(event, ...args); -}; - -let isReady = false; -let whenReadyCallback: (event: any) => any; -const whenReadyPromise = new Promise(f => whenReadyCallback = f); -app.isReady = () => isReady; -app.whenReady = () => whenReadyPromise; - -(globalThis as any).__playwright_run = async () => { - // Wait for app to be ready to avoid browser-initialization races. - const event = await originalWhenReady; - isReady = true; - whenReadyCallback(event); - originalEmit('ready', ...readyEventArgs); -}; diff --git a/packages/playwright-core/src/inprocess.ts b/packages/playwright-core/src/inprocess.ts index c65ae864f4068..fba4fb5ad7155 100644 --- a/packages/playwright-core/src/inprocess.ts +++ b/packages/playwright-core/src/inprocess.ts @@ -17,7 +17,6 @@ import { nodePlatform } from '@utils/nodePlatform'; import { AndroidServerLauncherImpl } from './androidServerImpl'; import { BrowserServerLauncherImpl } from './browserServerImpl'; -import { Electron } from './electron/electron'; import { DispatcherConnection, PlaywrightDispatcher, RootDispatcher, createPlaywright } from './server'; import { Connection } from './client/connection'; import { packageRoot } from './package'; @@ -44,7 +43,6 @@ export function createInProcessPlaywright(): PlaywrightAPI { playwrightAPI.firefox._serverLauncher = new BrowserServerLauncherImpl('firefox'); playwrightAPI.webkit._serverLauncher = new BrowserServerLauncherImpl('webkit'); playwrightAPI._android._serverLauncher = new AndroidServerLauncherImpl(); - (playwrightAPI as any)._electron = new Electron(playwrightAPI); // Switch to async dispatch after we got Playwright object. dispatcherConnection.onmessage = message => setImmediate(() => clientConnection.dispatch(message)); diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 8c0a3fcb23b84..b73bea2193e9f 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -1101,6 +1101,7 @@ scheme.EventTargetWaitForEventInfoParams = tObject({ }); scheme.AndroidDeviceWaitForEventInfoParams = tType('EventTargetWaitForEventInfoParams'); scheme.BrowserContextWaitForEventInfoParams = tType('EventTargetWaitForEventInfoParams'); +scheme.ElectronApplicationWaitForEventInfoParams = tType('EventTargetWaitForEventInfoParams'); scheme.WebSocketWaitForEventInfoParams = tType('EventTargetWaitForEventInfoParams'); scheme.PageWaitForEventInfoParams = tType('EventTargetWaitForEventInfoParams'); scheme.DebuggerWaitForEventInfoParams = tType('EventTargetWaitForEventInfoParams'); @@ -1108,10 +1109,100 @@ scheme.WorkerWaitForEventInfoParams = tType('EventTargetWaitForEventInfoParams') scheme.EventTargetWaitForEventInfoResult = tOptional(tObject({})); scheme.AndroidDeviceWaitForEventInfoResult = tType('EventTargetWaitForEventInfoResult'); scheme.BrowserContextWaitForEventInfoResult = tType('EventTargetWaitForEventInfoResult'); +scheme.ElectronApplicationWaitForEventInfoResult = tType('EventTargetWaitForEventInfoResult'); scheme.WebSocketWaitForEventInfoResult = tType('EventTargetWaitForEventInfoResult'); scheme.PageWaitForEventInfoResult = tType('EventTargetWaitForEventInfoResult'); scheme.DebuggerWaitForEventInfoResult = tType('EventTargetWaitForEventInfoResult'); scheme.WorkerWaitForEventInfoResult = tType('EventTargetWaitForEventInfoResult'); +scheme.ElectronInitializer = tOptional(tObject({})); +scheme.ElectronLaunchParams = tObject({ + executablePath: tOptional(tString), + args: tOptional(tArray(tString)), + chromiumSandbox: tOptional(tBoolean), + cwd: tOptional(tString), + env: tOptional(tArray(tType('NameValue'))), + timeout: tFloat, + acceptDownloads: tOptional(tEnum(['accept', 'deny', 'internal-browser-default'])), + bypassCSP: tOptional(tBoolean), + colorScheme: tOptional(tEnum(['dark', 'light', 'no-preference', 'no-override'])), + extraHTTPHeaders: tOptional(tArray(tType('NameValue'))), + geolocation: tOptional(tObject({ + longitude: tFloat, + latitude: tFloat, + accuracy: tOptional(tFloat), + })), + httpCredentials: tOptional(tObject({ + username: tString, + password: tString, + origin: tOptional(tString), + })), + ignoreHTTPSErrors: tOptional(tBoolean), + locale: tOptional(tString), + offline: tOptional(tBoolean), + recordVideo: tOptional(tObject({ + dir: tOptional(tString), + size: tOptional(tObject({ + width: tInt, + height: tInt, + })), + showActions: tOptional(tObject({ + duration: tOptional(tFloat), + position: tOptional(tEnum(['top-left', 'top', 'top-right', 'bottom-left', 'bottom', 'bottom-right'])), + fontSize: tOptional(tInt), + })), + })), + strictSelectors: tOptional(tBoolean), + timezoneId: tOptional(tString), + tracesDir: tOptional(tString), + artifactsDir: tOptional(tString), + selectorEngines: tOptional(tArray(tType('SelectorEngine'))), + testIdAttributeName: tOptional(tString), +}); +scheme.ElectronLaunchResult = tObject({ + electronApplication: tChannel(['ElectronApplication']), +}); +scheme.ElectronApplicationInitializer = tObject({ + context: tChannel(['BrowserContext']), +}); +scheme.ElectronApplicationCloseEvent = tOptional(tObject({})); +scheme.ElectronApplicationConsoleEvent = tObject({ + type: tString, + text: tString, + args: tArray(tChannel(['ElementHandle', 'JSHandle'])), + location: tObject({ + url: tString, + lineNumber: tInt, + columnNumber: tInt, + }), + timestamp: tFloat, +}); +scheme.ElectronApplicationBrowserWindowParams = tObject({ + page: tChannel(['Page']), +}); +scheme.ElectronApplicationBrowserWindowResult = tObject({ + handle: tChannel(['ElementHandle', 'JSHandle']), +}); +scheme.ElectronApplicationEvaluateExpressionParams = tObject({ + expression: tString, + isFunction: tOptional(tBoolean), + arg: tType('SerializedArgument'), +}); +scheme.ElectronApplicationEvaluateExpressionResult = tObject({ + value: tType('SerializedValue'), +}); +scheme.ElectronApplicationEvaluateExpressionHandleParams = tObject({ + expression: tString, + isFunction: tOptional(tBoolean), + arg: tType('SerializedArgument'), +}); +scheme.ElectronApplicationEvaluateExpressionHandleResult = tObject({ + handle: tChannel(['ElementHandle', 'JSHandle']), +}); +scheme.ElectronApplicationUpdateSubscriptionParams = tObject({ + event: tEnum(['console']), + enabled: tBoolean, +}); +scheme.ElectronApplicationUpdateSubscriptionResult = tOptional(tObject({})); scheme.FrameInitializer = tObject({ url: tString, name: tString, @@ -2568,6 +2659,7 @@ scheme.PlaywrightInitializer = tObject({ firefox: tChannel(['BrowserType']), webkit: tChannel(['BrowserType']), android: tChannel(['Android']), + electron: tChannel(['Electron']), utils: tOptional(tChannel(['LocalUtils'])), preLaunchedBrowser: tOptional(tChannel(['Browser'])), preConnectedAndroidDevice: tOptional(tChannel(['AndroidDevice'])), diff --git a/packages/playwright-core/src/server/DEPS.list b/packages/playwright-core/src/server/DEPS.list index 8af50d834faac..f9cf05a0b315c 100644 --- a/packages/playwright-core/src/server/DEPS.list +++ b/packages/playwright-core/src/server/DEPS.list @@ -25,6 +25,7 @@ node_modules/yazl ./android/ ./bidi/ ./chromium/ +./electron/ ./firefox/ ./webkit/ diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index ac66c000d41e1..1b7c0856e3ba9 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -726,8 +726,13 @@ export function validateBrowserContextOptions(options: types.BrowserContextOptio throw new Error(`"deviceScaleFactor" option is not supported with null "viewport"`); if (options.noDefaultViewport && !!options.isMobile) throw new Error(`"isMobile" option is not supported with null "viewport"`); - if (options.acceptDownloads === undefined) + if (options.acceptDownloads === undefined && browserOptions.name !== 'electron') options.acceptDownloads = 'accept'; + // Electron requires explicit acceptDownloads: true since we wait for + // https://github.com/electron/electron/pull/41718 to be widely shipped. + // In 6-12 months, we can remove this check. + else if (options.acceptDownloads === undefined && browserOptions.name === 'electron') + options.acceptDownloads = 'internal-browser-default'; if (!options.viewport && !options.noDefaultViewport) options.viewport = { width: 1280, height: 720 }; if (options.proxy) diff --git a/packages/playwright-core/src/server/dispatchers/electronDispatcher.ts b/packages/playwright-core/src/server/dispatchers/electronDispatcher.ts new file mode 100644 index 0000000000000..4dcf68d0d1db6 --- /dev/null +++ b/packages/playwright-core/src/server/dispatchers/electronDispatcher.ts @@ -0,0 +1,95 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { BrowserContextDispatcher } from './browserContextDispatcher'; +import { Dispatcher } from './dispatcher'; +import { JSHandleDispatcher, parseArgument, serializeResult } from './jsHandleDispatcher'; +import { ElectronApplication } from '../electron/electron'; + +import type { RootDispatcher } from './dispatcher'; +import type { PageDispatcher } from './pageDispatcher'; +import type { ConsoleMessage } from '../console'; +import type { Electron } from '../electron/electron'; +import type * as channels from '@protocol/channels'; +import type { Progress } from '@protocol/progress'; + + +export class ElectronDispatcher extends Dispatcher implements channels.ElectronChannel { + _type_Electron = true; + _denyLaunch: boolean; + + constructor(scope: RootDispatcher, electron: Electron, denyLaunch: boolean) { + super(scope, electron, 'Electron', {}); + this._denyLaunch = denyLaunch; + } + + async launch(params: channels.ElectronLaunchParams, progress: Progress): Promise { + if (this._denyLaunch) + throw new Error(`Launching more browsers is not allowed.`); + const electronApplication = await this._object.launch(progress, params); + return { electronApplication: new ElectronApplicationDispatcher(this, electronApplication) }; + } +} + +export class ElectronApplicationDispatcher extends Dispatcher implements channels.ElectronApplicationChannel { + _type_EventTarget = true; + _type_ElectronApplication = true; + private readonly _subscriptions = new Set(); + + constructor(scope: ElectronDispatcher, electronApplication: ElectronApplication) { + super(scope, electronApplication, 'ElectronApplication', { + context: BrowserContextDispatcher.from(scope, electronApplication.context()) + }); + this.addObjectListener(ElectronApplication.Events.Close, () => { + this._dispatchEvent('close'); + this._dispose(); + }); + this.addObjectListener(ElectronApplication.Events.Console, (message: ConsoleMessage) => { + if (!this._subscriptions.has('console')) + return; + this._dispatchEvent('console', { + type: message.type(), + text: message.text(), + args: message.args().map(a => JSHandleDispatcher.fromJSHandle(this, a)), + location: message.location(), + timestamp: message.timestamp(), + }); + }); + } + + async browserWindow(params: channels.ElectronApplicationBrowserWindowParams, progress: Progress): Promise { + const handle = await this._object.browserWindow(progress, (params.page as PageDispatcher).page()); + return { handle: JSHandleDispatcher.fromJSHandle(this, handle) }; + } + + async evaluateExpression(params: channels.ElectronApplicationEvaluateExpressionParams, progress: Progress): Promise { + const handle = await progress.race(this._object._nodeElectronHandlePromise); + return { value: serializeResult(await handle.evaluateExpression(progress, params.expression, { isFunction: params.isFunction }, parseArgument(params.arg))) }; + } + + async evaluateExpressionHandle(params: channels.ElectronApplicationEvaluateExpressionHandleParams, progress: Progress): Promise { + const handle = await progress.race(this._object._nodeElectronHandlePromise); + const result = await handle.evaluateExpressionHandle(progress, params.expression, { isFunction: params.isFunction }, parseArgument(params.arg)); + return { handle: JSHandleDispatcher.fromJSHandle(this, result) }; + } + + async updateSubscription(params: channels.ElectronApplicationUpdateSubscriptionParams, progress: Progress): Promise { + if (params.enabled) + this._subscriptions.add(params.event); + else + this._subscriptions.delete(params.event); + } +} diff --git a/packages/playwright-core/src/server/dispatchers/jsHandleDispatcher.ts b/packages/playwright-core/src/server/dispatchers/jsHandleDispatcher.ts index b1d47c0f0bd80..8b494360a9d5b 100644 --- a/packages/playwright-core/src/server/dispatchers/jsHandleDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/jsHandleDispatcher.ts @@ -19,12 +19,13 @@ import { ElementHandleDispatcher } from './elementHandlerDispatcher'; import { parseSerializedValue, serializeValue } from '../../protocol/serializers'; import type * as js from '../javascript'; +import type { ElectronApplicationDispatcher } from './electronDispatcher'; import type { FrameDispatcher } from './frameDispatcher'; import type { PageDispatcher, WorkerDispatcher } from './pageDispatcher'; import type * as channels from '@protocol/channels'; import type { Progress } from '@protocol/progress'; -export type JSHandleDispatcherParentScope = PageDispatcher | FrameDispatcher | WorkerDispatcher; +export type JSHandleDispatcherParentScope = PageDispatcher | FrameDispatcher | WorkerDispatcher | ElectronApplicationDispatcher; export class JSHandleDispatcher extends Dispatcher implements channels.JSHandleChannel { _type_JSHandle = true; diff --git a/packages/playwright-core/src/server/dispatchers/playwrightDispatcher.ts b/packages/playwright-core/src/server/dispatchers/playwrightDispatcher.ts index f11677855fe68..09a3b1dc8dcd1 100644 --- a/packages/playwright-core/src/server/dispatchers/playwrightDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/playwrightDispatcher.ts @@ -22,6 +22,7 @@ import { AndroidDeviceDispatcher } from './androidDispatcher'; import { BrowserDispatcher } from './browserDispatcher'; import { BrowserTypeDispatcher } from './browserTypeDispatcher'; import { Dispatcher } from './dispatcher'; +import { ElectronDispatcher } from './electronDispatcher'; import { LocalUtilsDispatcher } from './localUtilsDispatcher'; import { APIRequestContextDispatcher } from './networkDispatchers'; import { SdkObject } from '../instrumentation'; @@ -59,6 +60,7 @@ export class PlaywrightDispatcher extends Dispatcher> = new ManualPromise(); + private _process: childProcess.ChildProcess; + + constructor(parent: SdkObject, browser: CRBrowser, nodeConnection: CRConnection, process: childProcess.ChildProcess) { + super(parent, 'electron-app'); + this._process = process; + this._browserContext = browser._defaultContext as CRBrowserContext; + this._nodeConnection = nodeConnection; + this._nodeSession = nodeConnection.rootSession; + this._nodeSession.on('Runtime.executionContextCreated', async (event: Protocol.Runtime.executionContextCreatedPayload) => { + if (!event.context.auxData || !event.context.auxData.isDefault) + return; + const crExecutionContext = new CRExecutionContext(this._nodeSession, event.context); + this._nodeExecutionContext = new js.ExecutionContext(this, crExecutionContext, 'electron'); + const { result: remoteObject } = await crExecutionContext._client.send('Runtime.evaluate', { + expression: `require('electron')`, + contextId: event.context.id, + // Needed after Electron 28 to get access to require: https://github.com/microsoft/playwright/issues/28048 + includeCommandLineAPI: true, + }); + this._nodeElectronHandlePromise.resolve(new js.JSHandle(this._nodeExecutionContext!, 'object', 'ElectronModule', remoteObject.objectId!)); + }); + this._nodeSession.on('Runtime.consoleAPICalled', event => this._onConsoleAPI(event)); + const appClosePromise = new Promise(f => this.once(ElectronApplication.Events.Close, f)); + this._browserContext.setCustomCloseHandler(async () => { + const electronHandle = await this._nodeElectronHandlePromise; + await electronHandle.evaluate(({ app }) => app.quit()).catch(() => {}); + this._nodeConnection.close(); + await appClosePromise; + }); + } + + async _onConsoleAPI(event: Protocol.Runtime.consoleAPICalledPayload) { + if (event.executionContextId === 0) { + // DevTools protocol stores the last 1000 console messages. These + // messages are always reported even for removed execution contexts. In + // this case, they are marked with executionContextId = 0 and are + // reported upon enabling Runtime agent. + // + // Ignore these messages since: + // - there's no execution context we can use to operate with message + // arguments + // - these messages are reported before Playwright clients can subscribe + // to the 'console' + // page event. + // + // @see https://github.com/GoogleChrome/puppeteer/issues/3865 + return; + } + if (!this._nodeExecutionContext) + return; + const args = event.args.map(arg => createHandle(this._nodeExecutionContext!, arg)); + const message = new ConsoleMessage(null, null, event.type, undefined, args, stackTraceToLocation(event.stackTrace), event.timestamp); + this.emit(ElectronApplication.Events.Console, message); + } + + async initialize() { + await this._nodeSession.send('Runtime.enable', {}); + // Delay loading the app until browser is started and the browser targets are configured to auto-attach. + await this._nodeSession.send('Runtime.evaluate', { expression: '__playwright_run()' }); + } + + process(): childProcess.ChildProcess { + return this._process; + } + + context(): BrowserContext { + return this._browserContext; + } + + async close(progress: Progress) { + // This will call BrowserContext.setCustomCloseHandler. + await this._browserContext.close(progress, { reason: 'Application exited' }); + } + + async browserWindow(progress: Progress, page: Page): Promise> { + // Assume CRPage as Electron is always Chromium. + const targetId = (page.delegate as CRPage)._targetId; + const electronHandle = await progress.race(this._nodeElectronHandlePromise); + return await progress.race(electronHandle.evaluateHandle(({ BrowserWindow, webContents }, targetId) => { + const wc = webContents.fromDevToolsTargetId(targetId); + return BrowserWindow.fromWebContents(wc!)!; + }, targetId)); + } +} + +export class Electron extends SdkObject { + constructor(playwright: Playwright) { + super(playwright, 'electron'); + this.logName = 'browser'; + } + + async launch(progress: Progress, options: Omit): Promise { + let app: ElectronApplication | undefined = undefined; + // --remote-debugging-port=0 must be the last playwright's argument, loader.ts relies on it. + let electronArguments = ['--inspect=0', '--remote-debugging-port=0', ...(options.args || [])]; + + if (os.platform() === 'linux') { + if (!options.chromiumSandbox && electronArguments.indexOf('--no-sandbox') === -1) + electronArguments.unshift('--no-sandbox'); + } + + let artifactsDir: string; + const tempDirectories: string[] = []; + if (options.artifactsDir) { + artifactsDir = options.artifactsDir; + } else { + artifactsDir = await progress.race(fs.promises.mkdtemp(ARTIFACTS_FOLDER)); + tempDirectories.push(artifactsDir); + } + const browserLogsCollector = new RecentLogsCollector(); + const env = options.env ? envArrayToObject(options.env) : process.env; + + let command: string; + if (options.executablePath) { + command = options.executablePath; + } else { + try { + // By default we fallback to the Electron App executable path. + // 'electron/index.js' resolves to the actual Electron App. + command = require('electron/index.js'); + } catch (error: any) { + if ((error as NodeJS.ErrnoException)?.code === 'MODULE_NOT_FOUND') { + throw new Error('\n' + wrapInASCIIBox([ + 'Electron executablePath not found!', + 'Please install it using `npm install -D electron` or set the executablePath to your Electron executable.', + ].join('\n'), 1)); + } + throw error; + } + // Only use our own loader for non-packaged apps. + // Packaged apps might have their own command line handling. + electronArguments.unshift('-r', libPath('server', 'electron', 'loader.js')); + } + let shell = false; + if (process.platform === 'win32') { + // On Windows in order to run .cmd files, shell: true is required. + // https://github.com/nodejs/node/issues/52554 + shell = true; + // On Windows, we need to quote the executable path and arguments due to shell: true. + // We allso pass the arguments as a single string due to DEP0190, + // see https://github.com/microsoft/playwright/issues/38278. + command = [command, ...electronArguments].map(arg => `"${escapeDoubleQuotes(arg)}"`).join(' '); + electronArguments = []; + } + + // When debugging Playwright test that runs Electron, NODE_OPTIONS + // will make the debugger attach to Electron's Node. But Playwright + // also needs to attach to drive the automation. Disable external debugging. + delete env.NODE_OPTIONS; + const { launchedProcess, gracefullyClose, kill } = await progress.race(launchProcess({ + command, + args: electronArguments, + env, + log: (message: string) => { + progress.log(message); + browserLogsCollector.log(message); + }, + shell, + stdio: 'pipe', + cwd: options.cwd, + tempDirectories, + attemptToGracefullyClose: () => app!.close(nullProgress), + handleSIGINT: true, + handleSIGTERM: true, + handleSIGHUP: true, + onExit: () => app?.emit(ElectronApplication.Events.Close), + })); + + // All waitForLines must be started immediately. + // Otherwise the lines might come before we are ready. + const waitForXserverError = waitForLine(progress, launchedProcess, /Unable to open X display/).then(() => { + throw new Error([ + 'Unable to open X display!', + `================================`, + 'Most likely this is because there is no X server available.', + "Use 'xvfb-run' on Linux to launch your tests with an emulated display server.", + "For example: 'xvfb-run npm run test:e2e'", + `================================`, + progress.metadata.log + ].join('\n')); + }); + const nodeMatchPromise = waitForLine(progress, launchedProcess, /^Debugger listening on (ws:\/\/.*)$/); + const chromeMatchPromise = waitForLine(progress, launchedProcess, /^DevTools listening on (ws:\/\/.*)$/); + const debuggerDisconnectPromise = waitForLine(progress, launchedProcess, /Waiting for the debugger to disconnect\.\.\./); + + try { + const nodeMatch = await nodeMatchPromise; + const nodeTransport = await WebSocketTransport.connect(progress, nodeMatch[1]); + const nodeConnection = new CRConnection(this, nodeTransport, helper.debugProtocolLogger(), browserLogsCollector); + // Immediately release exiting process under debug. + debuggerDisconnectPromise.then(() => { + nodeTransport.close(); + }).catch(() => {}); + + const chromeMatch = await progress.race(Promise.race([ + chromeMatchPromise, + waitForXserverError, + ])); + const chromeTransport = await WebSocketTransport.connect(progress, chromeMatch[1]); + const browserProcess: BrowserProcess = { + onclose: undefined, + process: launchedProcess, + close: gracefullyClose, + kill + }; + const contextOptions: types.BrowserContextOptions = { + ...options, + noDefaultViewport: true, + }; + const browserOptions: BrowserOptions = { + name: 'electron', + browserType: 'chromium', + headful: true, + persistent: contextOptions, + browserProcess, + protocolLogger: helper.debugProtocolLogger(), + browserLogsCollector, + artifactsDir, + downloadsPath: artifactsDir, + tracesDir: options.tracesDir || artifactsDir, + originalLaunchOptions: {}, + }; + validateBrowserContextOptions(contextOptions, browserOptions); + const browser = await progress.race(CRBrowser.connect(this.attribution.playwright, chromeTransport, browserOptions)); + app = new ElectronApplication(this, browser, nodeConnection, launchedProcess); + await progress.race(app.initialize()); + return app; + } catch (error) { + await progress.race(kill()); + throw error; + } + } +} + +async function waitForLine(progress: Progress, process: childProcess.ChildProcess, regex: RegExp) { + const promise = new ManualPromise(); + // eslint-disable-next-line no-restricted-properties + const rl = readline.createInterface({ input: process.stderr! }); + const failError = new Error('Process failed to launch!'); + const listeners = [ + eventsHelper.addEventListener(rl, 'line', onLine), + eventsHelper.addEventListener(rl, 'close', () => promise.reject(failError)), + eventsHelper.addEventListener(process, 'exit', () => promise.reject(failError)), + // It is Ok to remove error handler because we did not create process and there is another listener. + eventsHelper.addEventListener(process, 'error', () => promise.reject(failError)), + ]; + + function onLine(line: string) { + const match = line.match(regex); + if (match) + promise.resolve(match); + } + + try { + return await progress.race(promise); + } finally { + eventsHelper.removeEventListeners(listeners); + } +} + +function escapeDoubleQuotes(str: string): string { + return str.replace(/"/g, '\\"'); +} diff --git a/packages/playwright-core/src/server/electron/loader.ts b/packages/playwright-core/src/server/electron/loader.ts new file mode 100644 index 0000000000000..80c6f76fb3d37 --- /dev/null +++ b/packages/playwright-core/src/server/electron/loader.ts @@ -0,0 +1,54 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { app } from 'electron'; +import { chromiumSwitches } from '../chromium/chromiumSwitches'; + +// Always pass user arguments first, see https://github.com/microsoft/playwright/issues/16614 and +// https://github.com/microsoft/playwright/issues/29198. +// [Electron, -r, loader.js[, --no-sandbox>], --inspect=0, --remote-debugging-port=0, ...args] +process.argv.splice(1, process.argv.indexOf('--remote-debugging-port=0')); + +for (const arg of chromiumSwitches()) { + const match = arg.match(/--([^=]*)=?(.*)/)!; + app.commandLine.appendSwitch(match[1], match[2]); +} + +// Defer ready event. +const originalWhenReady = app.whenReady(); +const originalEmit = app.emit.bind(app); +let readyEventArgs: any[]; +app.emit = (event: string | symbol, ...args: any[]): boolean => { + if (event === 'ready') { + readyEventArgs = args; + return app.listenerCount('ready') > 0; + } + return originalEmit(event, ...args); +}; + +let isReady = false; +let whenReadyCallback: (event: any) => any; +const whenReadyPromise = new Promise(f => whenReadyCallback = f); +app.isReady = () => isReady; +app.whenReady = () => whenReadyPromise; + +(globalThis as any).__playwright_run = async () => { + // Wait for app to be ready to avoid browser initialization races. + const event = await originalWhenReady; + isReady = true; + whenReadyCallback(event); + originalEmit('ready', ...readyEventArgs); +}; diff --git a/packages/playwright-core/src/server/playwright.ts b/packages/playwright-core/src/server/playwright.ts index 26d13551c4fb6..3430f7829f27c 100644 --- a/packages/playwright-core/src/server/playwright.ts +++ b/packages/playwright-core/src/server/playwright.ts @@ -20,6 +20,7 @@ import { BidiChromium } from './bidi/bidiChromium'; import { BidiFirefox } from './bidi/bidiFirefox'; import { Chromium } from './chromium/chromium'; import { DebugController } from './debugController'; +import { Electron } from './electron/electron'; import { Firefox } from './firefox/firefox'; import { SdkObject, createRootSdkObject } from './instrumentation'; import { WebKit } from './webkit/webkit'; @@ -38,6 +39,7 @@ type PlaywrightOptions = { export class Playwright extends SdkObject { readonly chromium: BrowserType; readonly android: Android; + readonly electron: Electron; readonly firefox: BrowserType; readonly webkit: BrowserType; readonly options: PlaywrightOptions; @@ -58,6 +60,7 @@ export class Playwright extends SdkObject { this.chromium = new Chromium(this, new BidiChromium(this)); this.firefox = new Firefox(this, new BidiFirefox(this)); this.webkit = new WebKit(this); + this.electron = new Electron(this); this.android = new Android(this, new AdbBackend()); this.debugController = new DebugController(this); } diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index cb4cfb3767467..f3488dd71ad80 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -16702,6 +16702,450 @@ class TimeoutError extends Error { export const devices: Devices; +//@ts-ignore this will be any if electron is not installed +type ElectronType = typeof import('electron'); + +/** + * Electron application representation. You can use + * [electron.launch([options])](https://playwright.dev/docs/api/class-electron#electron-launch) to obtain the + * application instance. This instance you can control main electron process as well as work with Electron windows: + * + * ```js + * const { _electron: electron } = require('playwright'); + * + * (async () => { + * // Launch Electron app. + * const electronApp = await electron.launch({ args: ['main.js'] }); + * + * // Evaluation expression in the Electron context. + * const appPath = await electronApp.evaluate(async ({ app }) => { + * // This runs in the main Electron process, parameter here is always + * // the result of the require('electron') in the main app script. + * return app.getAppPath(); + * }); + * console.log(appPath); + * + * // Get the first window that the app opens, wait if necessary. + * const window = await electronApp.firstWindow(); + * // Print the title. + * console.log(await window.title()); + * // Capture a screenshot. + * await window.screenshot({ path: 'intro.png' }); + * // Direct Electron console to Node terminal. + * window.on('console', console.log); + * // Click button. + * await window.click('text=Click me'); + * // Exit app. + * await electronApp.close(); + * })(); + * ``` + * + */ +export interface ElectronApplication { + /** + * Returns the return value of + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-option-expression). + * + * If the function passed to the + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * returns a [Promise], then + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * would wait for the promise to resolve and return its value. + * + * If the function passed to the + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * returns a non-[Serializable] value, then + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * returns `undefined`. Playwright also supports transferring some additional values that are not serializable by + * `JSON`: `-0`, `NaN`, `Infinity`, `-Infinity`. + * @param pageFunction Function to be evaluated in the main Electron process. + * @param arg Optional argument to pass to + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-option-expression). + */ + /** + * Returns the return value of + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-option-expression). + * + * If the function passed to the + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * returns a [Promise], then + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * would wait for the promise to resolve and return its value. + * + * If the function passed to the + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * returns a non-[Serializable] value, then + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * returns `undefined`. Playwright also supports transferring some additional values that are not serializable by + * `JSON`: `-0`, `NaN`, `Infinity`, `-Infinity`. + * @param pageFunction Function to be evaluated in the main Electron process. + * @param arg Optional argument to pass to + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-option-expression). + */ + evaluate(pageFunction: PageFunctionOn, arg: Arg): Promise; + /** + * Returns the return value of + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-option-expression). + * + * If the function passed to the + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * returns a [Promise], then + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * would wait for the promise to resolve and return its value. + * + * If the function passed to the + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * returns a non-[Serializable] value, then + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * returns `undefined`. Playwright also supports transferring some additional values that are not serializable by + * `JSON`: `-0`, `NaN`, `Infinity`, `-Infinity`. + * @param pageFunction Function to be evaluated in the main Electron process. + * @param arg Optional argument to pass to + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-option-expression). + */ + /** + * Returns the return value of + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-option-expression). + * + * If the function passed to the + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * returns a [Promise], then + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * would wait for the promise to resolve and return its value. + * + * If the function passed to the + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * returns a non-[Serializable] value, then + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * returns `undefined`. Playwright also supports transferring some additional values that are not serializable by + * `JSON`: `-0`, `NaN`, `Infinity`, `-Infinity`. + * @param pageFunction Function to be evaluated in the main Electron process. + * @param arg Optional argument to pass to + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-option-expression). + */ + evaluate(pageFunction: PageFunctionOn, arg?: any): Promise; + + /** + * Returns the return value of + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle-option-expression) + * as a [JSHandle](https://playwright.dev/docs/api/class-jshandle). + * + * The only difference between + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * and + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) + * is that + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) + * returns [JSHandle](https://playwright.dev/docs/api/class-jshandle). + * + * If the function passed to the + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) + * returns a [Promise], then + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) + * would wait for the promise to resolve and return its value. + * @param pageFunction Function to be evaluated in the main Electron process. + * @param arg Optional argument to pass to + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle-option-expression). + */ + /** + * Returns the return value of + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle-option-expression) + * as a [JSHandle](https://playwright.dev/docs/api/class-jshandle). + * + * The only difference between + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * and + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) + * is that + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) + * returns [JSHandle](https://playwright.dev/docs/api/class-jshandle). + * + * If the function passed to the + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) + * returns a [Promise], then + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) + * would wait for the promise to resolve and return its value. + * @param pageFunction Function to be evaluated in the main Electron process. + * @param arg Optional argument to pass to + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle-option-expression). + */ + evaluateHandle(pageFunction: PageFunctionOn, arg: Arg): Promise>; + /** + * Returns the return value of + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle-option-expression) + * as a [JSHandle](https://playwright.dev/docs/api/class-jshandle). + * + * The only difference between + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * and + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) + * is that + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) + * returns [JSHandle](https://playwright.dev/docs/api/class-jshandle). + * + * If the function passed to the + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) + * returns a [Promise], then + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) + * would wait for the promise to resolve and return its value. + * @param pageFunction Function to be evaluated in the main Electron process. + * @param arg Optional argument to pass to + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle-option-expression). + */ + /** + * Returns the return value of + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle-option-expression) + * as a [JSHandle](https://playwright.dev/docs/api/class-jshandle). + * + * The only difference between + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * and + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) + * is that + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) + * returns [JSHandle](https://playwright.dev/docs/api/class-jshandle). + * + * If the function passed to the + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) + * returns a [Promise], then + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) + * would wait for the promise to resolve and return its value. + * @param pageFunction Function to be evaluated in the main Electron process. + * @param arg Optional argument to pass to + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle-option-expression). + */ + evaluateHandle(pageFunction: PageFunctionOn, arg?: any): Promise>; + /** + * This event is issued when the application process has been terminated. + */ + on(event: 'close', listener: () => any): this; + + /** + * Emitted when JavaScript within the Electron main process calls one of console API methods, e.g. `console.log` or + * `console.dir`. + * + * The arguments passed into `console.log` are available on the + * [ConsoleMessage](https://playwright.dev/docs/api/class-consolemessage) event handler argument. + * + * **Usage** + * + * ```js + * electronApp.on('console', async msg => { + * const values = []; + * for (const arg of msg.args()) + * values.push(await arg.jsonValue()); + * console.log(...values); + * }); + * await electronApp.evaluate(() => console.log('hello', 5, { foo: 'bar' })); + * ``` + * + */ + on(event: 'console', listener: (consoleMessage: ConsoleMessage) => any): this; + + /** + * This event is issued for every window that is created **and loaded** in Electron. It contains a + * [Page](https://playwright.dev/docs/api/class-page) that can be used for Playwright automation. + */ + on(event: 'window', listener: (page: Page) => any): this; + + /** + * Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event. + */ + once(event: 'close', listener: () => any): this; + + /** + * Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event. + */ + once(event: 'console', listener: (consoleMessage: ConsoleMessage) => any): this; + + /** + * Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event. + */ + once(event: 'window', listener: (page: Page) => any): this; + + /** + * This event is issued when the application process has been terminated. + */ + addListener(event: 'close', listener: () => any): this; + + /** + * Emitted when JavaScript within the Electron main process calls one of console API methods, e.g. `console.log` or + * `console.dir`. + * + * The arguments passed into `console.log` are available on the + * [ConsoleMessage](https://playwright.dev/docs/api/class-consolemessage) event handler argument. + * + * **Usage** + * + * ```js + * electronApp.on('console', async msg => { + * const values = []; + * for (const arg of msg.args()) + * values.push(await arg.jsonValue()); + * console.log(...values); + * }); + * await electronApp.evaluate(() => console.log('hello', 5, { foo: 'bar' })); + * ``` + * + */ + addListener(event: 'console', listener: (consoleMessage: ConsoleMessage) => any): this; + + /** + * This event is issued for every window that is created **and loaded** in Electron. It contains a + * [Page](https://playwright.dev/docs/api/class-page) that can be used for Playwright automation. + */ + addListener(event: 'window', listener: (page: Page) => any): this; + + /** + * Removes an event listener added by `on` or `addListener`. + */ + removeListener(event: 'close', listener: () => any): this; + + /** + * Removes an event listener added by `on` or `addListener`. + */ + removeListener(event: 'console', listener: (consoleMessage: ConsoleMessage) => any): this; + + /** + * Removes an event listener added by `on` or `addListener`. + */ + removeListener(event: 'window', listener: (page: Page) => any): this; + + /** + * Removes an event listener added by `on` or `addListener`. + */ + off(event: 'close', listener: () => any): this; + + /** + * Removes an event listener added by `on` or `addListener`. + */ + off(event: 'console', listener: (consoleMessage: ConsoleMessage) => any): this; + + /** + * Removes an event listener added by `on` or `addListener`. + */ + off(event: 'window', listener: (page: Page) => any): this; + + /** + * This event is issued when the application process has been terminated. + */ + prependListener(event: 'close', listener: () => any): this; + + /** + * Emitted when JavaScript within the Electron main process calls one of console API methods, e.g. `console.log` or + * `console.dir`. + * + * The arguments passed into `console.log` are available on the + * [ConsoleMessage](https://playwright.dev/docs/api/class-consolemessage) event handler argument. + * + * **Usage** + * + * ```js + * electronApp.on('console', async msg => { + * const values = []; + * for (const arg of msg.args()) + * values.push(await arg.jsonValue()); + * console.log(...values); + * }); + * await electronApp.evaluate(() => console.log('hello', 5, { foo: 'bar' })); + * ``` + * + */ + prependListener(event: 'console', listener: (consoleMessage: ConsoleMessage) => any): this; + + /** + * This event is issued for every window that is created **and loaded** in Electron. It contains a + * [Page](https://playwright.dev/docs/api/class-page) that can be used for Playwright automation. + */ + prependListener(event: 'window', listener: (page: Page) => any): this; + + /** + * Returns the BrowserWindow object that corresponds to the given Playwright page. + * @param page Page to retrieve the window for. + */ + browserWindow(page: Page): Promise; + + /** + * Closes Electron application. + */ + close(): Promise; + + /** + * This method returns browser context that can be used for setting up context-wide routing, etc. + */ + context(): BrowserContext; + + /** + * Convenience method that waits for the first application window to be opened. + * + * **Usage** + * + * ```js + * const electronApp = await electron.launch({ + * args: ['main.js'] + * }); + * const window = await electronApp.firstWindow(); + * // ... + * ``` + * + * @param options + */ + firstWindow(options?: { + /** + * Maximum time to wait for in milliseconds. Defaults to `30000` (30 seconds). Pass `0` to disable timeout. The + * default value can be changed by using the + * [browserContext.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-default-timeout). + */ + timeout?: number; + }): Promise; + + /** + * Returns the main process for this Electron Application. + */ + process(): ChildProcess; + + /** + * This event is issued when the application process has been terminated. + */ + waitForEvent(event: 'close', optionsOrPredicate?: { predicate?: () => boolean | Promise, timeout?: number } | (() => boolean | Promise)): Promise; + + /** + * Emitted when JavaScript within the Electron main process calls one of console API methods, e.g. `console.log` or + * `console.dir`. + * + * The arguments passed into `console.log` are available on the + * [ConsoleMessage](https://playwright.dev/docs/api/class-consolemessage) event handler argument. + * + * **Usage** + * + * ```js + * electronApp.on('console', async msg => { + * const values = []; + * for (const arg of msg.args()) + * values.push(await arg.jsonValue()); + * console.log(...values); + * }); + * await electronApp.evaluate(() => console.log('hello', 5, { foo: 'bar' })); + * ``` + * + */ + waitForEvent(event: 'console', optionsOrPredicate?: { predicate?: (consoleMessage: ConsoleMessage) => boolean | Promise, timeout?: number } | ((consoleMessage: ConsoleMessage) => boolean | Promise)): Promise; + + /** + * This event is issued for every window that is created **and loaded** in Electron. It contains a + * [Page](https://playwright.dev/docs/api/class-page) that can be used for Playwright automation. + */ + waitForEvent(event: 'window', optionsOrPredicate?: { predicate?: (page: Page) => boolean | Promise, timeout?: number } | ((page: Page) => boolean | Promise)): Promise; + + + /** + * Convenience method that returns all the opened windows. + */ + windows(): Array; + + [Symbol.asyncDispose](): Promise; +} + export type AndroidElementInfo = { clazz: string; desc: string; @@ -16807,6 +17251,7 @@ export type AndroidKey = 'Copy' | 'Paste'; +export const _electron: Electron; export const _android: Android; //@ts-ignore this will be any if electron is not installed @@ -16818,7 +17263,7 @@ type ElectronType = typeof import('electron'); * application instance. This instance you can control main electron process as well as work with Electron windows: * * ```js - * import { _electron as electron } from 'playwright'; + * const { _electron: electron } = require('playwright'); * * (async () => { * // Launch Electron app. @@ -16854,22 +17299,151 @@ export interface ElectronApplication { * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-option-expression). * * If the function passed to the - * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * returns a [Promise], then + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * would wait for the promise to resolve and return its value. + * + * If the function passed to the + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * returns a non-[Serializable] value, then + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * returns `undefined`. Playwright also supports transferring some additional values that are not serializable by + * `JSON`: `-0`, `NaN`, `Infinity`, `-Infinity`. + * @param pageFunction Function to be evaluated in the main Electron process. + * @param arg Optional argument to pass to + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-option-expression). + */ + /** + * Returns the return value of + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-option-expression). + * + * If the function passed to the + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * returns a [Promise], then + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * would wait for the promise to resolve and return its value. + * + * If the function passed to the + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * returns a non-[Serializable] value, then + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * returns `undefined`. Playwright also supports transferring some additional values that are not serializable by + * `JSON`: `-0`, `NaN`, `Infinity`, `-Infinity`. + * @param pageFunction Function to be evaluated in the main Electron process. + * @param arg Optional argument to pass to + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-option-expression). + */ + evaluate(pageFunction: PageFunctionOn, arg: Arg): Promise; + /** + * Returns the return value of + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-option-expression). + * + * If the function passed to the + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * returns a [Promise], then + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * would wait for the promise to resolve and return its value. + * + * If the function passed to the + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * returns a non-[Serializable] value, then + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * returns `undefined`. Playwright also supports transferring some additional values that are not serializable by + * `JSON`: `-0`, `NaN`, `Infinity`, `-Infinity`. + * @param pageFunction Function to be evaluated in the main Electron process. + * @param arg Optional argument to pass to + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-option-expression). + */ + /** + * Returns the return value of + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-option-expression). + * + * If the function passed to the + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * returns a [Promise], then + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * would wait for the promise to resolve and return its value. + * + * If the function passed to the + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * returns a non-[Serializable] value, then + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * returns `undefined`. Playwright also supports transferring some additional values that are not serializable by + * `JSON`: `-0`, `NaN`, `Infinity`, `-Infinity`. + * @param pageFunction Function to be evaluated in the main Electron process. + * @param arg Optional argument to pass to + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-option-expression). + */ + evaluate(pageFunction: PageFunctionOn, arg?: any): Promise; + + /** + * Returns the return value of + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle-option-expression) + * as a [JSHandle](https://playwright.dev/docs/api/class-jshandle). + * + * The only difference between + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * and + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) + * is that + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) + * returns [JSHandle](https://playwright.dev/docs/api/class-jshandle). + * + * If the function passed to the + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) + * returns a [Promise], then + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) + * would wait for the promise to resolve and return its value. + * @param pageFunction Function to be evaluated in the main Electron process. + * @param arg Optional argument to pass to + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle-option-expression). + */ + /** + * Returns the return value of + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle-option-expression) + * as a [JSHandle](https://playwright.dev/docs/api/class-jshandle). + * + * The only difference between + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * and + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) + * is that + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) + * returns [JSHandle](https://playwright.dev/docs/api/class-jshandle). + * + * If the function passed to the + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) + * returns a [Promise], then + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) + * would wait for the promise to resolve and return its value. + * @param pageFunction Function to be evaluated in the main Electron process. + * @param arg Optional argument to pass to + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle-option-expression). + */ + evaluateHandle(pageFunction: PageFunctionOn, arg: Arg): Promise>; + /** + * Returns the return value of + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle-option-expression) + * as a [JSHandle](https://playwright.dev/docs/api/class-jshandle). + * + * The only difference between + * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * and + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) + * is that + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) + * returns [JSHandle](https://playwright.dev/docs/api/class-jshandle). + * + * If the function passed to the + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) * returns a [Promise], then - * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) + * [electronApplication.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle) * would wait for the promise to resolve and return its value. - * - * If the function passed to the - * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) - * returns a non-[Serializable] value, then - * [electronApplication.evaluate(pageFunction[, arg])](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate) - * returns `undefined`. Playwright also supports transferring some additional values that are not serializable by - * `JSON`: `-0`, `NaN`, `Infinity`, `-Infinity`. * @param pageFunction Function to be evaluated in the main Electron process. * @param arg Optional argument to pass to - * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-option-expression). + * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle-option-expression). */ - evaluate: JSHandle['evaluate']; /** * Returns the return value of * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle-option-expression) @@ -16892,7 +17466,7 @@ export interface ElectronApplication { * @param arg Optional argument to pass to * [`pageFunction`](https://playwright.dev/docs/api/class-electronapplication#electron-application-evaluate-handle-option-expression). */ - evaluateHandle: JSHandle['evaluateHandle']; + evaluateHandle(pageFunction: PageFunctionOn, arg?: any): Promise>; /** * This event is issued when the application process has been terminated. */ @@ -17124,8 +17698,6 @@ export interface ElectronApplication { [Symbol.asyncDispose](): Promise; } -export const _electron: Electron; - // This is required to not export everything by default. See https://github.com/Microsoft/TypeScript/issues/19545#issuecomment-340490459 export {}; @@ -21317,11 +21889,16 @@ export interface WebSocket { } /** - * Playwright has **experimental** support for Electron automation, exposed as `_electron`. An example of the Electron - * automation script would be: + * Playwright has **experimental** support for Electron automation. You can access electron namespace via: + * + * ```js + * const { _electron } = require('playwright'); + * ``` + * + * An example of the Electron automation script would be: * * ```js - * import { _electron as electron } from 'playwright'; + * const { _electron: electron } = require('playwright'); * * (async () => { * // Launch Electron app. @@ -21361,127 +21938,6 @@ export interface WebSocket { * - Ensure that `nodeCliInspect` * ([FuseV1Options.EnableNodeCliInspectArguments](https://www.electronjs.org/docs/latest/tutorial/fuses#nodecliinspect)) * fuse is **not** set to `false`. - * - * **Migrating from v1.59** - * - * A number of launch options have been removed after v1.59. See below for alternatives. - * - `recordHar` - use - * [tracing.startHar(path[, options])](https://playwright.dev/docs/api/class-tracing#tracing-start-har). - * - * ```js - * const electronApp = await electron.launch({ args: ['main.js'] }); - * await electronApp.context().tracing.startHar('network.har'); - * // ... drive the app ... - * await electronApp.context().tracing.stopHar(); - * await electronApp.close(); - * ``` - * - * - `recordVideo` - use - * [screencast.start([options])](https://playwright.dev/docs/api/class-screencast#screencast-start) on each - * window. - * - * ```js - * const electronApp = await electron.launch({ args: ['main.js'] }); - * const window = await electronApp.firstWindow(); - * await window.screencast.start({ path: 'video.webm' }); - * // ... drive the window ... - * await window.screencast.stop(); - * await electronApp.close(); - * ``` - * - * - `colorScheme` - use - * [page.emulateMedia([options])](https://playwright.dev/docs/api/class-page#page-emulate-media) on each window. - * - * ```js - * const window = await electronApp.firstWindow(); - * await window.emulateMedia({ colorScheme: 'dark' }); - * ``` - * - * - `extraHTTPHeaders` - use - * [browserContext.setExtraHTTPHeaders(headers)](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-extra-http-headers). - * - * ```js - * await electronApp.context().setExtraHTTPHeaders({ 'X-My-Header': 'value' }); - * ``` - * - * - `geolocation` - use - * [browserContext.setGeolocation(geolocation)](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-geolocation). - * - * ```js - * await electronApp.context().setGeolocation({ latitude: 48.858455, longitude: 2.294474 }); - * ``` - * - * - `httpCredentials` - use - * [browserContext.setHTTPCredentials(httpCredentials)](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-http-credentials). - * - * ```js - * await electronApp.context().setHTTPCredentials({ username: 'user', password: 'pass' }); - * ``` - * - * - `offline` - use - * [browserContext.setOffline(offline)](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-offline). - * - * ```js - * await electronApp.context().setOffline(true); - * ``` - * - * - `bypassCSP` - disable CSP at the `BrowserWindow` level via Electron's - * [web preferences](https://www.electronjs.org/docs/latest/api/structures/web-preferences). Note that - * `webSecurity: false` also disables CORS and the Same-Origin Policy. - * - * ```js - * const win = new BrowserWindow({ - * webPreferences: { - * webSecurity: false, - * }, - * }); - * ``` - * - * - `ignoreHTTPSErrors` - * - * There are several ways to relax HTTPS checks in Electron. Pick the one that matches the scope you need. - * - * Per-window, allow mixed content through - * [web preferences](https://www.electronjs.org/docs/latest/api/structures/web-preferences): - * - * ```js - * const win = new BrowserWindow({ - * webPreferences: { - * allowRunningInsecureContent: true, - * }, - * }); - * ``` - * - * Process-wide, ignore certificate errors via Chromium command-line switches (must run before the `ready` event): - * - * ```js - * const { app } = require('electron'); - * app.commandLine.appendSwitch('ignore-certificate-errors'); - * // Optional: also ignore localhost certificate errors when testing on an IP. - * app.commandLine.appendSwitch('allow-insecure-localhost', 'true'); - * ``` - * - * Per-request, accept the certificate manually via the - * [`certificate-error`](https://www.electronjs.org/docs/latest/api/app#event-certificate-error) event: - * - * ```js - * app.on('certificate-error', (event, webContents, url, error, certificate, callback) => { - * event.preventDefault(); - * callback(true); - * }); - * ``` - * - * - `timezoneId` - set an environment variable at the very top of the main file, before any other logic or Chromium - * windows are initialized. - * - * ```js - * // main.js - * process.env.TZ = 'Europe/London'; - * - * const { app } = require('electron'); - * // ... rest of your app logic - * ``` - * */ export interface Electron { /** @@ -21490,16 +21946,41 @@ export interface Electron { * @param options */ launch(options?: { + /** + * Whether to automatically download all the attachments. Defaults to `true` where all the downloads are accepted. + */ + acceptDownloads?: boolean; + /** * Additional arguments to pass to the application when launching. You typically pass the main script name here. */ args?: Array; + /** + * If specified, artifacts (traces, videos, downloads, HAR files, etc.) are saved into this directory. The directory + * is not cleaned up when the browser closes. If not specified, a temporary directory is used and cleaned up when the + * browser closes. + */ + artifactsDir?: string; + + /** + * Toggles bypassing page's Content-Security-Policy. Defaults to `false`. + */ + bypassCSP?: boolean; + /** * Enable Chromium sandboxing. Defaults to `false`. */ chromiumSandbox?: boolean; + /** + * Emulates [prefers-colors-scheme](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme) + * media feature, supported values are `'light'` and `'dark'`. See + * [page.emulateMedia([options])](https://playwright.dev/docs/api/class-page#page-emulate-media) for more details. + * Passing `null` resets emulation to system defaults. Defaults to `'light'`. + */ + colorScheme?: null|"light"|"dark"|"no-preference"; + /** * Current working directory to launch application from. */ @@ -21516,11 +21997,180 @@ export interface Electron { */ executablePath?: string; + /** + * An object containing additional HTTP headers to be sent with every request. Defaults to none. + */ + extraHTTPHeaders?: { [key: string]: string; }; + + geolocation?: { + /** + * Latitude between -90 and 90. + */ + latitude: number; + + /** + * Longitude between -180 and 180. + */ + longitude: number; + + /** + * Non-negative accuracy value. Defaults to `0`. + */ + accuracy?: number; + }; + + /** + * Credentials for [HTTP authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication). If no + * origin is specified, the username and password are sent to any servers upon unauthorized responses. + */ + httpCredentials?: { + username: string; + + password: string; + + /** + * Restrain sending http credentials on specific origin (scheme://host:port). + */ + origin?: string; + + /** + * This option only applies to the requests sent from corresponding + * [APIRequestContext](https://playwright.dev/docs/api/class-apirequestcontext) and does not affect requests sent from + * the browser. `'always'` - `Authorization` header with basic authentication credentials will be sent with the each + * API request. `'unauthorized` - the credentials are only sent when 401 (Unauthorized) response with + * `WWW-Authenticate` header is received. Defaults to `'unauthorized'`. + */ + send?: "unauthorized"|"always"; + }; + + /** + * Whether to ignore HTTPS errors when sending network requests. Defaults to `false`. + */ + ignoreHTTPSErrors?: boolean; + + /** + * Specify user locale, for example `en-GB`, `de-DE`, etc. Locale will affect `navigator.language` value, + * `Accept-Language` request header value as well as number and date formatting rules. Defaults to the system default + * locale. Learn more about emulation in our [emulation guide](https://playwright.dev/docs/emulation#locale--timezone). + */ + locale?: string; + + /** + * Whether to emulate network being offline. Defaults to `false`. Learn more about + * [network emulation](https://playwright.dev/docs/emulation#offline). + */ + offline?: boolean; + + /** + * Enables [HAR](http://www.softwareishard.com/blog/har-12-spec) recording for all pages into `recordHar.path` file. + * If not specified, the HAR is not recorded. Make sure to await + * [browserContext.close([options])](https://playwright.dev/docs/api/class-browsercontext#browser-context-close) for + * the HAR to be saved. + */ + recordHar?: { + /** + * Optional setting to control whether to omit request content from the HAR. Defaults to `false`. Deprecated, use + * `content` policy instead. + */ + omitContent?: boolean; + + /** + * Optional setting to control resource content management. If `omit` is specified, content is not persisted. If + * `attach` is specified, resources are persisted as separate files or entries in the ZIP archive. If `embed` is + * specified, content is stored inline the HAR file as per HAR specification. Defaults to `attach` for `.zip` output + * files and to `embed` for all other file extensions. + */ + content?: "omit"|"embed"|"attach"; + + /** + * Path on the filesystem to write the HAR file to. If the file name ends with `.zip`, `content: 'attach'` is used by + * default. + */ + path: string; + + /** + * When set to `minimal`, only record information necessary for routing from HAR. This omits sizes, timing, page, + * cookies, security and other types of HAR information that are not used when replaying from HAR. Defaults to `full`. + */ + mode?: "full"|"minimal"; + + /** + * A glob or regex pattern to filter requests that are stored in the HAR. When a + * [`baseURL`](https://playwright.dev/docs/api/class-browser#browser-new-context-option-base-url) via the context + * options was provided and the passed URL is a path, it gets merged via the + * [`new URL()`](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL) constructor. Defaults to none. + */ + urlFilter?: string|RegExp; + }; + + /** + * Enables video recording for all pages into `recordVideo.dir` directory. If not specified videos are not recorded. + * Make sure to await + * [browserContext.close([options])](https://playwright.dev/docs/api/class-browsercontext#browser-context-close) for + * videos to be saved. + */ + recordVideo?: { + /** + * Path to the directory to put videos into. If not specified, the videos will be stored in `artifactsDir` (see + * [browserType.launch([options])](https://playwright.dev/docs/api/class-browsertype#browser-type-launch) options). + */ + dir?: string; + + /** + * Optional dimensions of the recorded videos. If not specified the size will be equal to `viewport` scaled down to + * fit into 800x800. If `viewport` is not configured explicitly the video size defaults to 800x450. Actual picture of + * each page will be scaled down if necessary to fit the specified size. + */ + size?: { + /** + * Video frame width. + */ + width: number; + + /** + * Video frame height. + */ + height: number; + }; + + /** + * If specified, enables visual annotations on interacted elements during video recording. + */ + showActions?: { + /** + * How long each annotation is displayed in milliseconds. Defaults to `500`. + */ + duration?: number; + + /** + * Position of the action title overlay. Defaults to `"top-right"`. + */ + position?: "top-left"|"top"|"top-right"|"bottom-left"|"bottom"|"bottom-right"; + + /** + * Font size of the action title in pixels. Defaults to `24`. + */ + fontSize?: number; + }; + }; + /** * Maximum time in milliseconds to wait for the application to start. Defaults to `30000` (30 seconds). Pass `0` to * disable timeout. */ timeout?: number; + + /** + * Changes the timezone of the context. See + * [ICU's metaZones.txt](https://cs.chromium.org/chromium/src/third_party/icu/source/data/misc/metaZones.txt?rcl=faee8bc70570192d82d2978a71e2a615788597d1) + * for a list of supported timezone IDs. Defaults to the system timezone. + */ + timezoneId?: string; + + /** + * If specified, traces are saved into this directory. + */ + tracesDir?: string; }): Promise; } diff --git a/packages/playwright-electron/.npmignore b/packages/playwright-electron/.npmignore deleted file mode 100644 index 2ea87ab6083c9..0000000000000 --- a/packages/playwright-electron/.npmignore +++ /dev/null @@ -1,8 +0,0 @@ -**/* - -!README.md -!LICENSE -!lib/** -!index.d.ts -!types.d.ts -!index.js diff --git a/packages/playwright-electron/README.md b/packages/playwright-electron/README.md deleted file mode 100644 index bfdd76e689d28..0000000000000 --- a/packages/playwright-electron/README.md +++ /dev/null @@ -1,170 +0,0 @@ -# 🎭 Playwright for Electron - -[![npm version](https://img.shields.io/npm/v/@playwright/electron.svg)](https://www.npmjs.com/package/@playwright/electron) [![Join Discord](https://img.shields.io/badge/join-discord-informational)](https://aka.ms/playwright/discord) - -## [Documentation](https://playwright.dev/docs/api/class-electron) | [API reference](https://playwright.dev/docs/api/class-electron) - -Drive [Electron](https://www.electronjs.org/) apps with the full Playwright API — evaluate in the main process, control each `BrowserWindow` as a Playwright [Page](https://playwright.dev/docs/api/class-page), use locators and assertions. - -## Install - -```bash -npm i -D @playwright/electron -``` - -## Usage - -```js -import { electron } from '@playwright/electron'; - -const electronApp = await electron.launch({ args: ['main.js'] }); -const window = await electronApp.firstWindow(); -// ... drive `window` like any Playwright Page ... -await electronApp.close(); -``` - -Read more at https://playwright.dev/docs/api/class-electron. - -## Migrating from v1.59 - -Prior to v1.60, the Electron API shipped as `playwright._electron` from the -`playwright` package. It is now exposed as `electron` from this dedicated -package. - -A number of `electron.launch(...)` options have changed in the process. The sections below describe how to achieve -the same behavior with public Playwright APIs, or with built-in Electron APIs. - -### Use Playwright APIs after launch - -#### `recordHar` - -Use [`browserContext.tracing.startHar`](https://playwright.dev/docs/api/class-tracing#tracing-start-har) / -[`stopHar`](https://playwright.dev/docs/api/class-tracing#tracing-stop-har). - -```js -const electronApp = await electron.launch({ args: ['main.js'] }); -await electronApp.context().tracing.startHar('network.har'); -// ... drive the app ... -await electronApp.context().tracing.stopHar(); -await electronApp.close(); -``` - -#### `recordVideo` - -Use [`page.screencast.start`](https://playwright.dev/docs/api/class-page#page-screencast) / -[`stop`](https://playwright.dev/docs/api/class-page#page-screencast) on each window. - -```js -const electronApp = await electron.launch({ args: ['main.js'] }); -const window = await electronApp.firstWindow(); -await window.screencast.start({ path: 'video.webm' }); -// ... drive the window ... -await window.screencast.stop(); -await electronApp.close(); -``` - -#### `colorScheme` - -Use [`page.emulateMedia`](https://playwright.dev/docs/api/class-page#page-emulate-media) -on each window. - -```js -const window = await electronApp.firstWindow(); -await window.emulateMedia({ colorScheme: 'dark' }); -``` - -#### `extraHTTPHeaders` - -Use [`browserContext.setExtraHTTPHeaders`](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-extra-http-headers). - -```js -await electronApp.context().setExtraHTTPHeaders({ 'X-My-Header': 'value' }); -``` - -#### `geolocation` - -Use [`browserContext.setGeolocation`](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-geolocation). - -```js -await electronApp.context().setGeolocation({ latitude: 48.858455, longitude: 2.294474 }); -``` - -#### `httpCredentials` - -Use [`browserContext.setHTTPCredentials`](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-http-credentials). - -```js -await electronApp.context().setHTTPCredentials({ username: 'user', password: 'pass' }); -``` - -#### `offline` - -Use [`browserContext.setOffline`](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-offline). - -```js -await electronApp.context().setOffline(true); -``` - -### Use built-in Electron APIs - -#### `bypassCSP` - -Disable CSP at the `BrowserWindow` level via Electron's -[web preferences](https://www.electronjs.org/docs/latest/api/structures/web-preferences). -Note that `webSecurity: false` also disables CORS and the Same-Origin Policy. - -```js -const win = new BrowserWindow({ - webPreferences: { - webSecurity: false, - }, -}); -``` - -#### `ignoreHTTPSErrors` - -There are several ways to relax HTTPS checks in Electron. Pick the one that -matches the scope you need. - -Per-window, allow mixed content through [web preferences](https://www.electronjs.org/docs/latest/api/structures/web-preferences): - -```js -const win = new BrowserWindow({ - webPreferences: { - allowRunningInsecureContent: true, - }, -}); -``` - -Process-wide, ignore certificate errors via Chromium command-line switches -(must run before the `ready` event): - -```js -const { app } = require('electron'); -app.commandLine.appendSwitch('ignore-certificate-errors'); -// Optional: also ignore localhost certificate errors when testing on an IP. -app.commandLine.appendSwitch('allow-insecure-localhost', 'true'); -``` - -Per-request, accept the certificate manually via the -[`certificate-error`](https://www.electronjs.org/docs/latest/api/app#event-certificate-error) -event: - -```js -app.on('certificate-error', (event, webContents, url, error, certificate, callback) => { - event.preventDefault(); - callback(true); -}); -``` - -#### `timezoneId` - -Set an environment variable at the very top of the main file, before any other logic or Chromium windows are initialized: - -```js -// main.js -process.env.TZ = 'Europe/London'; - -const { app } = require('electron'); -// ... rest of your app logic -``` diff --git a/packages/playwright-electron/class-electronfixtures.md b/packages/playwright-electron/class-electronfixtures.md deleted file mode 100644 index c5a1eea6bbce5..0000000000000 --- a/packages/playwright-electron/class-electronfixtures.md +++ /dev/null @@ -1,202 +0,0 @@ -# class: ElectronFixtures -* since: v1.60 -* langs: js - -The `@playwright/electron` package exposes a `test` object with a set of fixtures tailored for Electron automation. Fixtures are used to establish the environment for each test, giving the test everything it needs and nothing else. - -Below is an example config and a test file. - -```js title="playwright.config.ts" -import { defineConfig } from '@playwright/electron'; - -export default defineConfig({ - use: { - appOptions: { - args: ['main.js'], - env: { NODE_ENV: 'test' }, - }, - }, -}); -``` - -```js title="example.spec.ts" -import { test, expect } from '@playwright/electron'; - -test('basic test', async ({ app, page }) => { - // Evaluate in the main Electron process. - const appPath = await app.evaluate(async ({ app }) => app.getAppPath()); - console.log(appPath); - - // Interact with the first window via the `page` fixture. - await expect(page).toHaveTitle(/My App/); - await page.click('text=Click me'); - await expect(page.getByRole('heading')).toHaveText('Hello'); -}); -``` - -Given the test above, Playwright Test will launch the Electron application, wait for its first window, and expose it as the `page` fixture. Underneath, the [`property: ElectronFixtures.app`] fixture launches the application via [`method: Electron.launch`] using [`property: ElectronFixtures.appOptions`]. The application is closed after the test finishes. - -## property: ElectronFixtures.app -* since: v1.60 -- type: <[ElectronApplication]> - -[ElectronApplication] instance, created for each test by launching Electron with [`property: ElectronFixtures.appOptions`]. The application is closed after the test finishes. - -**Usage** - -```js -import { test, expect } from '@playwright/electron'; - -test.use({ appOptions: { args: ['main.js'] } }); - -test('scripts the main process', async ({ app }) => { - const appPath = await app.evaluate(({ app }) => app.getAppPath()); - expect(appPath).toBeTruthy(); -}); -``` - -## property: ElectronFixtures.appOptions -* since: v1.60 -- type: <[Object]> - -Options passed to [`method: Electron.launch`] when creating the [`property: ElectronFixtures.app`]. Override via `use` in the config file or `test.use()` to point at your Electron entry point and configure the launch. - -**Usage** - -```js title="playwright.config.ts" -import { defineConfig } from '@playwright/electron'; - -export default defineConfig({ - use: { - appOptions: { - args: ['main.js'], - env: { NODE_ENV: 'test' }, - }, - }, -}); -``` - -## property: ElectronFixtures.context -* since: v1.60 -- type: <[BrowserContext]> - -[BrowserContext] of the launched Electron app. All windows of the Electron application belong to this context. - -**Usage** - -```js -import { test, expect } from '@playwright/electron'; - -test('routes network', async ({ context, page }) => { - await context.route('**/api/**', route => route.fulfill({ status: 200, body: '{}' })); - await page.goto('https://example.com/api/data'); -}); -``` - -## property: ElectronFixtures.page -* since: v1.60 -- type: <[Page]> - -First window of the launched Electron app, as returned by [`method: ElectronApplication.firstWindow`]. This is the most common fixture used in an Electron test. - -**Usage** - -```js -import { test, expect } from '@playwright/electron'; - -test('interacts with the first window', async ({ page }) => { - await page.setContent('

Hello

'); - await expect(page.locator('h1')).toHaveText('Hello'); -}); -``` - -## property: ElectronFixtures.playwright -* since: v1.60 -- type: <[Object]> - -The Playwright module re-exported as a worker-scoped fixture. Use it when you need programmatic access to the Playwright API without importing it directly. - -**Usage** - -```js -import { test, expect } from '@playwright/electron'; - -test('uses playwright module', async ({ playwright }) => { - const request = await playwright.request.newContext(); - // ... -}); -``` - -## property: ElectronFixtures.screenshot -* since: v1.60 -- type: <[Object]|[ScreenshotMode]<"off"|"on"|"only-on-failure"|"on-first-failure">> - - `mode` <[ScreenshotMode]<"off"|"on"|"only-on-failure"|"on-first-failure">> Automatic screenshot mode. - - `fullPage` ?<[boolean]> When true, takes a screenshot of the full scrollable page, instead of the currently visible viewport. Defaults to `false`. - - `omitBackground` ?<[boolean]> Hides default white background and allows capturing screenshots with transparency. Not applicable to `jpeg` images. Defaults to `false`. - -Whether to automatically capture a screenshot after each test. Defaults to `'off'`. -* `'off'`: Do not capture screenshots. -* `'on'`: Capture screenshot after each test. -* `'only-on-failure'`: Capture screenshot after each test failure. -* `'on-first-failure'`: Capture screenshot after each test's first failure. - -**Usage** - -```js title="playwright.config.ts" -import { defineConfig } from '@playwright/electron'; - -export default defineConfig({ - use: { - screenshot: 'only-on-failure', - }, -}); -``` - -## property: ElectronFixtures.testIdAttribute -* since: v1.60 - -Custom attribute to be used in [`method: Page.getByTestId`]. `data-testid` is used by default. - -**Usage** - -```js title="playwright.config.ts" -import { defineConfig } from '@playwright/electron'; - -export default defineConfig({ - use: { - testIdAttribute: 'pw-test-id', - }, -}); -``` - -## property: ElectronFixtures.trace -* since: v1.60 -- type: <[Object]|[TraceMode]<"off"|"on"|"retain-on-failure"|"on-first-retry"|"retain-on-first-failure"|"retain-on-failure-and-retries">> - - `mode` <[TraceMode]<"off"|"on"|"retain-on-failure"|"on-first-retry"|"on-all-retries"|"retain-on-first-failure"|"retain-on-failure-and-retries">> Trace recording mode. - - `attachments` ?<[boolean]> Whether to include test attachments. Defaults to true. Optional. - - `screenshots` ?<[boolean]> Whether to capture screenshots during tracing. Screenshots are used to build a timeline preview. Defaults to true. Optional. - - `snapshots` ?<[boolean]> Whether to capture DOM snapshot on every action. Defaults to true. Optional. - - `sources` ?<[boolean]> Whether to include source files for trace actions. Defaults to true. Optional. - -Whether to record trace for each test. Defaults to `'off'`. -* `'off'`: Do not record trace. -* `'on'`: Record trace for each test. -* `'on-first-retry'`: Record trace only when retrying a test for the first time. -* `'on-all-retries'`: Record trace only when retrying a test. -* `'retain-on-failure'`: Record trace for each test. When test run passes, remove the recorded trace. -* `'retain-on-first-failure'`: Record trace for the first run of each test, but not for retries. When test run passes, remove the recorded trace. -* `'retain-on-failure-and-retries'`: Record trace for each test run. Retains all traces when an attempt fails. - -For more control, pass an object that specifies `mode` and trace features to enable. - -**Usage** - -```js title="playwright.config.ts" -import { defineConfig } from '@playwright/electron'; - -export default defineConfig({ - use: { - trace: 'on-first-retry' - }, -}); -``` diff --git a/packages/playwright-electron/index.d.ts b/packages/playwright-electron/index.d.ts deleted file mode 100644 index d2b79238666f2..0000000000000 --- a/packages/playwright-electron/index.d.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Copyright (c) Microsoft Corporation. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { - PlaywrightTestArgs as BasePlaywrightTestArgs, - PlaywrightTestOptions as BasePlaywrightTestOptions, - PlaywrightWorkerArgs as BasePlaywrightWorkerArgs, - PlaywrightWorkerOptions as BasePlaywrightWorkerOptions, - PlaywrightTestConfig as BasePlaywrightTestConfig, - TestType as BaseTestType, - BrowserContext, - Page, - Project, - Config, -} from 'playwright/test'; -import type { Electron, ElectronApplication } from './types'; - -export * from './types'; -export { expect, devices, mergeExpects, mergeTests } from 'playwright/test'; -export { _electron as electron } from 'playwright-core'; - -export type ElectronAppOptions = Parameters[0]; -export type PlaywrightTestOptions = Pick & { - appOptions: ElectronAppOptions; -}; -export type PlaywrightTestArgs = Pick & { - app: ElectronApplication; - context: BrowserContext; - page: Page; -}; -export type PlaywrightWorkerArgs = Pick; -export type PlaywrightWorkerOptions = Pick; - -export type TestType = BaseTestType< - PlaywrightTestOptions & PlaywrightTestArgs & T, - PlaywrightWorkerArgs & PlaywrightWorkerOptions & W ->; -export const test: TestType<{}, {}>; - -type ExcludeProps = { - [K in Exclude]: A[K]; -}; -type CustomProperties = ExcludeProps; -export type PlaywrightTestProject = Project, PlaywrightWorkerOptions & CustomProperties>; -export type PlaywrightTestConfig = Config, PlaywrightWorkerOptions & CustomProperties>; - -export function defineConfig(config: PlaywrightTestConfig): PlaywrightTestConfig; -export function defineConfig(config: PlaywrightTestConfig): PlaywrightTestConfig; -export function defineConfig(config: PlaywrightTestConfig): PlaywrightTestConfig; -export function defineConfig(config: PlaywrightTestConfig, ...configs: PlaywrightTestConfig[]): PlaywrightTestConfig; -export function defineConfig(config: PlaywrightTestConfig, ...configs: PlaywrightTestConfig[]): PlaywrightTestConfig; -export function defineConfig(config: PlaywrightTestConfig, ...configs: PlaywrightTestConfig[]): PlaywrightTestConfig; diff --git a/packages/playwright-electron/index.js b/packages/playwright-electron/index.js deleted file mode 100644 index 227eee0fde3b0..0000000000000 --- a/packages/playwright-electron/index.js +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Copyright (c) Microsoft Corporation. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -module.exports = require('./lib/index'); diff --git a/packages/playwright-electron/package.json b/packages/playwright-electron/package.json deleted file mode 100644 index 676105e4a1dc9..0000000000000 --- a/packages/playwright-electron/package.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "@playwright/electron", - "version": "0.0.0", - "private": true, - "description": "Playwright for Electron", - "repository": { - "type": "git", - "url": "git+https://github.com/microsoft/playwright.git" - }, - "homepage": "https://playwright.dev", - "engines": { - "node": ">=18" - }, - "author": { - "name": "Microsoft Corporation" - }, - "license": "Apache-2.0", - "exports": { - ".": { - "types": "./index.d.ts", - "default": "./index.js" - } - }, - "dependencies": { - "playwright": "*" - } -} diff --git a/packages/playwright-electron/src/DEPS.list b/packages/playwright-electron/src/DEPS.list deleted file mode 100644 index e5344b5cbb48e..0000000000000 --- a/packages/playwright-electron/src/DEPS.list +++ /dev/null @@ -1,2 +0,0 @@ -[*] -node_modules/playwright diff --git a/packages/playwright-electron/src/index.ts b/packages/playwright-electron/src/index.ts deleted file mode 100644 index d8c9f5b881f67..0000000000000 --- a/packages/playwright-electron/src/index.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Copyright (c) Microsoft Corporation. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// @ts-expect-error -import { _utilityTest, _electron as electron } from 'playwright/test'; -import { selectors } from 'playwright/test'; - -export { expect, devices, defineConfig, mergeExpects, mergeTests } from 'playwright/test'; -export { electron, selectors }; - -import type { TestType } from '../index.d.ts'; - -const baseTest = _utilityTest as TestType<{}, {}>; - -export const test = baseTest.extend({ - // @ts-expect-error - appOptions: [{}, { option: true }], - - app: async ({ appOptions, testIdAttribute }, use) => { - selectors.setTestIdAttribute(testIdAttribute); - const app = await electron.launch(appOptions); - await use(app); - await app.close(); - }, - - page: async ({ app }, use) => { - await use(await app.firstWindow()); - }, - - context: async ({ app }, use) => { - await use(app.context()); - }, -}); diff --git a/packages/playwright-electron/types.d.ts b/packages/playwright-electron/types.d.ts deleted file mode 100644 index e2add353db0de..0000000000000 --- a/packages/playwright-electron/types.d.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Copyright (c) Microsoft Corporation. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export * from 'playwright-core'; diff --git a/packages/protocol/spec/electron.yml b/packages/protocol/spec/electron.yml new file mode 100644 index 0000000000000..72f3a818c5c13 --- /dev/null +++ b/packages/protocol/spec/electron.yml @@ -0,0 +1,140 @@ +# Copyright (c) Microsoft Corporation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +Electron: + type: interface + + commands: + + launch: + title: Launch electron + parameters: + executablePath: string? + args: + type: array? + items: string + chromiumSandbox: boolean? + cwd: string? + env: + type: array? + items: NameValue + timeout: float + acceptDownloads: + type: enum? + literals: + - accept + - deny + - internal-browser-default + bypassCSP: boolean? + colorScheme: + type: enum? + literals: + - dark + - light + - no-preference + - no-override + extraHTTPHeaders: + type: array? + items: NameValue + geolocation: + type: object? + properties: + longitude: float + latitude: float + accuracy: float? + httpCredentials: + type: object? + properties: + username: string + password: string + origin: string? + ignoreHTTPSErrors: boolean? + locale: string? + offline: boolean? + recordVideo: + type: object? + properties: + dir: string? + size: + type: object? + properties: + width: int + height: int + showActions: + type: object? + properties: + $mixin: ShowActionsOptions + strictSelectors: boolean? + timezoneId: string? + tracesDir: string? + artifactsDir: string? + selectorEngines: + type: array? + items: SelectorEngine + testIdAttributeName: string? + + returns: + electronApplication: ElectronApplication + + +ElectronApplication: + type: interface + + extends: EventTarget + + initializer: + context: BrowserContext + + commands: + + browserWindow: + internal: true + parameters: + page: Page + returns: + handle: JSHandle + + evaluateExpression: + title: Evaluate + parameters: + expression: string + isFunction: boolean? + arg: SerializedArgument + returns: + value: SerializedValue + + evaluateExpressionHandle: + title: Evaluate + parameters: + expression: string + isFunction: boolean? + arg: SerializedArgument + returns: + handle: JSHandle + + updateSubscription: + internal: true + parameters: + event: + type: enum + literals: + - console + enabled: boolean + + events: + close: + console: + parameters: + $mixin: ConsoleMessage + + diff --git a/packages/protocol/spec/playwright.yml b/packages/protocol/spec/playwright.yml index e585848d37af5..bec45a11f5451 100644 --- a/packages/protocol/spec/playwright.yml +++ b/packages/protocol/spec/playwright.yml @@ -32,6 +32,7 @@ Playwright: firefox: BrowserType webkit: BrowserType android: Android + electron: Electron utils: LocalUtils? # Only present when connecting remotely via BrowserType.connect() method. preLaunchedBrowser: Browser? diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index b1aede0f72880..645d3f441a172 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -28,6 +28,7 @@ export interface Channel { export type InitializerTraits = T extends AndroidDeviceChannel ? AndroidDeviceInitializer : T extends BrowserContextChannel ? BrowserContextInitializer : + T extends ElectronApplicationChannel ? ElectronApplicationInitializer : T extends ElementHandleChannel ? ElementHandleInitializer : T extends WebSocketChannel ? WebSocketInitializer : T extends PageChannel ? PageInitializer : @@ -43,6 +44,7 @@ export type InitializerTraits = T extends BrowserTypeChannel ? BrowserTypeInitializer : T extends DisposableChannel ? DisposableInitializer : T extends EventTargetChannel ? EventTargetInitializer : + T extends ElectronChannel ? ElectronInitializer : T extends FrameChannel ? FrameInitializer : T extends JSHandleChannel ? JSHandleInitializer : T extends LocalUtilsChannel ? LocalUtilsInitializer : @@ -65,6 +67,7 @@ export type InitializerTraits = export type EventsTraits = T extends AndroidDeviceChannel ? AndroidDeviceEvents : T extends BrowserContextChannel ? BrowserContextEvents : + T extends ElectronApplicationChannel ? ElectronApplicationEvents : T extends ElementHandleChannel ? ElementHandleEvents : T extends WebSocketChannel ? WebSocketEvents : T extends PageChannel ? PageEvents : @@ -80,6 +83,7 @@ export type EventsTraits = T extends BrowserTypeChannel ? BrowserTypeEvents : T extends DisposableChannel ? DisposableEvents : T extends EventTargetChannel ? EventTargetEvents : + T extends ElectronChannel ? ElectronEvents : T extends FrameChannel ? FrameEvents : T extends JSHandleChannel ? JSHandleEvents : T extends LocalUtilsChannel ? LocalUtilsEvents : @@ -102,6 +106,7 @@ export type EventsTraits = export type EventTargetTraits = T extends AndroidDeviceChannel ? AndroidDeviceEventTarget : T extends BrowserContextChannel ? BrowserContextEventTarget : + T extends ElectronApplicationChannel ? ElectronApplicationEventTarget : T extends ElementHandleChannel ? ElementHandleEventTarget : T extends WebSocketChannel ? WebSocketEventTarget : T extends PageChannel ? PageEventTarget : @@ -117,6 +122,7 @@ export type EventTargetTraits = T extends BrowserTypeChannel ? BrowserTypeEventTarget : T extends DisposableChannel ? DisposableEventTarget : T extends EventTargetChannel ? EventTargetEventTarget : + T extends ElectronChannel ? ElectronEventTarget : T extends FrameChannel ? FrameEventTarget : T extends JSHandleChannel ? JSHandleEventTarget : T extends LocalUtilsChannel ? LocalUtilsEventTarget : @@ -2084,6 +2090,178 @@ export type EventTargetWaitForEventInfoResult = void; export interface EventTargetEvents { } +// ----------- Electron ----------- +export type ElectronInitializer = {}; +export interface ElectronEventTarget { +} +export interface ElectronChannel extends ElectronEventTarget, Channel { + _type_Electron: boolean; + launch(params: ElectronLaunchParams, progress?: Progress): Promise; +} +export type ElectronLaunchParams = { + executablePath?: string, + args?: string[], + chromiumSandbox?: boolean, + cwd?: string, + env?: NameValue[], + timeout: number, + acceptDownloads?: 'accept' | 'deny' | 'internal-browser-default', + bypassCSP?: boolean, + colorScheme?: 'dark' | 'light' | 'no-preference' | 'no-override', + extraHTTPHeaders?: NameValue[], + geolocation?: { + longitude: number, + latitude: number, + accuracy?: number, + }, + httpCredentials?: { + username: string, + password: string, + origin?: string, + }, + ignoreHTTPSErrors?: boolean, + locale?: string, + offline?: boolean, + recordVideo?: { + dir?: string, + size?: { + width: number, + height: number, + }, + showActions?: { + duration?: number, + position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', + fontSize?: number, + }, + }, + strictSelectors?: boolean, + timezoneId?: string, + tracesDir?: string, + artifactsDir?: string, + selectorEngines?: SelectorEngine[], + testIdAttributeName?: string, +}; +export type ElectronLaunchOptions = { + executablePath?: string, + args?: string[], + chromiumSandbox?: boolean, + cwd?: string, + env?: NameValue[], + acceptDownloads?: 'accept' | 'deny' | 'internal-browser-default', + bypassCSP?: boolean, + colorScheme?: 'dark' | 'light' | 'no-preference' | 'no-override', + extraHTTPHeaders?: NameValue[], + geolocation?: { + longitude: number, + latitude: number, + accuracy?: number, + }, + httpCredentials?: { + username: string, + password: string, + origin?: string, + }, + ignoreHTTPSErrors?: boolean, + locale?: string, + offline?: boolean, + recordVideo?: { + dir?: string, + size?: { + width: number, + height: number, + }, + showActions?: { + duration?: number, + position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', + fontSize?: number, + }, + }, + strictSelectors?: boolean, + timezoneId?: string, + tracesDir?: string, + artifactsDir?: string, + selectorEngines?: SelectorEngine[], + testIdAttributeName?: string, +}; +export type ElectronLaunchResult = { + electronApplication: ElectronApplicationChannel, +}; + +export interface ElectronEvents { +} + +// ----------- ElectronApplication ----------- +export type ElectronApplicationInitializer = { + context: BrowserContextChannel, +}; +export interface ElectronApplicationEventTarget { + on(event: 'close', callback: (params: ElectronApplicationCloseEvent) => void): this; + on(event: 'console', callback: (params: ElectronApplicationConsoleEvent) => void): this; +} +export interface ElectronApplicationChannel extends ElectronApplicationEventTarget, EventTargetChannel { + _type_ElectronApplication: boolean; + browserWindow(params: ElectronApplicationBrowserWindowParams, progress?: Progress): Promise; + evaluateExpression(params: ElectronApplicationEvaluateExpressionParams, progress?: Progress): Promise; + evaluateExpressionHandle(params: ElectronApplicationEvaluateExpressionHandleParams, progress?: Progress): Promise; + updateSubscription(params: ElectronApplicationUpdateSubscriptionParams, progress?: Progress): Promise; +} +export type ElectronApplicationCloseEvent = {}; +export type ElectronApplicationConsoleEvent = { + type: string, + text: string, + args: JSHandleChannel[], + location: { + url: string, + lineNumber: number, + columnNumber: number, + }, + timestamp: number, +}; +export type ElectronApplicationBrowserWindowParams = { + page: PageChannel, +}; +export type ElectronApplicationBrowserWindowOptions = { + +}; +export type ElectronApplicationBrowserWindowResult = { + handle: JSHandleChannel, +}; +export type ElectronApplicationEvaluateExpressionParams = { + expression: string, + isFunction?: boolean, + arg: SerializedArgument, +}; +export type ElectronApplicationEvaluateExpressionOptions = { + isFunction?: boolean, +}; +export type ElectronApplicationEvaluateExpressionResult = { + value: SerializedValue, +}; +export type ElectronApplicationEvaluateExpressionHandleParams = { + expression: string, + isFunction?: boolean, + arg: SerializedArgument, +}; +export type ElectronApplicationEvaluateExpressionHandleOptions = { + isFunction?: boolean, +}; +export type ElectronApplicationEvaluateExpressionHandleResult = { + handle: JSHandleChannel, +}; +export type ElectronApplicationUpdateSubscriptionParams = { + event: 'console', + enabled: boolean, +}; +export type ElectronApplicationUpdateSubscriptionOptions = { + +}; +export type ElectronApplicationUpdateSubscriptionResult = void; + +export interface ElectronApplicationEvents { + 'close': ElectronApplicationCloseEvent; + 'console': ElectronApplicationConsoleEvent; +} + // ----------- Frame ----------- export type FrameInitializer = { url: string, @@ -4522,6 +4700,7 @@ export type PlaywrightInitializer = { firefox: BrowserTypeChannel, webkit: BrowserTypeChannel, android: AndroidChannel, + electron: ElectronChannel, utils?: LocalUtilsChannel, preLaunchedBrowser?: BrowserChannel, preConnectedAndroidDevice?: AndroidDeviceChannel, diff --git a/tests/electron/electron-app.spec.ts b/tests/electron/electron-app.spec.ts index 77dc7e0b9aeef..adbca5e7fe162 100644 --- a/tests/electron/electron-app.spec.ts +++ b/tests/electron/electron-app.spec.ts @@ -376,7 +376,7 @@ test('should report downloads', async ({ launchElectronApp, electronMajorVersion res.end(`Hello world`); }); - const app = await launchElectronApp('electron-window-app.js'); + const app = await launchElectronApp('electron-window-app.js', [], { acceptDownloads: true }); const window = await app.firstWindow(); await window.setContent(`download`); const [download] = await Promise.all([ diff --git a/tests/electron/electron-screenshot.spec.ts b/tests/electron/electron-screenshot.spec.ts deleted file mode 100644 index 3c7c055aa7699..0000000000000 --- a/tests/electron/electron-screenshot.spec.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Copyright (c) Microsoft Corporation. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import fs from 'fs'; -import { electronTest as test, expect } from './electronTest'; - -test.use({ screenshot: 'on' }); - -test.afterEach(async ({}, testInfo) => { - const screenshots = testInfo.attachments.filter(a => a.name === 'screenshot'); - expect(screenshots).toHaveLength(1); - expect(fs.existsSync(screenshots[0].path!)).toBe(true); -}); - -test('should capture screenshot', async ({ launchElectronApp, newWindow }) => { - const app = await launchElectronApp('electron-app.js'); - const page = await newWindow(app); - await page.setContent('

Electron

'); -}); diff --git a/tests/electron/electron-tracing.spec.ts b/tests/electron/electron-tracing.spec.ts index c62d342eacc7c..ad3d87bf9dc03 100644 --- a/tests/electron/electron-tracing.spec.ts +++ b/tests/electron/electron-tracing.spec.ts @@ -15,6 +15,8 @@ */ import { electronTest as test, expect } from './electronTest'; +import fs from 'fs'; +import path from 'path'; test.skip(({ trace }) => trace === 'on'); @@ -50,3 +52,17 @@ test('should support custom protocol', async ({ launchElectronApp, newWindow, sh await expect(frame.locator('button')).toHaveCSS('color', 'rgb(255, 0, 0)'); await expect(frame.locator('button')).toHaveCSS('font-weight', '700'); }); + +test('should respect tracesDir and name', async ({ launchElectronApp, server }, testInfo) => { + const tracesDir = testInfo.outputPath('traces'); + const electronApp = await launchElectronApp('electron-window-app.js', [], { tracesDir }); + + await electronApp.context().tracing.start({ name: 'name1', snapshots: true }); + const page = await electronApp.firstWindow(); + await page.goto(server.PREFIX + '/one-style.html'); + await electronApp.context().tracing.stopChunk({ path: testInfo.outputPath('trace1.zip') }); + expect(fs.existsSync(path.join(tracesDir, 'name1.trace'))).toBe(true); + expect(fs.existsSync(path.join(tracesDir, 'name1.network'))).toBe(true); + + await electronApp.close(); +}); diff --git a/tests/electron/electronTest.ts b/tests/electron/electronTest.ts index 7e0467c94a49e..d4793dec5fe73 100644 --- a/tests/electron/electronTest.ts +++ b/tests/electron/electronTest.ts @@ -18,15 +18,15 @@ import { baseTest } from '../config/baseTest'; import path from 'path'; import fs from 'fs'; import os from 'os'; -import type { ElectronApplication, Electron, Page } from '@playwright/electron'; -import { electron } from '@playwright/electron'; +import type { ElectronApplication, Electron, Page } from 'playwright'; +import { _electron as electron } from 'playwright'; import type { PageTestFixtures, PageWorkerFixtures } from '../page/pageTestApi'; import type { TraceViewerFixtures } from '../config/traceViewerFixtures'; import { traceViewerFixtures } from '../config/traceViewerFixtures'; import { utils } from '../../packages/playwright-core/lib/coreBundle'; import { inheritAndCleanEnv } from '../config/utils'; -export { expect, selectors } from '@playwright/electron'; +export { expect, selectors } from '@playwright/test'; const { removeFolders } = utils; diff --git a/tests/installation/globalSetup.ts b/tests/installation/globalSetup.ts index 3035afd00f037..9e295ee328aa3 100644 --- a/tests/installation/globalSetup.ts +++ b/tests/installation/globalSetup.ts @@ -57,7 +57,6 @@ async function globalSetup() { build('playwright-browser-webkit', '@playwright/browser-webkit'), build('playwright-ct-react', '@playwright/experimental-ct-react'), build('playwright-ct-core', '@playwright/experimental-ct-core'), - build('playwright-electron', '@playwright/electron'), ]); const buildPlaywrightTestPlugin = async () => { diff --git a/tests/library/channels.spec.ts b/tests/library/channels.spec.ts index a1cc03076b47c..7bff98362dc15 100644 --- a/tests/library/channels.spec.ts +++ b/tests/library/channels.spec.ts @@ -48,6 +48,7 @@ it('should scope context handles', async ({ browserType, server, expectScopeStat { _guid: 'browser-type', objects: [ { _guid: 'browser', objects: [] } ] }, + { _guid: 'electron', objects: [] }, { _guid: 'localUtils', objects: [] }, { _guid: 'Playwright', objects: [] }, ] @@ -80,6 +81,7 @@ it('should scope context handles', async ({ browserType, server, expectScopeStat ] }, ] }, ] }, + { _guid: 'electron', objects: [] }, { _guid: 'localUtils', objects: [] }, { _guid: 'Playwright', objects: [] }, ] @@ -103,6 +105,7 @@ it('should scope CDPSession handles', async ({ browserType, browserName, expectS { _guid: 'browser-type', objects: [ { _guid: 'browser', objects: [] } ] }, + { _guid: 'electron', objects: [] }, { _guid: 'localUtils', objects: [] }, { _guid: 'Playwright', objects: [] }, ] @@ -121,6 +124,7 @@ it('should scope CDPSession handles', async ({ browserType, browserName, expectS { _guid: 'cdp-session', objects: [] }, ] }, ] }, + { _guid: 'electron', objects: [] }, { _guid: 'localUtils', objects: [] }, { _guid: 'Playwright', objects: [] }, ] @@ -140,6 +144,7 @@ it('should scope browser handles', async ({ browserType, expectScopeState }) => { _guid: 'browser-type', objects: [] }, { _guid: 'browser-type', objects: [] }, { _guid: 'browser-type', objects: [] }, + { _guid: 'electron', objects: [] }, { _guid: 'localUtils', objects: [] }, { _guid: 'Playwright', objects: [] }, ] @@ -166,6 +171,7 @@ it('should scope browser handles', async ({ browserType, expectScopeState }) => }, ] }, + { _guid: 'electron', objects: [] }, { _guid: 'localUtils', objects: [] }, { _guid: 'Playwright', objects: [] }, ] @@ -208,6 +214,7 @@ it('should not generate dispatchers for subresources w/o listeners', async ({ pa ] }], }, + { _guid: 'electron', objects: [] }, { _guid: 'localUtils', objects: [] }, { _guid: 'Playwright', objects: [] }, ] @@ -316,6 +323,10 @@ it('exposeFunction should not leak', async ({ page, expectScopeState, server }) }, ], }, + { + '_guid': 'electron', + 'objects': [], + }, { '_guid': 'localUtils', 'objects': [], diff --git a/tests/page/page-leaks.spec.ts b/tests/page/page-leaks.spec.ts index 7b09f33e3f875..d38488932f62f 100644 --- a/tests/page/page-leaks.spec.ts +++ b/tests/page/page-leaks.spec.ts @@ -24,7 +24,7 @@ function leakedJSHandles(): string { const map = new MultiMap(); for (const [h, e] of (globalThis as any).leakedJSHandles) { const name = `[${h.worldNameForTest()}] ${h.preview()}`; - if (name === '[main] UtilityScript' || name === '[utility] UtilityScript' || name === '[electron] UtilityScript' || name === '[worker] UtilityScript' || name === '[main] InjectedScript' || name === '[utility] InjectedScript' || name === '[electron] ElectronModule' || name === '[worker] ElectronModule') + if (name === '[main] UtilityScript' || name === '[utility] UtilityScript' || name === '[electron] UtilityScript' || name === '[main] InjectedScript' || name === '[utility] InjectedScript' || name === '[electron] ElectronModule') continue; map.set(e.stack, name); } diff --git a/utils/build/build.js b/utils/build/build.js index 347d617638522..d565e9dd3e22f 100644 --- a/utils/build/build.js +++ b/utils/build/build.js @@ -539,7 +539,7 @@ for (const pkg of workspace.packages()) { // playwright-client is built as a bundle. if (['@playwright/client'].includes(pkg.name)) continue; - if (pkg.name === 'playwright-core' || pkg.name === 'playwright' || pkg.name === '@playwright/electron') + if (pkg.name === 'playwright-core' || pkg.name === 'playwright') continue; steps.push(new EsbuildStep({ @@ -549,22 +549,6 @@ for (const pkg of workspace.packages()) { })); } -// playwright-electron/lib/index.js — thin test-runner integration shim that -// re-exports `playwright._electron`. Resolved at runtime against the parent -// `playwright` install. -{ - const electronPkg = filePath('packages/playwright-electron'); - steps.push(new EsbuildStep({ - bundle: true, - entryPoints: [path.join(electronPkg, 'src/index.ts')], - outfile: path.join(electronPkg, 'lib/index.js'), - external: [ - 'playwright', - 'playwright/*', - ], - })); -} - // Build playwright-core exported entry points. steps.push(new EsbuildStep({ entryPoints: [ @@ -577,10 +561,6 @@ steps.push(new EsbuildStep({ filePath('packages/playwright-core/src/entry/mcp.ts'), filePath('packages/playwright-core/src/entry/oopBrowserDownload.ts'), - // Electron loader — preloaded inside the Electron main process via `-r`. - // Self-contained (no @utils/@isomorphic imports) — emitted as a thin shim. - filePath('packages/playwright-core/src/electron/loader.ts'), - // CLI client tools, should be a separate bundle. filePath('packages/playwright-core/src/tools/cli-client/*.ts'), filePath('packages/playwright-core/src/package.ts'), @@ -818,6 +798,16 @@ steps.push(new EsbuildStep({ plugins: [dynamicImportToRequirePlugin], }, [filePath('packages/playwright/src')])); +// Build the Electron preload loader as a standalone CJS file. It runs inside +// the Electron process (via `electron -r loader.js`) and must not depend on +// coreBundle. `electron` is resolved at runtime by the Electron process. +steps.push(new EsbuildStep({ + bundle: true, + entryPoints: [filePath('packages/playwright-core/src/server/electron/loader.ts')], + outfile: filePath('packages/playwright-core/lib/server/electron/loader.js'), + external: ['electron'], +}, [playwrightCoreSrc])); + function copyXdgOpen() { const outdir = filePath('packages/playwright-core/lib'); if (!fs.existsSync(outdir)) diff --git a/utils/doclint/cli.js b/utils/doclint/cli.js index 141ecf3eb2289..b6d346817de21 100755 --- a/utils/doclint/cli.js +++ b/utils/doclint/cli.js @@ -272,6 +272,7 @@ async function run() { // Check for missing docs { const apiDocumentation = parseApi(path.join(PROJECT_DIR, 'docs', 'src', 'api')) + .mergeWith(parseApi(path.join(PROJECT_DIR, 'docs', 'src', 'electron-api'), path.join(PROJECT_DIR, 'docs', 'src', 'api', 'params.md'))) .mergeWith(parseApi(path.join(PROJECT_DIR, 'docs', 'src', 'mobile-api'), path.join(PROJECT_DIR, 'docs', 'src', 'api', 'params.md'))); apiDocumentation.filterForLanguage('js'); const srcClient = path.join(PROJECT_DIR, 'packages', 'playwright-core', 'src', 'client'); diff --git a/utils/generate_types/overrides.d.ts b/utils/generate_types/overrides.d.ts index f7f10b66a3b3b..03ca88049c18c 100644 --- a/utils/generate_types/overrides.d.ts +++ b/utils/generate_types/overrides.d.ts @@ -277,6 +277,17 @@ class TimeoutError extends Error {} export const devices: Devices; +//@ts-ignore this will be any if electron is not installed +type ElectronType = typeof import('electron'); + +export interface ElectronApplication { + evaluate(pageFunction: PageFunctionOn, arg: Arg): Promise; + evaluate(pageFunction: PageFunctionOn, arg?: any): Promise; + + evaluateHandle(pageFunction: PageFunctionOn, arg: Arg): Promise>; + evaluateHandle(pageFunction: PageFunctionOn, arg?: any): Promise>; +} + export type AndroidElementInfo = { clazz: string; desc: string; @@ -382,17 +393,19 @@ export type AndroidKey = 'Copy' | 'Paste'; +export const _electron: Electron; export const _android: Android; //@ts-ignore this will be any if electron is not installed type ElectronType = typeof import('electron'); export interface ElectronApplication { - evaluate: JSHandle['evaluate']; - evaluateHandle: JSHandle['evaluateHandle']; -} + evaluate(pageFunction: PageFunctionOn, arg: Arg): Promise; + evaluate(pageFunction: PageFunctionOn, arg?: any): Promise; -export const _electron: Electron; + evaluateHandle(pageFunction: PageFunctionOn, arg: Arg): Promise>; + evaluateHandle(pageFunction: PageFunctionOn, arg?: any): Promise>; +} // This is required to not export everything by default. See https://github.com/Microsoft/TypeScript/issues/19545#issuecomment-340490459 export {}; From 87bb9ddbd78f329df18c2b24847bc9409240cd07 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Fri, 8 May 2026 20:02:58 -0700 Subject: [PATCH 06/13] cherry-pick(#40747): fix(yauzl): vendor yauzl with destroy-lifecycle fix --- package-lock.json | 17 +- package.json | 2 - packages/playwright-core/src/server/DEPS.list | 1 - .../playwright-core/src/server/localUtils.ts | 2 +- packages/playwright-core/src/utilsBundle.ts | 1 - packages/playwright/src/worker/DEPS.list | 1 - packages/playwright/src/worker/testTracing.ts | 2 +- packages/utils/DEPS.list | 1 - packages/utils/third_party/extractZip.ts | 6 +- packages/utils/third_party/yauzl/LICENSE | 134 +++ .../utils/third_party/yauzl/buffer-crc32.js | 114 +++ packages/utils/third_party/yauzl/fd-slicer.js | 330 ++++++ packages/utils/third_party/yauzl/index.d.ts | 124 +++ packages/utils/third_party/yauzl/index.js | 951 ++++++++++++++++++ packages/utils/third_party/yauzl/pend.js | 58 ++ packages/utils/zipFile.ts | 4 +- utils/build/utilsBundleMapping.js | 1 - utils/protocol-types-generator/index.js | 2 +- 18 files changed, 1720 insertions(+), 31 deletions(-) create mode 100644 packages/utils/third_party/yauzl/LICENSE create mode 100644 packages/utils/third_party/yauzl/buffer-crc32.js create mode 100644 packages/utils/third_party/yauzl/fd-slicer.js create mode 100644 packages/utils/third_party/yauzl/index.d.ts create mode 100644 packages/utils/third_party/yauzl/index.js create mode 100644 packages/utils/third_party/yauzl/pend.js diff --git a/package-lock.json b/package-lock.json index c19b5c6b955b1..0746b71a046b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -66,7 +66,6 @@ "@types/source-map-support": "^0.5.4", "@types/ws": "8.2.2", "@types/xml2js": "^0.4.9", - "@types/yauzl": "^2.10.3", "@types/yazl": "^2.4.2", "@typescript-eslint/eslint-plugin": "^8.59.0", "@typescript-eslint/parser": "^8.59.0", @@ -121,7 +120,6 @@ "ws": "8.17.1", "xml2js": "^0.5.0", "yaml": "^2.8.3", - "yauzl": "3.2.1", "yazl": "2.5.1", "zod": "^4.3.6", "zod-to-json-schema": "^3.25.1" @@ -2821,6 +2819,7 @@ "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", "dev": true, + "optional": true, "dependencies": { "@types/node": "*" } @@ -9507,20 +9506,6 @@ "node": ">=10" } }, - "node_modules/yauzl": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.2.1.tgz", - "integrity": "sha512-k1isifdbpNSFEHFJ1ZY4YDewv0IH9FR61lDetaRMD3j2ae3bIXGV+7c+LHCqtQGofSd8PIyV4X6+dHMAnSr60A==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-crc32": "~0.2.3", - "pend": "~1.2.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/yazl": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/yazl/-/yazl-2.5.1.tgz", diff --git a/package.json b/package.json index 6a41d8735879f..7ee8e86fdf0ff 100644 --- a/package.json +++ b/package.json @@ -107,7 +107,6 @@ "@types/source-map-support": "^0.5.4", "@types/ws": "8.2.2", "@types/xml2js": "^0.4.9", - "@types/yauzl": "^2.10.3", "@types/yazl": "^2.4.2", "@typescript-eslint/eslint-plugin": "^8.59.0", "@typescript-eslint/parser": "^8.59.0", @@ -162,7 +161,6 @@ "ws": "8.17.1", "xml2js": "^0.5.0", "yaml": "^2.8.3", - "yauzl": "3.2.1", "yazl": "2.5.1", "zod": "^4.3.6", "zod-to-json-schema": "^3.25.1" diff --git a/packages/playwright-core/src/server/DEPS.list b/packages/playwright-core/src/server/DEPS.list index f9cf05a0b315c..b6267405e2d6f 100644 --- a/packages/playwright-core/src/server/DEPS.list +++ b/packages/playwright-core/src/server/DEPS.list @@ -16,7 +16,6 @@ node_modules/pngjs node_modules/proxy-from-env node_modules/ws node_modules/yaml -node_modules/yauzl node_modules/yazl [devtoolsController.ts] diff --git a/packages/playwright-core/src/server/localUtils.ts b/packages/playwright-core/src/server/localUtils.ts index 2ba65b226e639..e52eb66cf0379 100644 --- a/packages/playwright-core/src/server/localUtils.ts +++ b/packages/playwright-core/src/server/localUtils.ts @@ -18,8 +18,8 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; -import * as yauzl from 'yauzl'; import * as yazl from 'yazl'; +import * as yauzl from '@utils/third_party/yauzl'; import { ManualPromise } from '@isomorphic/manualPromise'; import { serializeClientSideCallMetadata } from '@isomorphic/trace/traceUtils'; import { assert } from '@isomorphic/assert'; diff --git a/packages/playwright-core/src/utilsBundle.ts b/packages/playwright-core/src/utilsBundle.ts index af57d4cf10f19..d6a69b083d8b0 100644 --- a/packages/playwright-core/src/utilsBundle.ts +++ b/packages/playwright-core/src/utilsBundle.ts @@ -81,7 +81,6 @@ import * as getEastAsianWidthLibrary from 'get-east-asian-width'; export const getEastAsianWidth = getEastAsianWidthLibrary; export * as yazl from 'yazl'; -export * as yauzl from 'yauzl'; // @ts-expect-error untyped vendored module import * as gracefulFsLibrary from 'graceful-fs'; diff --git a/packages/playwright/src/worker/DEPS.list b/packages/playwright/src/worker/DEPS.list index 72ccc32a7361d..40fc65671b041 100644 --- a/packages/playwright/src/worker/DEPS.list +++ b/packages/playwright/src/worker/DEPS.list @@ -6,5 +6,4 @@ ../util.ts ../matchers/** node_modules/colors/safe -node_modules/yauzl node_modules/yazl diff --git a/packages/playwright/src/worker/testTracing.ts b/packages/playwright/src/worker/testTracing.ts index 27132bc7fda4d..ccf66ee5fb61e 100644 --- a/packages/playwright/src/worker/testTracing.ts +++ b/packages/playwright/src/worker/testTracing.ts @@ -18,7 +18,7 @@ import fs from 'fs'; import path from 'path'; import * as yazl from 'yazl'; -import * as yauzl from 'yauzl'; +import * as yauzl from '@utils/third_party/yauzl'; import { ManualPromise } from '@isomorphic/manualPromise'; import { monotonicTime } from '@isomorphic/time'; import { calculateSha1, createGuid } from '@utils/crypto'; diff --git a/packages/utils/DEPS.list b/packages/utils/DEPS.list index 0834f57c8e073..aa25a66a8a292 100644 --- a/packages/utils/DEPS.list +++ b/packages/utils/DEPS.list @@ -10,7 +10,6 @@ node_modules/pngjs node_modules/proxy-from-env node_modules/socks-proxy-agent node_modules/ws -node_modules/yauzl node_modules/yazl [comparators.ts] diff --git a/packages/utils/third_party/extractZip.ts b/packages/utils/third_party/extractZip.ts index 1dc0d27253042..0e8bb41181b74 100644 --- a/packages/utils/third_party/extractZip.ts +++ b/packages/utils/third_party/extractZip.ts @@ -31,12 +31,12 @@ import { promisify } from 'util'; import debugPkg from 'debug'; import getStream from 'get-stream'; -import yauzl from 'yauzl'; -import type { Entry, ZipFile } from 'yauzl'; +import yauzl from './yauzl'; +import type { Entry, Options as YauzlOptions, ZipFile } from './yauzl'; const debug = debugPkg('extract-zip'); -const openZip = promisify(yauzl.open); +const openZip = promisify(yauzl.open); const pipeline = promisify(stream.pipeline); export interface Options { diff --git a/packages/utils/third_party/yauzl/LICENSE b/packages/utils/third_party/yauzl/LICENSE new file mode 100644 index 0000000000000..65412d42edd0b --- /dev/null +++ b/packages/utils/third_party/yauzl/LICENSE @@ -0,0 +1,134 @@ +This directory contains code vendored from several MIT-licensed projects. + +================================================================================ +yauzl (https://github.com/thejoshwolfe/yauzl) +================================================================================ + +The MIT License (MIT) + +Copyright (c) 2014 Josh Wolfe + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +================================================================================ +node-fd-slicer (https://github.com/andrewrk/node-fd-slicer) — embedded as +fd-slicer.js, with additional fix from https://github.com/thejoshwolfe/yauzl/pull/168 +================================================================================ + +The MIT License (Expat) + +Copyright (c) 2014 Andrew Kelley + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation files +(the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of the Software, +and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +================================================================================ +pend (https://github.com/andrewrk/node-pend) +================================================================================ + +The MIT License (Expat) + +Copyright (c) 2014 Andrew Kelley + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation files +(the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of the Software, +and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +================================================================================ +buffer-crc32 (https://github.com/brianloveswords/buffer-crc32) +================================================================================ + +The MIT License + +Copyright (c) 2013 Brian J. Brennan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +================================================================================ +DefinitelyTyped @types/yauzl — basis for index.d.ts +================================================================================ + +MIT License + +Copyright (c) Microsoft Corporation. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/utils/third_party/yauzl/buffer-crc32.js b/packages/utils/third_party/yauzl/buffer-crc32.js new file mode 100644 index 0000000000000..534590d76a63e --- /dev/null +++ b/packages/utils/third_party/yauzl/buffer-crc32.js @@ -0,0 +1,114 @@ +// Vendored from https://github.com/brianloveswords/buffer-crc32 at v0.2.13 under the MIT License. +// See LICENSE for the full text. + +var Buffer = require('buffer').Buffer; + +var CRC_TABLE = [ + 0x00000000, 0x77073096, 0xee0e612c, 0x990951ba, 0x076dc419, + 0x706af48f, 0xe963a535, 0x9e6495a3, 0x0edb8832, 0x79dcb8a4, + 0xe0d5e91e, 0x97d2d988, 0x09b64c2b, 0x7eb17cbd, 0xe7b82d07, + 0x90bf1d91, 0x1db71064, 0x6ab020f2, 0xf3b97148, 0x84be41de, + 0x1adad47d, 0x6ddde4eb, 0xf4d4b551, 0x83d385c7, 0x136c9856, + 0x646ba8c0, 0xfd62f97a, 0x8a65c9ec, 0x14015c4f, 0x63066cd9, + 0xfa0f3d63, 0x8d080df5, 0x3b6e20c8, 0x4c69105e, 0xd56041e4, + 0xa2677172, 0x3c03e4d1, 0x4b04d447, 0xd20d85fd, 0xa50ab56b, + 0x35b5a8fa, 0x42b2986c, 0xdbbbc9d6, 0xacbcf940, 0x32d86ce3, + 0x45df5c75, 0xdcd60dcf, 0xabd13d59, 0x26d930ac, 0x51de003a, + 0xc8d75180, 0xbfd06116, 0x21b4f4b5, 0x56b3c423, 0xcfba9599, + 0xb8bda50f, 0x2802b89e, 0x5f058808, 0xc60cd9b2, 0xb10be924, + 0x2f6f7c87, 0x58684c11, 0xc1611dab, 0xb6662d3d, 0x76dc4190, + 0x01db7106, 0x98d220bc, 0xefd5102a, 0x71b18589, 0x06b6b51f, + 0x9fbfe4a5, 0xe8b8d433, 0x7807c9a2, 0x0f00f934, 0x9609a88e, + 0xe10e9818, 0x7f6a0dbb, 0x086d3d2d, 0x91646c97, 0xe6635c01, + 0x6b6b51f4, 0x1c6c6162, 0x856530d8, 0xf262004e, 0x6c0695ed, + 0x1b01a57b, 0x8208f4c1, 0xf50fc457, 0x65b0d9c6, 0x12b7e950, + 0x8bbeb8ea, 0xfcb9887c, 0x62dd1ddf, 0x15da2d49, 0x8cd37cf3, + 0xfbd44c65, 0x4db26158, 0x3ab551ce, 0xa3bc0074, 0xd4bb30e2, + 0x4adfa541, 0x3dd895d7, 0xa4d1c46d, 0xd3d6f4fb, 0x4369e96a, + 0x346ed9fc, 0xad678846, 0xda60b8d0, 0x44042d73, 0x33031de5, + 0xaa0a4c5f, 0xdd0d7cc9, 0x5005713c, 0x270241aa, 0xbe0b1010, + 0xc90c2086, 0x5768b525, 0x206f85b3, 0xb966d409, 0xce61e49f, + 0x5edef90e, 0x29d9c998, 0xb0d09822, 0xc7d7a8b4, 0x59b33d17, + 0x2eb40d81, 0xb7bd5c3b, 0xc0ba6cad, 0xedb88320, 0x9abfb3b6, + 0x03b6e20c, 0x74b1d29a, 0xead54739, 0x9dd277af, 0x04db2615, + 0x73dc1683, 0xe3630b12, 0x94643b84, 0x0d6d6a3e, 0x7a6a5aa8, + 0xe40ecf0b, 0x9309ff9d, 0x0a00ae27, 0x7d079eb1, 0xf00f9344, + 0x8708a3d2, 0x1e01f268, 0x6906c2fe, 0xf762575d, 0x806567cb, + 0x196c3671, 0x6e6b06e7, 0xfed41b76, 0x89d32be0, 0x10da7a5a, + 0x67dd4acc, 0xf9b9df6f, 0x8ebeeff9, 0x17b7be43, 0x60b08ed5, + 0xd6d6a3e8, 0xa1d1937e, 0x38d8c2c4, 0x4fdff252, 0xd1bb67f1, + 0xa6bc5767, 0x3fb506dd, 0x48b2364b, 0xd80d2bda, 0xaf0a1b4c, + 0x36034af6, 0x41047a60, 0xdf60efc3, 0xa867df55, 0x316e8eef, + 0x4669be79, 0xcb61b38c, 0xbc66831a, 0x256fd2a0, 0x5268e236, + 0xcc0c7795, 0xbb0b4703, 0x220216b9, 0x5505262f, 0xc5ba3bbe, + 0xb2bd0b28, 0x2bb45a92, 0x5cb36a04, 0xc2d7ffa7, 0xb5d0cf31, + 0x2cd99e8b, 0x5bdeae1d, 0x9b64c2b0, 0xec63f226, 0x756aa39c, + 0x026d930a, 0x9c0906a9, 0xeb0e363f, 0x72076785, 0x05005713, + 0x95bf4a82, 0xe2b87a14, 0x7bb12bae, 0x0cb61b38, 0x92d28e9b, + 0xe5d5be0d, 0x7cdcefb7, 0x0bdbdf21, 0x86d3d2d4, 0xf1d4e242, + 0x68ddb3f8, 0x1fda836e, 0x81be16cd, 0xf6b9265b, 0x6fb077e1, + 0x18b74777, 0x88085ae6, 0xff0f6a70, 0x66063bca, 0x11010b5c, + 0x8f659eff, 0xf862ae69, 0x616bffd3, 0x166ccf45, 0xa00ae278, + 0xd70dd2ee, 0x4e048354, 0x3903b3c2, 0xa7672661, 0xd06016f7, + 0x4969474d, 0x3e6e77db, 0xaed16a4a, 0xd9d65adc, 0x40df0b66, + 0x37d83bf0, 0xa9bcae53, 0xdebb9ec5, 0x47b2cf7f, 0x30b5ffe9, + 0xbdbdf21c, 0xcabac28a, 0x53b39330, 0x24b4a3a6, 0xbad03605, + 0xcdd70693, 0x54de5729, 0x23d967bf, 0xb3667a2e, 0xc4614ab8, + 0x5d681b02, 0x2a6f2b94, 0xb40bbe37, 0xc30c8ea1, 0x5a05df1b, + 0x2d02ef8d +]; + +if (typeof Int32Array !== 'undefined') { + CRC_TABLE = new Int32Array(CRC_TABLE); +} + +function ensureBuffer(input) { + if (Buffer.isBuffer(input)) { + return input; + } + + var hasNewBufferAPI = + typeof Buffer.alloc === "function" && + typeof Buffer.from === "function"; + + if (typeof input === "number") { + return hasNewBufferAPI ? Buffer.alloc(input) : new Buffer(input); + } + else if (typeof input === "string") { + return hasNewBufferAPI ? Buffer.from(input) : new Buffer(input); + } + else { + throw new Error("input must be buffer, number, or string, received " + + typeof input); + } +} + +function bufferizeInt(num) { + var tmp = ensureBuffer(4); + tmp.writeInt32BE(num, 0); + return tmp; +} + +function _crc32(buf, previous) { + buf = ensureBuffer(buf); + if (Buffer.isBuffer(previous)) { + previous = previous.readUInt32BE(0); + } + var crc = ~~previous ^ -1; + for (var n = 0; n < buf.length; n++) { + crc = CRC_TABLE[(crc ^ buf[n]) & 0xff] ^ (crc >>> 8); + } + return (crc ^ -1); +} + +function crc32() { + return bufferizeInt(_crc32.apply(null, arguments)); +} +crc32.signed = function () { + return _crc32.apply(null, arguments); +}; +crc32.unsigned = function () { + return _crc32.apply(null, arguments) >>> 0; +}; + +module.exports = crc32; diff --git a/packages/utils/third_party/yauzl/fd-slicer.js b/packages/utils/third_party/yauzl/fd-slicer.js new file mode 100644 index 0000000000000..2888ea474fe7e --- /dev/null +++ b/packages/utils/third_party/yauzl/fd-slicer.js @@ -0,0 +1,330 @@ +// This was adapted from https://github.com/andrewrk/node-fd-slicer by Andrew Kelley under the MIT License. +// Originally vendored from https://github.com/thejoshwolfe/yauzl at 3.2.1. +// Modified to apply the destroy() lifecycle fix from +// https://github.com/thejoshwolfe/yauzl/pull/168 (HoJeong Go) so async iteration +// over `openReadStream` properly emits 'close' on modern Node. +var fs = require('fs'); +var util = require('util'); +var stream = require('stream'); +var Readable = stream.Readable; +var Writable = stream.Writable; +var PassThrough = stream.PassThrough; +var Pend = require('./pend'); +var EventEmitter = require('events').EventEmitter; + +exports.createFromBuffer = createFromBuffer; +exports.createFromFd = createFromFd; +exports.BufferSlicer = BufferSlicer; +exports.FdSlicer = FdSlicer; + +util.inherits(FdSlicer, EventEmitter); +function FdSlicer(fd, options) { + options = options || {}; + EventEmitter.call(this); + + this.fd = fd; + this.pend = new Pend(); + this.pend.max = 1; + this.refCount = 0; + this.autoClose = !!options.autoClose; +} + +FdSlicer.prototype.read = function(buffer, offset, length, position, callback) { + var self = this; + self.pend.go(function(cb) { + fs.read(self.fd, buffer, offset, length, position, function(err, bytesRead, buffer) { + cb(); + callback(err, bytesRead, buffer); + }); + }); +}; + +FdSlicer.prototype.write = function(buffer, offset, length, position, callback) { + var self = this; + self.pend.go(function(cb) { + fs.write(self.fd, buffer, offset, length, position, function(err, written, buffer) { + cb(); + callback(err, written, buffer); + }); + }); +}; + +FdSlicer.prototype.createReadStream = function(options) { + return new ReadStream(this, options); +}; + +FdSlicer.prototype.createWriteStream = function(options) { + return new WriteStream(this, options); +}; + +FdSlicer.prototype.ref = function() { + this.refCount += 1; +}; + +FdSlicer.prototype.unref = function() { + var self = this; + self.refCount -= 1; + + if (self.refCount > 0) return; + if (self.refCount < 0) throw new Error("invalid unref"); + + if (self.autoClose) { + fs.close(self.fd, onCloseDone); + } + + function onCloseDone(err) { + if (err) { + self.emit('error', err); + } else { + self.emit('close'); + } + } +}; + +util.inherits(ReadStream, Readable); +function ReadStream(context, options) { + options = options || {}; + Readable.call(this, options); + + this.context = context; + this.context.ref(); + + this.start = options.start || 0; + this.endOffset = options.end; + this.pos = this.start; + this._destroyed = false; +} + +ReadStream.prototype._read = function(n) { + var self = this; + if (self._destroyed) return; + + var toRead = Math.min(self._readableState.highWaterMark, n); + if (self.endOffset != null) { + toRead = Math.min(toRead, self.endOffset - self.pos); + } + if (toRead <= 0) { + self._destroyed = true; + self.push(null); + self.context.unref(); + return; + } + self.context.pend.go(function(cb) { + if (self._destroyed) return cb(); + var buffer = Buffer.allocUnsafe(toRead); + fs.read(self.context.fd, buffer, 0, toRead, self.pos, function(err, bytesRead) { + if (self._destroyed) return cb(); + if (err) { + self.destroy(err); + } else if (bytesRead === 0) { + self._destroyed = true; + self.push(null); + self.context.unref(); + } else { + self.pos += bytesRead; + self.push(buffer.slice(0, bytesRead)); + } + cb(); + }); + }); +}; + +ReadStream.prototype.destroy = function(err) { + if (err == null && !this.readableEnded) { + err = new Error("stream destroyed"); + } + return Readable.prototype.destroy.call(this, err); +}; + +ReadStream.prototype._destroy = function(err, cb) { + if (!this._destroyed) { + this._destroyed = true; + this.context.unref(); + } + cb(err); +}; + +util.inherits(WriteStream, Writable); +function WriteStream(context, options) { + options = options || {}; + Writable.call(this, options); + + this.context = context; + this.context.ref(); + + this.start = options.start || 0; + this.endOffset = (options.end == null) ? Infinity : +options.end; + this.bytesWritten = 0; + this.pos = this.start; + this._destroyed = false; + + this.on('finish', this.destroy.bind(this)); +} + +WriteStream.prototype._write = function(buffer, encoding, callback) { + var self = this; + if (self._destroyed) return; + + if (self.pos + buffer.length > self.endOffset) { + var err = new Error("maximum file length exceeded"); + err.code = 'ETOOBIG'; + self.destroy(); + callback(err); + return; + } + self.context.pend.go(function(cb) { + if (self._destroyed) return cb(); + fs.write(self.context.fd, buffer, 0, buffer.length, self.pos, function(err, bytes) { + if (err) { + self.destroy(); + cb(); + callback(err); + } else { + self.bytesWritten += bytes; + self.pos += bytes; + self.emit('progress'); + cb(); + callback(); + } + }); + }); +}; + +WriteStream.prototype._destroy = function(err, cb) { + if (!this._destroyed) { + this._destroyed = true; + this.context.unref(); + } + cb(err); +}; + +util.inherits(BufferSlicer, EventEmitter); +function BufferSlicer(buffer, options) { + EventEmitter.call(this); + + options = options || {}; + this.refCount = 0; + this.buffer = buffer; + this.maxChunkSize = options.maxChunkSize || Number.MAX_SAFE_INTEGER; +} + +BufferSlicer.prototype.read = function(buffer, offset, length, position, callback) { + if (!(0 <= offset && offset <= buffer.length)) throw new RangeError("offset outside buffer: 0 <= " + offset + " <= " + buffer.length); + if (position < 0) throw new RangeError("position is negative: " + position); + if (offset + length > buffer.length) { + // The caller's buffer can't hold all the bytes they're trying to read. + // Clamp the length instead of giving an error. + // The callback will be informed of fewer than expected bytes written. + length = buffer.length - offset; + } + if (position + length > this.buffer.length) { + // Clamp any attempt to read past the end of the source buffer. + length = this.buffer.length - position; + } + if (length <= 0) { + // After any clamping, we're fully out of bounds or otherwise have nothing to do. + // This isn't an error; it's just zero bytes written. + setImmediate(function() { + callback(null, 0); + }); + return; + } + this.buffer.copy(buffer, offset, position, position + length); + setImmediate(function() { + callback(null, length); + }); +}; + +BufferSlicer.prototype.write = function(buffer, offset, length, position, callback) { + buffer.copy(this.buffer, position, offset, offset + length); + setImmediate(function() { + callback(null, length, buffer); + }); +}; + +BufferSlicer.prototype.createReadStream = function(options) { + options = options || {}; + var readStream = new PassThrough(options); + readStream._destroyed = false; + readStream.start = options.start || 0; + readStream.endOffset = options.end; + // by the time this function returns, we'll be done. + readStream.pos = readStream.endOffset || this.buffer.length; + + // respect the maxChunkSize option to slice up the chunk into smaller pieces. + var entireSlice = this.buffer.slice(readStream.start, readStream.pos); + var offset = 0; + while (true) { + var nextOffset = offset + this.maxChunkSize; + if (nextOffset >= entireSlice.length) { + // last chunk + if (offset < entireSlice.length) { + readStream.write(entireSlice.slice(offset, entireSlice.length)); + } + break; + } + readStream.write(entireSlice.slice(offset, nextOffset)); + offset = nextOffset; + } + + readStream.end(); + readStream._destroy = function(err, cb) { + readStream._destroyed = true; + PassThrough.prototype._destroy.call(readStream, err, cb); + }; + return readStream; +}; + +BufferSlicer.prototype.createWriteStream = function(options) { + var bufferSlicer = this; + options = options || {}; + var writeStream = new Writable(options); + writeStream.start = options.start || 0; + writeStream.endOffset = (options.end == null) ? this.buffer.length : +options.end; + writeStream.bytesWritten = 0; + writeStream.pos = writeStream.start; + writeStream._destroyed = false; + writeStream._write = function(buffer, encoding, callback) { + if (writeStream._destroyed) return; + + var end = writeStream.pos + buffer.length; + if (end > writeStream.endOffset) { + var err = new Error("maximum file length exceeded"); + err.code = 'ETOOBIG'; + writeStream._destroyed = true; + callback(err); + return; + } + buffer.copy(bufferSlicer.buffer, writeStream.pos, 0, buffer.length); + + writeStream.bytesWritten += buffer.length; + writeStream.pos = end; + writeStream.emit('progress'); + callback(); + }; + writeStream._destroy = function(err, cb) { + writeStream._destroyed = true; + cb(err); + }; + return writeStream; +}; + +BufferSlicer.prototype.ref = function() { + this.refCount += 1; +}; + +BufferSlicer.prototype.unref = function() { + this.refCount -= 1; + + if (this.refCount < 0) { + throw new Error("invalid unref"); + } +}; + +function createFromBuffer(buffer, options) { + return new BufferSlicer(buffer, options); +} + +function createFromFd(fd, options) { + return new FdSlicer(fd, options); +} diff --git a/packages/utils/third_party/yauzl/index.d.ts b/packages/utils/third_party/yauzl/index.d.ts new file mode 100644 index 0000000000000..0b49b62f0d5c9 --- /dev/null +++ b/packages/utils/third_party/yauzl/index.d.ts @@ -0,0 +1,124 @@ +// Adapted from https://www.npmjs.com/package/@types/yauzl (DefinitelyTyped, MIT). +/// + +import { EventEmitter } from 'events'; +import { Readable } from 'stream'; + +export abstract class RandomAccessReader extends EventEmitter { + _readStreamForRange(start: number, end: number): void; + createReadStream(options: { start: number; end: number }): void; + read(buffer: Buffer, offset: number, length: number, position: number, callback: (err: Error | null) => void): void; + close(callback: (err: Error | null) => void): void; +} + +export class Entry { + comment: string; + compressedSize: number; + compressionMethod: number; + crc32: number; + externalFileAttributes: number; + extraFieldLength: number; + extraFields: Array<{ id: number; data: Buffer }>; + fileCommentLength: number; + fileName: string; + fileNameLength: number; + generalPurposeBitFlag: number; + internalFileAttributes: number; + lastModFileDate: number; + lastModFileTime: number; + relativeOffsetOfLocalHeader: number; + uncompressedSize: number; + versionMadeBy: number; + versionNeededToExtract: number; + + getLastModDate(): Date; + isEncrypted(): boolean; + isCompressed(): boolean; +} + +export interface ZipFileOptions { + decompress: boolean | null; + decrypt: boolean | null; + start: number | null; + end: number | null; +} + +export class ZipFile extends EventEmitter { + autoClose: boolean; + comment: string; + decodeStrings: boolean; + emittedError: boolean; + entriesRead: number; + entryCount: number; + fileSize: number; + isOpen: boolean; + lazyEntries: boolean; + readEntryCursor: boolean; + validateEntrySizes: boolean; + + constructor( + reader: RandomAccessReader, + centralDirectoryOffset: number, + fileSize: number, + entryCount: number, + comment: string, + autoClose: boolean, + lazyEntries: boolean, + decodeStrings: boolean, + validateEntrySizes: boolean, + ); + + openReadStream( + entry: Entry, + options: ZipFileOptions, + callback: (err: Error | null, stream: Readable) => void, + ): void; + openReadStream(entry: Entry, callback: (err: Error | null, stream: Readable) => void): void; + close(): void; + readEntry(): void; +} + +export interface Options { + autoClose?: boolean | undefined; + lazyEntries?: boolean | undefined; + decodeStrings?: boolean | undefined; + validateEntrySizes?: boolean | undefined; + strictFileNames?: boolean | undefined; +} + +export function open(path: string, options: Options, callback?: (err: Error | null, zipfile: ZipFile) => void): void; +export function open(path: string, callback?: (err: Error | null, zipfile: ZipFile) => void): void; +export function fromFd(fd: number, options: Options, callback?: (err: Error | null, zipfile: ZipFile) => void): void; +export function fromFd(fd: number, callback?: (err: Error | null, zipfile: ZipFile) => void): void; +export function fromBuffer( + buffer: Buffer, + options: Options, + callback?: (err: Error | null, zipfile: ZipFile) => void, +): void; +export function fromBuffer(buffer: Buffer, callback?: (err: Error | null, zipfile: ZipFile) => void): void; +export function fromRandomAccessReader( + reader: RandomAccessReader, + totalSize: number, + options: Options, + callback: (err: Error | null, zipfile: ZipFile) => void, +): void; +export function fromRandomAccessReader( + reader: RandomAccessReader, + totalSize: number, + callback: (err: Error | null, zipfile: ZipFile) => void, +): void; +export function dosDateTimeToDate(date: number, time: number): Date; +export function validateFileName(fileName: string): string | null; + +declare const yauzl: { + open: typeof open; + fromFd: typeof fromFd; + fromBuffer: typeof fromBuffer; + fromRandomAccessReader: typeof fromRandomAccessReader; + dosDateTimeToDate: typeof dosDateTimeToDate; + validateFileName: typeof validateFileName; + ZipFile: typeof ZipFile; + Entry: typeof Entry; + RandomAccessReader: typeof RandomAccessReader; +}; +export default yauzl; diff --git a/packages/utils/third_party/yauzl/index.js b/packages/utils/third_party/yauzl/index.js new file mode 100644 index 0000000000000..3661c7654080b --- /dev/null +++ b/packages/utils/third_party/yauzl/index.js @@ -0,0 +1,951 @@ +// Vendored from https://github.com/thejoshwolfe/yauzl at v3.2.1 under the MIT License. +// See LICENSE for the full text. +// +// Local modifications: +// - require('./fd-slicer'): unchanged path; fd-slicer.js carries the fix from +// https://github.com/thejoshwolfe/yauzl/pull/168. +// - require('buffer-crc32'): rewritten to ./buffer-crc32 (inlined sibling). + +var fs = require("fs"); +var zlib = require("zlib"); +var fd_slicer = require("./fd-slicer"); +var crc32 = require("./buffer-crc32"); +var util = require("util"); +var EventEmitter = require("events").EventEmitter; +var Transform = require("stream").Transform; +var PassThrough = require("stream").PassThrough; +var Writable = require("stream").Writable; + +exports.open = open; +exports.fromFd = fromFd; +exports.fromBuffer = fromBuffer; +exports.fromRandomAccessReader = fromRandomAccessReader; +exports.dosDateTimeToDate = dosDateTimeToDate; +exports.getFileNameLowLevel = getFileNameLowLevel; +exports.validateFileName = validateFileName; +exports.parseExtraFields = parseExtraFields; +exports.ZipFile = ZipFile; +exports.Entry = Entry; +exports.LocalFileHeader = LocalFileHeader; +exports.RandomAccessReader = RandomAccessReader; + +function open(path, options, callback) { + if (typeof options === "function") { + callback = options; + options = null; + } + if (options == null) options = {}; + if (options.autoClose == null) options.autoClose = true; + if (options.lazyEntries == null) options.lazyEntries = false; + if (options.decodeStrings == null) options.decodeStrings = true; + if (options.validateEntrySizes == null) options.validateEntrySizes = true; + if (options.strictFileNames == null) options.strictFileNames = false; + if (callback == null) callback = defaultCallback; + fs.open(path, "r", function(err, fd) { + if (err) return callback(err); + fromFd(fd, options, function(err, zipfile) { + if (err) fs.close(fd, defaultCallback); + callback(err, zipfile); + }); + }); +} + +function fromFd(fd, options, callback) { + if (typeof options === "function") { + callback = options; + options = null; + } + if (options == null) options = {}; + if (options.autoClose == null) options.autoClose = false; + if (options.lazyEntries == null) options.lazyEntries = false; + if (options.decodeStrings == null) options.decodeStrings = true; + if (options.validateEntrySizes == null) options.validateEntrySizes = true; + if (options.strictFileNames == null) options.strictFileNames = false; + if (callback == null) callback = defaultCallback; + fs.fstat(fd, function(err, stats) { + if (err) return callback(err); + var reader = fd_slicer.createFromFd(fd, {autoClose: true}); + fromRandomAccessReader(reader, stats.size, options, callback); + }); +} + +function fromBuffer(buffer, options, callback) { + if (typeof options === "function") { + callback = options; + options = null; + } + if (options == null) options = {}; + options.autoClose = false; + if (options.lazyEntries == null) options.lazyEntries = false; + if (options.decodeStrings == null) options.decodeStrings = true; + if (options.validateEntrySizes == null) options.validateEntrySizes = true; + if (options.strictFileNames == null) options.strictFileNames = false; + // limit the max chunk size. see https://github.com/thejoshwolfe/yauzl/issues/87 + var reader = fd_slicer.createFromBuffer(buffer, {maxChunkSize: 0x10000}); + fromRandomAccessReader(reader, buffer.length, options, callback); +} + +function fromRandomAccessReader(reader, totalSize, options, callback) { + if (typeof options === "function") { + callback = options; + options = null; + } + if (options == null) options = {}; + if (options.autoClose == null) options.autoClose = true; + if (options.lazyEntries == null) options.lazyEntries = false; + if (options.decodeStrings == null) options.decodeStrings = true; + var decodeStrings = !!options.decodeStrings; + if (options.validateEntrySizes == null) options.validateEntrySizes = true; + if (options.strictFileNames == null) options.strictFileNames = false; + if (callback == null) callback = defaultCallback; + if (typeof totalSize !== "number") throw new Error("expected totalSize parameter to be a number"); + if (totalSize > Number.MAX_SAFE_INTEGER) { + throw new Error("zip file too large. only file sizes up to 2^52 are supported due to JavaScript's Number type being an IEEE 754 double."); + } + + // the matching unref() call is in zipfile.close() + reader.ref(); + + // eocdr means End of Central Directory Record. + // search backwards for the eocdr signature. + // the last field of the eocdr is a variable-length comment. + // the comment size is encoded in a 2-byte field in the eocdr, which we can't find without trudging backwards through the comment to find it. + // as a consequence of this design decision, it's possible to have ambiguous zip file metadata if a coherent eocdr was in the comment. + // we search backwards for a eocdr signature, and hope that whoever made the zip file was smart enough to forbid the eocdr signature in the comment. + var eocdrWithoutCommentSize = 22; + var zip64EocdlSize = 20; // Zip64 end of central directory locator + var maxCommentSize = 0xffff; // 2-byte size + var bufferSize = Math.min(zip64EocdlSize + eocdrWithoutCommentSize + maxCommentSize, totalSize); + var buffer = newBuffer(bufferSize); + var bufferReadStart = totalSize - buffer.length; + readAndAssertNoEof(reader, buffer, 0, bufferSize, bufferReadStart, function(err) { + if (err) return callback(err); + for (var i = bufferSize - eocdrWithoutCommentSize; i >= 0; i -= 1) { + if (buffer.readUInt32LE(i) !== 0x06054b50) continue; + // found eocdr + var eocdrBuffer = buffer.subarray(i); + + // 0 - End of central directory signature = 0x06054b50 + // 4 - Number of this disk + var diskNumber = eocdrBuffer.readUInt16LE(4); + // 6 - Disk where central directory starts + // 8 - Number of central directory records on this disk + // 10 - Total number of central directory records + var entryCount = eocdrBuffer.readUInt16LE(10); + // 12 - Size of central directory (bytes) + // 16 - Offset of start of central directory, relative to start of archive + var centralDirectoryOffset = eocdrBuffer.readUInt32LE(16); + // 20 - Comment length + var commentLength = eocdrBuffer.readUInt16LE(20); + var expectedCommentLength = eocdrBuffer.length - eocdrWithoutCommentSize; + if (commentLength !== expectedCommentLength) { + return callback(new Error("Invalid comment length. Expected: " + expectedCommentLength + ". Found: " + commentLength + ". Are there extra bytes at the end of the file? Or is the end of central dir signature `PK☺☻` in the comment?")); + } + // 22 - Comment + // the encoding is always cp437. + var comment = decodeStrings ? decodeBuffer(eocdrBuffer.subarray(22), false) + : eocdrBuffer.subarray(22); + + // Look for a Zip64 end of central directory locator + if (i - zip64EocdlSize >= 0 && buffer.readUInt32LE(i - zip64EocdlSize) === 0x07064b50) { + // ZIP64 format + var zip64EocdlBuffer = buffer.subarray(i - zip64EocdlSize, i - zip64EocdlSize + zip64EocdlSize); + // 0 - zip64 end of central dir locator signature = 0x07064b50 + // 4 - number of the disk with the start of the zip64 end of central directory + // 8 - relative offset of the zip64 end of central directory record + var zip64EocdrOffset = readUInt64LE(zip64EocdlBuffer, 8); + // 16 - total number of disks + + // ZIP64 end of central directory record + var zip64EocdrBuffer = newBuffer(56); + return readAndAssertNoEof(reader, zip64EocdrBuffer, 0, zip64EocdrBuffer.length, zip64EocdrOffset, function(err) { + if (err) return callback(err); + + // 0 - zip64 end of central dir signature 4 bytes (0x06064b50) + if (zip64EocdrBuffer.readUInt32LE(0) !== 0x06064b50) { + return callback(new Error("invalid zip64 end of central directory record signature")); + } + // 4 - size of zip64 end of central directory record 8 bytes + // 12 - version made by 2 bytes + // 14 - version needed to extract 2 bytes + // 16 - number of this disk 4 bytes + diskNumber = zip64EocdrBuffer.readUInt32LE(16); + if (diskNumber !== 0) { + // Check this only after zip64 overrides. See #118. + return callback(new Error("multi-disk zip files are not supported: found disk number: " + diskNumber)); + } + // 20 - number of the disk with the start of the central directory 4 bytes + // 24 - total number of entries in the central directory on this disk 8 bytes + // 32 - total number of entries in the central directory 8 bytes + entryCount = readUInt64LE(zip64EocdrBuffer, 32); + // 40 - size of the central directory 8 bytes + // 48 - offset of start of central directory with respect to the starting disk number 8 bytes + centralDirectoryOffset = readUInt64LE(zip64EocdrBuffer, 48); + // 56 - zip64 extensible data sector (variable size) + return callback(null, new ZipFile(reader, centralDirectoryOffset, totalSize, entryCount, comment, options.autoClose, options.lazyEntries, decodeStrings, options.validateEntrySizes, options.strictFileNames)); + }); + } + + // Not ZIP64 format + if (diskNumber !== 0) { + return callback(new Error("multi-disk zip files are not supported: found disk number: " + diskNumber)); + } + return callback(null, new ZipFile(reader, centralDirectoryOffset, totalSize, entryCount, comment, options.autoClose, options.lazyEntries, decodeStrings, options.validateEntrySizes, options.strictFileNames)); + + } + + // Not a zip file. + callback(new Error("End of central directory record signature not found. Either not a zip file, or file is truncated.")); + }); +} + +util.inherits(ZipFile, EventEmitter); +function ZipFile(reader, centralDirectoryOffset, fileSize, entryCount, comment, autoClose, lazyEntries, decodeStrings, validateEntrySizes, strictFileNames) { + var self = this; + EventEmitter.call(self); + self.reader = reader; + // forward close events + self.reader.on("error", function(err) { + // error closing the fd + emitError(self, err); + }); + self.reader.once("close", function() { + self.emit("close"); + }); + self.readEntryCursor = centralDirectoryOffset; + self.fileSize = fileSize; + self.entryCount = entryCount; + self.comment = comment; + self.entriesRead = 0; + self.autoClose = !!autoClose; + self.lazyEntries = !!lazyEntries; + self.decodeStrings = !!decodeStrings; + self.validateEntrySizes = !!validateEntrySizes; + self.strictFileNames = !!strictFileNames; + self.isOpen = true; + self.emittedError = false; + + if (!self.lazyEntries) self._readEntry(); +} +ZipFile.prototype.close = function() { + if (!this.isOpen) return; + this.isOpen = false; + this.reader.unref(); +}; + +function emitErrorAndAutoClose(self, err) { + if (self.autoClose) self.close(); + emitError(self, err); +} +function emitError(self, err) { + if (self.emittedError) return; + self.emittedError = true; + self.emit("error", err); +} + +ZipFile.prototype.readEntry = function() { + if (!this.lazyEntries) throw new Error("readEntry() called without lazyEntries:true"); + this._readEntry(); +}; +ZipFile.prototype._readEntry = function() { + var self = this; + if (self.entryCount === self.entriesRead) { + // done with metadata + setImmediate(function() { + if (self.autoClose) self.close(); + if (self.emittedError) return; + self.emit("end"); + }); + return; + } + if (self.emittedError) return; + var buffer = newBuffer(46); + readAndAssertNoEof(self.reader, buffer, 0, buffer.length, self.readEntryCursor, function(err) { + if (err) return emitErrorAndAutoClose(self, err); + if (self.emittedError) return; + var entry = new Entry(); + // 0 - Central directory file header signature + var signature = buffer.readUInt32LE(0); + if (signature !== 0x02014b50) return emitErrorAndAutoClose(self, new Error("invalid central directory file header signature: 0x" + signature.toString(16))); + // 4 - Version made by + entry.versionMadeBy = buffer.readUInt16LE(4); + // 6 - Version needed to extract (minimum) + entry.versionNeededToExtract = buffer.readUInt16LE(6); + // 8 - General purpose bit flag + entry.generalPurposeBitFlag = buffer.readUInt16LE(8); + // 10 - Compression method + entry.compressionMethod = buffer.readUInt16LE(10); + // 12 - File last modification time + entry.lastModFileTime = buffer.readUInt16LE(12); + // 14 - File last modification date + entry.lastModFileDate = buffer.readUInt16LE(14); + // 16 - CRC-32 + entry.crc32 = buffer.readUInt32LE(16); + // 20 - Compressed size + entry.compressedSize = buffer.readUInt32LE(20); + // 24 - Uncompressed size + entry.uncompressedSize = buffer.readUInt32LE(24); + // 28 - File name length (n) + entry.fileNameLength = buffer.readUInt16LE(28); + // 30 - Extra field length (m) + entry.extraFieldLength = buffer.readUInt16LE(30); + // 32 - File comment length (k) + entry.fileCommentLength = buffer.readUInt16LE(32); + // 34 - Disk number where file starts + // 36 - Internal file attributes + entry.internalFileAttributes = buffer.readUInt16LE(36); + // 38 - External file attributes + entry.externalFileAttributes = buffer.readUInt32LE(38); + // 42 - Relative offset of local file header + entry.relativeOffsetOfLocalHeader = buffer.readUInt32LE(42); + + if (entry.generalPurposeBitFlag & 0x40) return emitErrorAndAutoClose(self, new Error("strong encryption is not supported")); + + self.readEntryCursor += 46; + + buffer = newBuffer(entry.fileNameLength + entry.extraFieldLength + entry.fileCommentLength); + readAndAssertNoEof(self.reader, buffer, 0, buffer.length, self.readEntryCursor, function(err) { + if (err) return emitErrorAndAutoClose(self, err); + if (self.emittedError) return; + // 46 - File name + entry.fileNameRaw = buffer.subarray(0, entry.fileNameLength); + // 46+n - Extra field + var fileCommentStart = entry.fileNameLength + entry.extraFieldLength; + entry.extraFieldRaw = buffer.subarray(entry.fileNameLength, fileCommentStart); + // 46+n+m - File comment + entry.fileCommentRaw = buffer.subarray(fileCommentStart, fileCommentStart + entry.fileCommentLength); + + // Parse the extra fields, which we need for processing other fields. + try { + entry.extraFields = parseExtraFields(entry.extraFieldRaw); + } catch (err) { + return emitErrorAndAutoClose(self, err); + } + + // Interpret strings according to bit flags, extra fields, and options. + if (self.decodeStrings) { + var isUtf8 = (entry.generalPurposeBitFlag & 0x800) !== 0; + entry.fileComment = decodeBuffer(entry.fileCommentRaw, isUtf8); + entry.fileName = getFileNameLowLevel(entry.generalPurposeBitFlag, entry.fileNameRaw, entry.extraFields, self.strictFileNames); + var errorMessage = validateFileName(entry.fileName); + if (errorMessage != null) return emitErrorAndAutoClose(self, new Error(errorMessage)); + } else { + entry.fileComment = entry.fileCommentRaw; + entry.fileName = entry.fileNameRaw; + } + // Maintain API compatibility. See https://github.com/thejoshwolfe/yauzl/issues/47 + entry.comment = entry.fileComment; + + self.readEntryCursor += buffer.length; + self.entriesRead += 1; + + // Check for the Zip64 Extended Information Extra Field. + for (var i = 0; i < entry.extraFields.length; i++) { + var extraField = entry.extraFields[i]; + if (extraField.id !== 0x0001) continue; + // Found it. + + var zip64EiefBuffer = extraField.data; + var index = 0; + // 0 - Original Size 8 bytes + if (entry.uncompressedSize === 0xffffffff) { + if (index + 8 > zip64EiefBuffer.length) { + return emitErrorAndAutoClose(self, new Error("zip64 extended information extra field does not include uncompressed size")); + } + entry.uncompressedSize = readUInt64LE(zip64EiefBuffer, index); + index += 8; + } + // 8 - Compressed Size 8 bytes + if (entry.compressedSize === 0xffffffff) { + if (index + 8 > zip64EiefBuffer.length) { + return emitErrorAndAutoClose(self, new Error("zip64 extended information extra field does not include compressed size")); + } + entry.compressedSize = readUInt64LE(zip64EiefBuffer, index); + index += 8; + } + // 16 - Relative Header Offset 8 bytes + if (entry.relativeOffsetOfLocalHeader === 0xffffffff) { + if (index + 8 > zip64EiefBuffer.length) { + return emitErrorAndAutoClose(self, new Error("zip64 extended information extra field does not include relative header offset")); + } + entry.relativeOffsetOfLocalHeader = readUInt64LE(zip64EiefBuffer, index); + index += 8; + } + // 24 - Disk Start Number 4 bytes + + break; + } + + // validate file size + if (self.validateEntrySizes && entry.compressionMethod === 0) { + var expectedCompressedSize = entry.uncompressedSize; + if (entry.isEncrypted()) { + // traditional encryption prefixes the file data with a header + expectedCompressedSize += 12; + } + if (entry.compressedSize !== expectedCompressedSize) { + var msg = "compressed/uncompressed size mismatch for stored file: " + entry.compressedSize + " != " + entry.uncompressedSize; + return emitErrorAndAutoClose(self, new Error(msg)); + } + } + + self.emit("entry", entry); + + if (!self.lazyEntries) self._readEntry(); + }); + }); +}; + +ZipFile.prototype.openReadStream = function(entry, options, callback) { + var self = this; + // parameter validation + var relativeStart = 0; + var relativeEnd = entry.compressedSize; + if (callback == null) { + callback = options; + options = null; + } + if (options == null) { + options = {}; + } else { + // validate options that the caller has no excuse to get wrong + if (options.decrypt != null) { + if (!entry.isEncrypted()) { + throw new Error("options.decrypt can only be specified for encrypted entries"); + } + if (options.decrypt !== false) throw new Error("invalid options.decrypt value: " + options.decrypt); + if (entry.isCompressed()) { + if (options.decompress !== false) throw new Error("entry is encrypted and compressed, and options.decompress !== false"); + } + } + if (options.decompress != null) { + if (!entry.isCompressed()) { + throw new Error("options.decompress can only be specified for compressed entries"); + } + if (!(options.decompress === false || options.decompress === true)) { + throw new Error("invalid options.decompress value: " + options.decompress); + } + } + if (options.start != null || options.end != null) { + if (entry.isCompressed() && options.decompress !== false) { + throw new Error("start/end range not allowed for compressed entry without options.decompress === false"); + } + if (entry.isEncrypted() && options.decrypt !== false) { + throw new Error("start/end range not allowed for encrypted entry without options.decrypt === false"); + } + } + if (options.start != null) { + relativeStart = options.start; + if (relativeStart < 0) throw new Error("options.start < 0"); + if (relativeStart > entry.compressedSize) throw new Error("options.start > entry.compressedSize"); + } + if (options.end != null) { + relativeEnd = options.end; + if (relativeEnd < 0) throw new Error("options.end < 0"); + if (relativeEnd > entry.compressedSize) throw new Error("options.end > entry.compressedSize"); + if (relativeEnd < relativeStart) throw new Error("options.end < options.start"); + } + } + // any further errors can either be caused by the zipfile, + // or were introduced in a minor version of yauzl, + // so should be passed to the client rather than thrown. + if (!self.isOpen) return callback(new Error("closed")); + if (entry.isEncrypted()) { + if (options.decrypt !== false) return callback(new Error("entry is encrypted, and options.decrypt !== false")); + } + var decompress; + if (entry.compressionMethod === 0) { + // 0 - The file is stored (no compression) + decompress = false; + } else if (entry.compressionMethod === 8) { + // 8 - The file is Deflated + decompress = options.decompress != null ? options.decompress : true; + } else { + return callback(new Error("unsupported compression method: " + entry.compressionMethod)); + } + + self.readLocalFileHeader(entry, {minimal: true}, function(err, localFileHeader) { + if (err) return callback(err); + self.openReadStreamLowLevel( + localFileHeader.fileDataStart, entry.compressedSize, + relativeStart, relativeEnd, + decompress, entry.uncompressedSize, + callback); + }); +}; + +ZipFile.prototype.openReadStreamLowLevel = function(fileDataStart, compressedSize, relativeStart, relativeEnd, decompress, uncompressedSize, callback) { + var self = this; + + var fileDataEnd = fileDataStart + compressedSize; + var readStream = self.reader.createReadStream({ + start: fileDataStart + relativeStart, + end: fileDataStart + relativeEnd, + }); + var endpointStream = readStream; + if (decompress) { + var destroyed = false; + var inflateFilter = zlib.createInflateRaw(); + readStream.on("error", function(err) { + // setImmediate here because errors can be emitted during the first call to pipe() + setImmediate(function() { + if (!destroyed) inflateFilter.emit("error", err); + }); + }); + readStream.pipe(inflateFilter); + + if (self.validateEntrySizes) { + endpointStream = new AssertByteCountStream(uncompressedSize); + inflateFilter.on("error", function(err) { + // forward zlib errors to the client-visible stream + setImmediate(function() { + if (!destroyed) endpointStream.emit("error", err); + }); + }); + inflateFilter.pipe(endpointStream); + } else { + // the zlib filter is the client-visible stream + endpointStream = inflateFilter; + } + // this is part of yauzl's API, so implement this function on the client-visible stream + installDestroyFn(endpointStream, function() { + destroyed = true; + if (inflateFilter !== endpointStream) inflateFilter.unpipe(endpointStream); + readStream.unpipe(inflateFilter); + // TODO: the inflateFilter may cause a memory leak. see Issue #27. + readStream.destroy(); + }); + } + callback(null, endpointStream); +}; + +ZipFile.prototype.readLocalFileHeader = function(entry, options, callback) { + var self = this; + if (callback == null) { + callback = options; + options = null; + } + if (options == null) options = {}; + + self.reader.ref(); + var buffer = newBuffer(30); + readAndAssertNoEof(self.reader, buffer, 0, buffer.length, entry.relativeOffsetOfLocalHeader, function(err) { + try { + if (err) return callback(err); + // 0 - Local file header signature = 0x04034b50 + var signature = buffer.readUInt32LE(0); + if (signature !== 0x04034b50) { + return callback(new Error("invalid local file header signature: 0x" + signature.toString(16))); + } + + var fileNameLength = buffer.readUInt16LE(26); + var extraFieldLength = buffer.readUInt16LE(28); + var fileDataStart = entry.relativeOffsetOfLocalHeader + 30 + fileNameLength + extraFieldLength; + // We now have enough information to do this bounds check. + if (fileDataStart + entry.compressedSize > self.fileSize) { + return callback(new Error("file data overflows file bounds: " + + fileDataStart + " + " + entry.compressedSize + " > " + self.fileSize)); + } + + if (options.minimal) { + return callback(null, {fileDataStart: fileDataStart}); + } + + var localFileHeader = new LocalFileHeader(); + localFileHeader.fileDataStart = fileDataStart; + + // 4 - Version needed to extract (minimum) + localFileHeader.versionNeededToExtract = buffer.readUInt16LE(4); + // 6 - General purpose bit flag + localFileHeader.generalPurposeBitFlag = buffer.readUInt16LE(6); + // 8 - Compression method + localFileHeader.compressionMethod = buffer.readUInt16LE(8); + // 10 - File last modification time + localFileHeader.lastModFileTime = buffer.readUInt16LE(10); + // 12 - File last modification date + localFileHeader.lastModFileDate = buffer.readUInt16LE(12); + // 14 - CRC-32 + localFileHeader.crc32 = buffer.readUInt32LE(14); + // 18 - Compressed size + localFileHeader.compressedSize = buffer.readUInt32LE(18); + // 22 - Uncompressed size + localFileHeader.uncompressedSize = buffer.readUInt32LE(22); + // 26 - File name length (n) + localFileHeader.fileNameLength = fileNameLength; + // 28 - Extra field length (m) + localFileHeader.extraFieldLength = extraFieldLength; + // 30 - File name + // 30+n - Extra field + + buffer = newBuffer(fileNameLength + extraFieldLength); + self.reader.ref(); + readAndAssertNoEof(self.reader, buffer, 0, buffer.length, entry.relativeOffsetOfLocalHeader + 30, function(err) { + try { + if (err) return callback(err); + localFileHeader.fileName = buffer.subarray(0, fileNameLength); + localFileHeader.extraField = buffer.subarray(fileNameLength); + return callback(null, localFileHeader); + } finally { + self.reader.unref(); + } + }); + } finally { + self.reader.unref(); + } + }); +}; + +function Entry() { +} +Entry.prototype.getLastModDate = function(options) { + if (options == null) options = {}; + + if (!options.forceDosFormat) { + // Check extended fields. + for (var i = 0; i < this.extraFields.length; i++) { + var extraField = this.extraFields[i]; + if (extraField.id === 0x5455) { + // Info-ZIP "universal timestamp" extended field (`0x5455` aka `"UT"`). + // See the Info-ZIP source code unix/unix.c:set_extra_field() and zipfile.c:ef_scan_ut_time(). + var data = extraField.data; + if (data.length < 5) continue; // Too short. + // The flags define which of the three fields are present: mtime, atime, ctime. + // We only care about mtime. + // Also, ctime is never included in practice. + // And also, atime is only included in the local file header for some reason + // despite the flags lying about its inclusion in the central header. + var flags = data[0]; + var HAS_MTIME = 1; + if (!(flags & HAS_MTIME)) continue; // This will realistically never happen. + // Although the positions of all of the fields shift around depending on the presence of other fields, + // mtime is always first if present, and that's the only one we care about. + var posixTimestamp = data.readInt32LE(1); + return new Date(posixTimestamp * 1000); + } else if (extraField.id === 0x000a) { + var data = extraField.data; + if (data.length !== 32) continue; // The length is always the same. + // 4 bytes reserved + // 2 bytes Tag + if (data.readUInt16LE(4) !== 1) continue; // Tag1 is actually the only defined Tag. + // 2 bytes Size + if (data.readUInt16LE(6) !== 24) continue; // Size is always 24. + // 8 bytes Mtime + var hundredNanoSecondsSince1601 = data.readUInt32LE(8) + 4294967296 * data.readInt32LE(12); + // Convert from NTFS to POSIX milliseconds. + // The big number below is the milliseconds between year 1601 and year 1970 + // (i.e. the negative POSIX timestamp of 1601-01-01 00:00:00Z) + var millisecondsSince1970 = hundredNanoSecondsSince1601 / 10000 - 11644473600000; + // Note on numeric precision: JavaScript Number objects lose precision above Number.MAX_SAFE_INTEGER, + // and NTFS timestamps are typically much bigger than that limit. + // (MAX_SAFE_INTEGER would represent 1629-07-17T23:58:45.475Z.) + // However, we're losing precision in the conversion from 100nanosecond units to millisecond units anyway, + // and the time at which we also lose 1-millisecond precision is year 275760, the JavaScript Date limit (by design). + // Up through the year 2057, this conversion only drops 4 bits of precision, + // which is well under the 13-14 bits ratio between the milliseconds and 100nanoseconds. + return new Date(millisecondsSince1970); + } + } + } + + // Fallback to non-extended encoding. + return dosDateTimeToDate(this.lastModFileDate, this.lastModFileTime, options.timezone); +}; +Entry.prototype.isEncrypted = function() { + return (this.generalPurposeBitFlag & 0x1) !== 0; +}; +Entry.prototype.isCompressed = function() { + return this.compressionMethod === 8; +}; + +function LocalFileHeader() { +} + +function dosDateTimeToDate(date, time, timezone) { + var day = date & 0x1f; // 1-31 + var month = (date >> 5 & 0xf) - 1; // 1-12, 0-11 + var year = (date >> 9 & 0x7f) + 1980; // 0-128, 1980-2108 + + var millisecond = 0; + var second = (time & 0x1f) * 2; // 0-29, 0-58 (even numbers) + var minute = time >> 5 & 0x3f; // 0-59 + var hour = time >> 11 & 0x1f; // 0-23 + + if (timezone == null || timezone === "local") { + return new Date(year, month, day, hour, minute, second, millisecond); + } else if (timezone === "UTC") { + return new Date(Date.UTC(year, month, day, hour, minute, second, millisecond)); + } else { + throw new Error("unrecognized options.timezone: " + options.timezone); + } +} + +function getFileNameLowLevel(generalPurposeBitFlag, fileNameBuffer, extraFields, strictFileNames) { + var fileName = null; + + // check for Info-ZIP Unicode Path Extra Field (0x7075) + // see https://github.com/thejoshwolfe/yauzl/issues/33 + for (var i = 0; i < extraFields.length; i++) { + var extraField = extraFields[i]; + if (extraField.id === 0x7075) { + if (extraField.data.length < 6) { + // too short to be meaningful + continue; + } + // Version 1 byte version of this extra field, currently 1 + if (extraField.data.readUInt8(0) !== 1) { + // > Changes may not be backward compatible so this extra + // > field should not be used if the version is not recognized. + continue; + } + // NameCRC32 4 bytes File Name Field CRC32 Checksum + var oldNameCrc32 = extraField.data.readUInt32LE(1); + if (crc32.unsigned(fileNameBuffer) !== oldNameCrc32) { + // > If the CRC check fails, this UTF-8 Path Extra Field should be + // > ignored and the File Name field in the header should be used instead. + continue; + } + // UnicodeName Variable UTF-8 version of the entry File Name + fileName = decodeBuffer(extraField.data.subarray(5), true); + break; + } + } + + if (fileName == null) { + // The typical case. + var isUtf8 = (generalPurposeBitFlag & 0x800) !== 0; + fileName = decodeBuffer(fileNameBuffer, isUtf8); + } + + if (!strictFileNames) { + // Allow backslash. + fileName = fileName.replace(/\\/g, "/"); + } + return fileName; +} + +function validateFileName(fileName) { + if (fileName.indexOf("\\") !== -1) { + return "invalid characters in fileName: " + fileName; + } + if (/^[a-zA-Z]:/.test(fileName) || /^\//.test(fileName)) { + return "absolute path: " + fileName; + } + if (fileName.split("/").indexOf("..") !== -1) { + return "invalid relative path: " + fileName; + } + // all good + return null; +} + +function parseExtraFields(extraFieldBuffer) { + var extraFields = []; + var i = 0; + while (i < extraFieldBuffer.length - 3) { + var headerId = extraFieldBuffer.readUInt16LE(i + 0); + var dataSize = extraFieldBuffer.readUInt16LE(i + 2); + var dataStart = i + 4; + var dataEnd = dataStart + dataSize; + if (dataEnd > extraFieldBuffer.length) throw new Error("extra field length exceeds extra field buffer size"); + var dataBuffer = extraFieldBuffer.subarray(dataStart, dataEnd); + extraFields.push({ + id: headerId, + data: dataBuffer, + }); + i = dataEnd; + } + return extraFields; +} + +function readAndAssertNoEof(reader, buffer, offset, length, position, callback) { + if (length === 0) { + // fs.read will throw an out-of-bounds error if you try to read 0 bytes from a 0 byte file + return setImmediate(function() { callback(null, newBuffer(0)); }); + } + reader.read(buffer, offset, length, position, function(err, bytesRead) { + if (err) return callback(err); + if (bytesRead < length) { + return callback(new Error("unexpected EOF")); + } + callback(); + }); +} + +util.inherits(AssertByteCountStream, Transform); +function AssertByteCountStream(byteCount) { + Transform.call(this); + this.actualByteCount = 0; + this.expectedByteCount = byteCount; +} +AssertByteCountStream.prototype._transform = function(chunk, encoding, cb) { + this.actualByteCount += chunk.length; + if (this.actualByteCount > this.expectedByteCount) { + var msg = "too many bytes in the stream. expected " + this.expectedByteCount + ". got at least " + this.actualByteCount; + return cb(new Error(msg)); + } + cb(null, chunk); +}; +AssertByteCountStream.prototype._flush = function(cb) { + if (this.actualByteCount < this.expectedByteCount) { + var msg = "not enough bytes in the stream. expected " + this.expectedByteCount + ". got only " + this.actualByteCount; + return cb(new Error(msg)); + } + cb(); +}; + +util.inherits(RandomAccessReader, EventEmitter); +function RandomAccessReader() { + EventEmitter.call(this); + this.refCount = 0; +} +RandomAccessReader.prototype.ref = function() { + this.refCount += 1; +}; +RandomAccessReader.prototype.unref = function() { + var self = this; + self.refCount -= 1; + + if (self.refCount > 0) return; + if (self.refCount < 0) throw new Error("invalid unref"); + + self.close(onCloseDone); + + function onCloseDone(err) { + if (err) return self.emit('error', err); + self.emit('close'); + } +}; +RandomAccessReader.prototype.createReadStream = function(options) { + if (options == null) options = {}; + var start = options.start; + var end = options.end; + if (start === end) { + var emptyStream = new PassThrough(); + setImmediate(function() { + emptyStream.end(); + }); + return emptyStream; + } + var stream = this._readStreamForRange(start, end); + + var destroyed = false; + var refUnrefFilter = new RefUnrefFilter(this); + stream.on("error", function(err) { + setImmediate(function() { + if (!destroyed) refUnrefFilter.emit("error", err); + }); + }); + installDestroyFn(refUnrefFilter, function() { + stream.unpipe(refUnrefFilter); + refUnrefFilter.unref(); + stream.destroy(); + }); + + var byteCounter = new AssertByteCountStream(end - start); + refUnrefFilter.on("error", function(err) { + setImmediate(function() { + if (!destroyed) byteCounter.emit("error", err); + }); + }); + installDestroyFn(byteCounter, function() { + destroyed = true; + refUnrefFilter.unpipe(byteCounter); + refUnrefFilter.destroy(); + }); + + return stream.pipe(refUnrefFilter).pipe(byteCounter); +}; +RandomAccessReader.prototype._readStreamForRange = function(start, end) { + throw new Error("not implemented"); +}; +RandomAccessReader.prototype.read = function(buffer, offset, length, position, callback) { + var readStream = this.createReadStream({start: position, end: position + length}); + var writeStream = new Writable(); + var written = 0; + writeStream._write = function(chunk, encoding, cb) { + chunk.copy(buffer, offset + written, 0, chunk.length); + written += chunk.length; + cb(); + }; + writeStream.on("finish", callback); + readStream.on("error", function(error) { + callback(error); + }); + readStream.pipe(writeStream); +}; +RandomAccessReader.prototype.close = function(callback) { + setImmediate(callback); +}; + +util.inherits(RefUnrefFilter, PassThrough); +function RefUnrefFilter(context) { + PassThrough.call(this); + this.context = context; + this.context.ref(); + this.unreffedYet = false; +} +RefUnrefFilter.prototype._flush = function(cb) { + this.unref(); + cb(); +}; +RefUnrefFilter.prototype.unref = function(cb) { + if (this.unreffedYet) return; + this.unreffedYet = true; + this.context.unref(); +}; + +var cp437 = '\u0000☺☻♥♦♣♠•◘○◙♂♀♪♫☼►◄↕‼¶§▬↨↑↓→←∟↔▲▼ !"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~⌂ÇüéâäàåçêëèïîìÄÅÉæÆôöòûùÿÖÜ¢£¥₧ƒáíóúñѪº¿⌐¬½¼¡«»░▒▓│┤╡╢╖╕╣║╗╝╜╛┐└┴┬├─┼╞╟╚╔╩╦╠═╬╧╨╤╥╙╘╒╓╫╪┘┌█▄▌▐▀αßΓπΣσµτΦΘΩδ∞φε∩≡±≥≤⌠⌡÷≈°∙·√ⁿ²■ '; +function decodeBuffer(buffer, isUtf8) { + if (isUtf8) { + return buffer.toString("utf8"); + } else { + var result = ""; + for (var i = 0; i < buffer.length; i++) { + result += cp437[buffer[i]]; + } + return result; + } +} + +function readUInt64LE(buffer, offset) { + // There is no native function for this, because we can't actually store 64-bit integers precisely. + // after 53 bits, JavaScript's Number type (IEEE 754 double) can't store individual integers anymore. + // but since 53 bits is a whole lot more than 32 bits, we do our best anyway. + // As of 2020, Node has added support for BigInt, which obviates this whole function, + // but yauzl hasn't been updated to depend on BigInt (yet?). + var lower32 = buffer.readUInt32LE(offset); + var upper32 = buffer.readUInt32LE(offset + 4); + // we can't use bitshifting here, because JavaScript bitshifting only works on 32-bit integers. + return upper32 * 0x100000000 + lower32; + // as long as we're bounds checking the result of this function against the total file size, + // we'll catch any overflow errors, because we already made sure the total file size was within reason. +} + +// Node 10 deprecated new Buffer(). +var newBuffer; +if (typeof Buffer.allocUnsafe === "function") { + newBuffer = function(len) { + return Buffer.allocUnsafe(len); + }; +} else { + newBuffer = function(len) { + return new Buffer(len); + }; +} + +// Node 8 introduced a proper destroy() implementation on writable streams. +function installDestroyFn(stream, fn) { + if (typeof stream.destroy === "function") { + // New API. + stream._destroy = function(err, cb) { + fn(); + if (cb != null) cb(err); + }; + } else { + // Old API. + stream.destroy = fn; + } +} + +function defaultCallback(err) { + if (err) throw err; +} diff --git a/packages/utils/third_party/yauzl/pend.js b/packages/utils/third_party/yauzl/pend.js new file mode 100644 index 0000000000000..c278333e8e8eb --- /dev/null +++ b/packages/utils/third_party/yauzl/pend.js @@ -0,0 +1,58 @@ +// Vendored from https://github.com/andrewrk/node-pend at v1.2.0 under the MIT License. +// See LICENSE for the full text. + +module.exports = Pend; + +function Pend() { + this.pending = 0; + this.max = Infinity; + this.listeners = []; + this.waiting = []; + this.error = null; +} + +Pend.prototype.go = function(fn) { + if (this.pending < this.max) { + pendGo(this, fn); + } else { + this.waiting.push(fn); + } +}; + +Pend.prototype.wait = function(cb) { + if (this.pending === 0) { + cb(this.error); + } else { + this.listeners.push(cb); + } +}; + +Pend.prototype.hold = function() { + return pendHold(this); +}; + +function pendHold(self) { + self.pending += 1; + var called = false; + return onCb; + function onCb(err) { + if (called) throw new Error("callback called twice"); + called = true; + self.error = self.error || err; + self.pending -= 1; + if (self.waiting.length > 0 && self.pending < self.max) { + pendGo(self, self.waiting.shift()); + } else if (self.pending === 0) { + var listeners = self.listeners; + self.listeners = []; + listeners.forEach(cbListener); + } + } + function cbListener(listener) { + listener(self.error); + } +} + +function pendGo(self, fn) { + fn(pendHold(self)); +} diff --git a/packages/utils/zipFile.ts b/packages/utils/zipFile.ts index 9c59e5ae2443d..4b8209bab00b9 100644 --- a/packages/utils/zipFile.ts +++ b/packages/utils/zipFile.ts @@ -14,8 +14,8 @@ * limitations under the License. */ -import * as yauzl from 'yauzl'; -import type { Entry, ZipFile as UnzipFile } from 'yauzl'; +import * as yauzl from './third_party/yauzl'; +import type { Entry, ZipFile as UnzipFile } from './third_party/yauzl'; export class ZipFile { private _fileName: string; diff --git a/utils/build/utilsBundleMapping.js b/utils/build/utilsBundleMapping.js index 3020a6ea86c3f..1ee21a049b14f 100644 --- a/utils/build/utilsBundleMapping.js +++ b/utils/build/utilsBundleMapping.js @@ -33,7 +33,6 @@ const MAPPING = { 'chokidar': { default: 'chokidar' }, 'get-east-asian-width': { namespace: 'getEastAsianWidth' }, 'yazl': { namespace: 'yazl' }, - 'yauzl': { default: 'yauzl', namespace: 'yauzl' }, 'zod': { namespace: 'z' }, 'zod-to-json-schema': { named: { zodToJsonSchema: 'zodToJsonSchema' } }, '@modelcontextprotocol/sdk/client/index.js': { named: { Client: 'Client' } }, diff --git a/utils/protocol-types-generator/index.js b/utils/protocol-types-generator/index.js index 0219eb8960072..992e45ece940d 100644 --- a/utils/protocol-types-generator/index.js +++ b/utils/protocol-types-generator/index.js @@ -1,7 +1,7 @@ // @ts-check const path = require('path'); const fs = require('fs'); -const yauzl = require('yauzl'); +const yauzl = require('../../packages/utils/third_party/yauzl'); const vm = require('vm'); const os = require('os'); From 8618ba941c64b04c95b2c721fa3cd6364e9163ee Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Mon, 11 May 2026 14:15:37 -0700 Subject: [PATCH 07/13] cherry-pick(#40785): docs: 1.60 release notes for Java, Python and C# --- docs/src/release-notes-csharp.md | 81 ++++++++++++++++++++++++++++++++ docs/src/release-notes-java.md | 74 +++++++++++++++++++++++++++++ docs/src/release-notes-python.md | 78 ++++++++++++++++++++++++++++++ 3 files changed, 233 insertions(+) diff --git a/docs/src/release-notes-csharp.md b/docs/src/release-notes-csharp.md index 52a786b29ffae..f24cd9eaaa98b 100644 --- a/docs/src/release-notes-csharp.md +++ b/docs/src/release-notes-csharp.md @@ -6,6 +6,87 @@ toc_max_heading_level: 2 import LiteYouTube from '@site/src/components/LiteYouTube'; +## Version 1.60 + +### 🌐 HAR recording on Tracing + +[`method: Tracing.startHar`] / [`method: Tracing.stopHar`] expose HAR recording as a first-class tracing API, with the same `Content`, `Mode` and `UrlFilter` options as `RecordHar`: + +```csharp +await context.Tracing.StartHarAsync("trace.har"); +var page = await context.NewPageAsync(); +await page.GotoAsync("https://playwright.dev"); +await context.Tracing.StopHarAsync(); +``` + +### 🪝 Drop API + +New [`method: Locator.drop`] simulates an external drag-and-drop of files or clipboard-like data onto an element. Playwright dispatches `dragenter`, `dragover`, and `drop` with a synthetic [DataTransfer] in the page context — works cross-browser and is great for testing upload zones: + +```csharp +await page.Locator("#dropzone").DropAsync(new() { + Files = new FilePayload() { + Name = "note.txt", + MimeType = "text/plain", + Buffer = Encoding.UTF8.GetBytes("hello"), + }, +}); + +await page.Locator("#dropzone").DropAsync(new() { + Data = new Dictionary { + ["text/plain"] = "hello world", + ["text/uri-list"] = "https://example.com", + }, +}); +``` + +### 🎯 Aria snapshots + +- [`method: PageAssertions.toMatchAriaSnapshot`] now works on a [Page], in addition to a [Locator] — equivalent to asserting against `Page.Locator("body")`. +- New `Boxes` option on [`method: Locator.ariaSnapshot`] / [`method: Page.ariaSnapshot`] appends each element's bounding box as `[box=x,y,width,height]`, useful for AI consumption. + +### New APIs + +#### Browser, Context and Page + +- Event [`event: Browser.context`] — fired when a new context is created on the browser. +- [BrowserContext] now mirrors lifecycle events from its pages: [`event: BrowserContext.download`], [`event: BrowserContext.frameAttached`], [`event: BrowserContext.frameDetached`], [`event: BrowserContext.frameNavigated`], [`event: BrowserContext.pageClose`], [`event: BrowserContext.pageLoad`]. + +#### Locators and Assertions + +- New option `Description` in [`method: Page.getByRole`] / [`method: Locator.getByRole`] / [`method: Frame.getByRole`] / [`method: FrameLocator.getByRole`] for matching the [accessible description](https://www.w3.org/TR/wai-aria-1.2/#dfn-accessible-description). +- New option `Pseudo` in [`method: LocatorAssertions.toHaveCSS`] reads computed styles from `::before` or `::after`. +- New option `Style` in [`method: Locator.highlight`] applies extra inline CSS to the highlight overlay, plus new [`method: Page.hideHighlight`] to clear all highlights. + +#### Network + +- [`method: WebSocketRoute.protocols`] returns the WebSocket subprotocols requested by the page. +- New option `NoDefaults` in [`method: BrowserType.connectOverCDP`] disables Playwright's default overrides on the default context (download behavior, focus emulation, media emulation), so attaching to a user's daily-driver browser doesn't disturb its state. + +#### Errors + +- New [`method: WebError.location`] mirrors [`method: ConsoleMessage.location`]. + +### 🛠️ Other improvements + +- Trace Viewer adds a pretty-print toggle for JSON / form request and response bodies in the network details panel. + +### Breaking Changes ⚠️ + +- Removed long-deprecated `Handle` option on `BrowserContext.ExposeBindingAsync` and `Page.ExposeBindingAsync`. + +### Browser Versions + +- Chromium 148.0.7778.96 +- Mozilla Firefox 150.0.2 +- WebKit 26.4 + +This version was also tested against the following stable channels: + +- Google Chrome 147 +- Microsoft Edge 147 + + ## Version 1.59 ### 🎬 Screencast diff --git a/docs/src/release-notes-java.md b/docs/src/release-notes-java.md index c2e9ee1e2457d..70497e4a725f9 100644 --- a/docs/src/release-notes-java.md +++ b/docs/src/release-notes-java.md @@ -6,6 +6,80 @@ toc_max_heading_level: 2 import LiteYouTube from '@site/src/components/LiteYouTube'; +## Version 1.60 + +### 🌐 HAR recording on Tracing + +[`method: Tracing.startHar`] / [`method: Tracing.stopHar`] expose HAR recording as a first-class tracing API, with the same `content`, `mode` and `urlFilter` options as `recordHar`: + +```java +context.tracing().startHar(Paths.get("trace.har")); +Page page = context.newPage(); +page.navigate("https://playwright.dev"); +context.tracing().stopHar(); +``` + +### 🪝 Drop API + +New [`method: Locator.drop`] simulates an external drag-and-drop of files or clipboard-like data onto an element. Playwright dispatches `dragenter`, `dragover`, and `drop` with a synthetic [DataTransfer] in the page context — works cross-browser and is great for testing upload zones: + +```java +page.locator("#dropzone").drop(new Locator.DropPayload() + .setFiles(new FilePayload("note.txt", "text/plain", "hello".getBytes(StandardCharsets.UTF_8)))); + +page.locator("#dropzone").drop(new Locator.DropPayload() + .setData(Map.of( + "text/plain", "hello world", + "text/uri-list", "https://example.com"))); +``` + +### 🎯 Aria snapshots + +- [`method: PageAssertions.toMatchAriaSnapshot`] now works on a [Page], in addition to a [Locator] — equivalent to asserting against `page.locator("body")`. +- New `boxes` option on [`method: Locator.ariaSnapshot`] / [`method: Page.ariaSnapshot`] appends each element's bounding box as `[box=x,y,width,height]`, useful for AI consumption. + +### New APIs + +#### Browser, Context and Page + +- Event [`event: Browser.context`] — fired when a new context is created on the browser. +- [BrowserContext] now mirrors lifecycle events from its pages: [`event: BrowserContext.download`], [`event: BrowserContext.frameAttached`], [`event: BrowserContext.frameDetached`], [`event: BrowserContext.frameNavigated`], [`event: BrowserContext.pageClose`], [`event: BrowserContext.pageLoad`]. + +#### Locators and Assertions + +- New option `description` in [`method: Page.getByRole`] / [`method: Locator.getByRole`] / [`method: Frame.getByRole`] / [`method: FrameLocator.getByRole`] for matching the [accessible description](https://www.w3.org/TR/wai-aria-1.2/#dfn-accessible-description). +- New option `pseudo` in [`method: LocatorAssertions.toHaveCSS`] reads computed styles from `::before` or `::after`. +- New option `style` in [`method: Locator.highlight`] applies extra inline CSS to the highlight overlay, plus new [`method: Page.hideHighlight`] to clear all highlights. + +#### Network + +- [`method: WebSocketRoute.protocols`] returns the WebSocket subprotocols requested by the page. +- New option `noDefaults` in [`method: BrowserType.connectOverCDP`] disables Playwright's default overrides on the default context (download behavior, focus emulation, media emulation), so attaching to a user's daily-driver browser doesn't disturb its state. + +#### Errors + +- New [`method: WebError.location`] mirrors [`method: ConsoleMessage.location`]. + +### 🛠️ Other improvements + +- Trace Viewer adds a pretty-print toggle for JSON / form request and response bodies in the network details panel. + +### Breaking Changes ⚠️ + +- Removed long-deprecated `handle` option on `BrowserContext.exposeBinding` and `Page.exposeBinding`. + +### Browser Versions + +- Chromium 148.0.7778.96 +- Mozilla Firefox 150.0.2 +- WebKit 26.4 + +This version was also tested against the following stable channels: + +- Google Chrome 147 +- Microsoft Edge 147 + + ## Version 1.59 ### 🎬 Screencast diff --git a/docs/src/release-notes-python.md b/docs/src/release-notes-python.md index ea012876c75ac..91dd0a1fed3c2 100644 --- a/docs/src/release-notes-python.md +++ b/docs/src/release-notes-python.md @@ -6,6 +6,84 @@ toc_max_heading_level: 2 import LiteYouTube from '@site/src/components/LiteYouTube'; +## Version 1.60 + +### 🌐 HAR recording on Tracing + +[`method: Tracing.startHar`] / [`method: Tracing.stopHar`] expose HAR recording as a first-class tracing API, with the same `content`, `mode` and `url_filter` options as `record_har`: + +```python +context.tracing.start_har("trace.har") +page = context.new_page() +page.goto("https://playwright.dev") +context.tracing.stop_har() +``` + +### 🪝 Drop API + +New [`method: Locator.drop`] simulates an external drag-and-drop of files or clipboard-like data onto an element. Playwright dispatches `dragenter`, `dragover`, and `drop` with a synthetic [DataTransfer] in the page context — works cross-browser and is great for testing upload zones: + +```python +page.locator("#dropzone").drop( + files={"name": "note.txt", "mime_type": "text/plain", "buffer": b"hello"}, +) + +page.locator("#dropzone").drop( + data={ + "text/plain": "hello world", + "text/uri-list": "https://example.com", + }, +) +``` + +### 🎯 Aria snapshots + +- [`method: PageAssertions.toMatchAriaSnapshot`] now works on a [Page], in addition to a [Locator] — equivalent to asserting against `page.locator("body")`. +- New `boxes` option on [`method: Locator.ariaSnapshot`] / [`method: Page.ariaSnapshot`] appends each element's bounding box as `[box=x,y,width,height]`, useful for AI consumption. + +### New APIs + +#### Browser, Context and Page + +- Event [`event: Browser.context`] — fired when a new context is created on the browser. +- [BrowserContext] now mirrors lifecycle events from its pages: [`event: BrowserContext.download`], [`event: BrowserContext.frameAttached`], [`event: BrowserContext.frameDetached`], [`event: BrowserContext.frameNavigated`], [`event: BrowserContext.pageClose`], [`event: BrowserContext.pageLoad`]. + +#### Locators and Assertions + +- New option `description` in [`method: Page.getByRole`] / [`method: Locator.getByRole`] / [`method: Frame.getByRole`] / [`method: FrameLocator.getByRole`] for matching the [accessible description](https://www.w3.org/TR/wai-aria-1.2/#dfn-accessible-description). +- New option `pseudo` in [`method: LocatorAssertions.toHaveCSS`] reads computed styles from `::before` or `::after`. +- New option `style` in [`method: Locator.highlight`] applies extra inline CSS to the highlight overlay, plus new [`method: Page.hideHighlight`] to clear all highlights. + +#### Network + +- [`method: WebSocketRoute.protocols`] returns the WebSocket subprotocols requested by the page. +- New option `no_defaults` in [`method: BrowserType.connectOverCDP`] disables Playwright's default overrides on the default context (download behavior, focus emulation, media emulation), so attaching to a user's daily-driver browser doesn't disturb its state. + +#### Errors + +- New [`method: WebError.location`] mirrors [`method: ConsoleMessage.location`]. +- [`method: ConsoleMessage.location`] now exposes `line` / `column` properties (`line_number` / `column_number` are deprecated). + +### 🛠️ Other improvements + +- Trace Viewer adds a pretty-print toggle for JSON / form request and response bodies in the network details panel. + +### Breaking Changes ⚠️ + +- Removed long-deprecated `handle` option on `BrowserContext.expose_binding` and `Page.expose_binding`. + +### Browser Versions + +- Chromium 148.0.7778.96 +- Mozilla Firefox 150.0.2 +- WebKit 26.4 + +This version was also tested against the following stable channels: + +- Google Chrome 147 +- Microsoft Edge 147 + + ## Version 1.59 ### 🎬 Screencast From 5fd703e3b7d7d85606299b88f414e8d15e3a41c4 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Tue, 26 May 2026 20:41:06 +0200 Subject: [PATCH 08/13] cherry-pick(#41004): Revert "fix(trace-viewer): validate origin of postMessage trace blob" --- packages/trace-viewer/src/ui/workbenchLoader.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/trace-viewer/src/ui/workbenchLoader.tsx b/packages/trace-viewer/src/ui/workbenchLoader.tsx index 5a112d23775a3..e3718e63368b5 100644 --- a/packages/trace-viewer/src/ui/workbenchLoader.tsx +++ b/packages/trace-viewer/src/ui/workbenchLoader.tsx @@ -69,8 +69,6 @@ export const WorkbenchLoader: React.FunctionComponent<{ }); React.useEffect(() => { const listener = (e: MessageEvent) => { - if (e.origin !== window.location.origin) - return; const { method, params } = e.data; if (method !== 'load' || !(params?.trace instanceof Blob)) From bc1040e49bccc03c73484ad3abb7217c033da6c6 Mon Sep 17 00:00:00 2001 From: Oliver Stenbom Date: Mon, 1 Jun 2026 14:34:35 +0200 Subject: [PATCH 09/13] Add programmatic runner export implementation --- ...bda-playwright-programmatic-runner-plan.md | 501 ++++++++++++++++++ packages/playwright/package.json | 6 + packages/playwright/programmatic-runner.d.ts | 82 +++ packages/playwright/programmatic-runner.js | 17 + packages/playwright/programmatic-runner.mjs | 22 + packages/playwright/src/DEPS.list | 3 + .../playwright/src/common/configLoader.ts | 7 +- packages/playwright/src/common/ipc.ts | 1 + packages/playwright/src/programmaticRunner.ts | 108 ++++ packages/playwright/src/runner/dispatcher.ts | 37 +- packages/playwright/src/runner/index.ts | 1 + packages/playwright/src/runner/loadUtils.ts | 91 +++- packages/playwright/src/runner/processHost.ts | 20 +- packages/playwright/src/runner/tasks.ts | 26 +- packages/playwright/src/runner/testRunner.ts | 4 +- packages/playwright/src/runner/workerHost.ts | 64 ++- packages/playwright/src/worker/workerMain.ts | 6 + .../programmatic-runner.spec.ts | 31 ++ utils/endform/programmatic-runner-smoke.js | 249 +++++++++ 19 files changed, 1232 insertions(+), 44 deletions(-) create mode 100644 endform-lambda-playwright-programmatic-runner-plan.md create mode 100644 packages/playwright/programmatic-runner.d.ts create mode 100644 packages/playwright/programmatic-runner.js create mode 100644 packages/playwright/programmatic-runner.mjs create mode 100644 packages/playwright/src/programmaticRunner.ts create mode 100644 tests/playwright-test/programmatic-runner.spec.ts create mode 100644 utils/endform/programmatic-runner-smoke.js diff --git a/endform-lambda-playwright-programmatic-runner-plan.md b/endform-lambda-playwright-programmatic-runner-plan.md new file mode 100644 index 0000000000000..9319c957a329c --- /dev/null +++ b/endform-lambda-playwright-programmatic-runner-plan.md @@ -0,0 +1,501 @@ +# Endform Lambda Playwright Programmatic Runner Plan + +## Summary + +Endform runs Playwright tests remotely inside AWS Lambda. The current Lambda Runner invokes Playwright through the normal CLI after preparing scratch files, dependencies, proxy state, config rewrites, reporter shims, and environment variables. This works, but it serializes too much startup work and expresses Endform policy through fragile command-line arguments and temporary edits to the user's Playwright config file. + +The new target architecture is a per-invocation Node host process that starts as early as possible, imports Playwright, preforks Playwright workers, and later receives a structured `runTests` request from Rust over IPC. The Playwright fork should expose generic programmatic runner primitives. Endform-specific config policy, reporter pipeline construction, generated reporters, and Rust IPC protocol should stay in the Lambda Runner repository, ideally inside an Endform-owned Node host script. + +The Playwright fork should not expose Endform-shaped APIs. It should expose a small, stable, Playwright-shaped API that lets an external host load a user config, mutate that config in normal JavaScript, and run selected tests with preforked workers and native Playwright reporting. + +## Goals + +- Start the Node Playwright host as early as possible in each Lambda invocation. +- Import Playwright and prefork one or more Playwright workers while Rust downloads dependencies, prepares scratch space, starts proxy infrastructure, and computes run parameters. +- Replace CLI argument construction with a typed programmatic `runTests` call. +- Replace temporary mutation of `playwright.config.*` with direct mutation of the raw user config object in the Endform Node host. +- Replace `--test-list=` with structured in-memory test selection. +- Keep reporter execution in the Playwright host process, preserving native Playwright reporter lifecycle and object identity. +- Keep Endform-specific policy out of the Playwright fork where practical. +- Keep the fork surface small enough to carry across multiple Playwright version branches. +- Design the generic pieces so some can plausibly be upstreamed later without bringing Endform-specific behavior with them. + +## Non-Goals + +- Do not move Playwright native reporters into worker processes. +- Do not make the Playwright fork understand Endform concepts such as suite-control, completion assets, trace upload policy, remote reporters, or test attempt IDs. +- Do not introduce a Playwright-fork-owned config mutation DSL such as `deleteTopLevel`, `mergeUse`, or `reporterPipeline`. +- Do not require the Playwright fork to own the Rust-to-Node IPC protocol. +- Do not remove Chrome launch monkey patching in this phase. Endform can continue using `NODE_OPTIONS` and spawn/fork monkey patches for Chrome flags and related process behavior. +- Do not optimize away runner-side test loading or prewarm browser contexts until the generic prefork/programmatic runner path is measured. + +## Current Endform Flow + +The current Lambda Runner flow is roughly: + +1. Rust receives the Lambda invocation. +2. Rust builds a `PlaywrightController` with middlewares. +3. Controller setup downloads dependent files, creates symlinks, writes env vars, starts proxy infrastructure, creates generated JS reporters, and prepares config overrides. +4. The controller writes a generated config override shim. +5. The controller temporarily edits the user's `playwright.config.*` file so it imports/requires that shim. +6. The controller spawns `node test ...`. +7. Playwright CLI loads the modified config file. +8. Playwright runs tests normally. +9. Generated reporters send observer events back to Rust through a file descriptor transport. +10. Rust waits for Playwright exit, restores the config file, runs teardowns, uploads artifacts, and reports results. + +This means Playwright startup begins late, after much of setup has already completed. It also means config policy is expressed partly as CLI flags and partly as generated JavaScript injected into the user's config file. + +## Current CLI Arguments To Replace + +Endform currently constructs Playwright CLI arguments equivalent to: + +```text +node test +--config= +--retries=0 +--workers=1 +--max-failures=0 +--no-deps +--timeout= +--update-snapshots= +--test-list= +``` + +These should become structured parameters to `runTests`: + +```ts +await runTests({ + config, + configLocation, + configOverrides: { + retries: 0, + workers: 1, + maxFailures: 0, + timeout, + updateSnapshots, + }, + ignoreProjectDependencies: true, + testSelection, + workerEnv, + preforkedWorkers, + reporter, +}); +``` + +The exact field names can follow Playwright naming, but the important boundary is that these are programmatic run options, not CLI strings. + +## Current Config File Mutations To Move Out Of The Fork + +Endform currently mutates the loaded config through a generated shim. Examples include: + +- Delete top-level `webServer`. +- Delete top-level `globalSetup`. +- Delete top-level `globalTeardown`. +- Delete `use.launchOptions.executablePath` from top-level `use`. +- Delete `use.launchOptions.executablePath` from every project `use`. +- Set top-level and project `outputDir`. +- Set top-level and project `use.trace`. +- Set top-level and project `use.browserName = "chromium"`. +- Set top-level and project `use.defaultBrowserType = "chromium"`. +- Set top-level and project `use.channel = "chromium"`. +- Merge Endform-provided `extraHTTPHeaders` into top-level and project `use.extraHTTPHeaders` with user config taking precedence. +- Rewrite `reporter` to include generated Endform reporters, selected pre-existing user reporters, OTEL reporter, and blob reporter in the correct order. + +The Playwright fork should not own a schema for these mutations. Instead, the Lambda Runner's Node host should load the raw user config object and mutate it directly with ordinary JavaScript before passing it to Playwright's new `runTests` export. + +Example Endform-owned host logic: + +```ts +const config = await loadUserConfig(configLocation); + +delete config.webServer; +delete config.globalSetup; +delete config.globalTeardown; + +deleteNestedUse(config.use, ['launchOptions', 'executablePath']); +for (const project of config.projects ?? []) + deleteNestedUse(project.use, ['launchOptions', 'executablePath']); + +setOutputDirEverywhere(config, request.outputDir); +setUseEverywhere(config, { + trace: request.trace, + browserName: 'chromium', + defaultBrowserType: 'chromium', + channel: 'chromium', +}); +mergeUseEverywhere(config, 'extraHTTPHeaders', request.extraHTTPHeaders, 'lower'); + +config.reporter = buildEndformReporterList(config.reporter, request); + +await runTests({ + configLocation, + config, + configOverrides, + testSelection, + reporter, + preforkedWorkers, +}); +``` + +This keeps Endform's policy close to Endform and avoids coupling the fork to Lambda Runner implementation details. + +## Target Architecture + +```text +Rust Lambda Runner + starts Node host early + continues dependency/scratch/proxy setup + sends structured run request over IPC + receives lifecycle/result/timing data + +Endform Node Host, owned by Lambda Runner repo + imports generic Playwright fork exports + owns Rust-to-Node IPC protocol + owns Endform config mutation policy + owns reporter pipeline generation + owns generated reporter files/modules + owns mapping Endform test attempts to Playwright structured test selection + +Playwright Fork + exposes generic runTests/loadConfig/prefork APIs + loads user configs without forcing immediate CLI execution + accepts an already-mutated config object + normalizes config into FullConfigInternal + runs selected tests through normal Playwright dispatcher/worker/reporter flow + supports preforked delayed-init workers +``` + +## Proposed Playwright Export + +Add a deliberate package export such as: + +```json +{ + "exports": { + "./programmatic-runner": { + "types": "./programmatic-runner.d.ts", + "import": "./programmatic-runner.mjs", + "require": "./programmatic-runner.js", + "default": "./programmatic-runner.js" + } + } +} +``` + +The exact export name can change. The important part is that consumers do not import `playwright/lib/runner`, `WorkerHost`, `Dispatcher`, or other private internals directly. + +The primary exported function can simply be named `runTests`: + +```ts +export async function runTests(params: RunTestsParams): Promise; +``` + +Supporting exports should be generic and minimal: + +```ts +export async function loadUserConfig(location: ConfigLocation): Promise; + +export async function createPreforkedWorkers(params: { + workers: number; +}): Promise; + +export async function disposePreforkedWorkers(workers: PreforkedWorkers): Promise; +``` + +If a class is more ergonomic than separate functions, expose a generic host-like object, but keep method names Playwright-shaped: + +```ts +const runner = await createRunnerHost({ workers: 1 }); +await runner.ready(); +await runner.runTests(params); +await runner.stop(); +``` + +Do not expose `WorkerHost` itself. + +## Proposed Generic `runTests` Parameters + +The run parameters should use Playwright concepts and avoid Endform-specific names: + +```ts +type RunTestsParams = { + configLocation: ConfigLocation; + config: Config; + configOverrides?: ConfigCLIOverrides; + ignoreProjectDependencies?: boolean; + projectFilter?: string[]; + testSelection?: StructuredTestSelection; + reporter?: Reporter | Reporter[]; + disableConfigReporters?: boolean; + preforkedWorkers?: PreforkedWorkers; + workerEnv?: Record; + metadata?: Record; +}; +``` + +Notes: + +- `config` is the raw user config object, possibly mutated by the caller before `runTests` receives it. +- `configOverrides` should map to the existing `ConfigCLIOverrides` structure where possible. +- `ignoreProjectDependencies` is the programmatic equivalent of `--no-deps`. +- `reporter` should allow the caller to provide additional in-process reporters without requiring them to exist in `config.reporter`. +- `disableConfigReporters` is useful for tests, but Endform will usually build the desired config reporter list itself and pass it in `config.reporter`. +- `preforkedWorkers` should be an opaque handle, not an array of internal `WorkerHost` instances. + +## Structured Test Selection + +Endform should move away from generating a `--test-list` file. The Node host should send an in-memory structured test selection to Playwright. + +Use whichever shape best matches Playwright's existing test-list implementation and title filtering model. A likely shape is: + +```ts +type StructuredTestSelection = { + tests: StructuredSelectedTest[]; +}; + +type StructuredSelectedTest = { + projectName?: string; + file: string; + titlePath: string[]; +}; +``` + +For Endform, `titlePath` should be the test's describe path plus case name: + +```ts +{ + projectName: request.projectName, + file: test.fileName, + titlePath: [...test.describes, test.caseName], +} +``` + +The Playwright implementation should internally convert this to the same filtering behavior as the existing test-list code: + +- Filter files to only the selected files. +- Filter tests by project and title path. +- Preserve behavior for duplicate titles as strictly as Playwright's current test-list behavior allows. +- Surface useful errors for selected tests that are not found. + +This should live as a generic in-memory sibling of the current `testList` file support, not as an Endform-specific selector. + +Potential implementation path: + +1. Keep existing `loadTestList` file parsing unchanged. +2. Add a new helper that builds the same `{ testFilter, fileFilter }` pair from structured entries. +3. Add `structuredTestSelection?: StructuredTestSelection` to `TestRunOptions`. +4. Apply it in `createLoadTask` at the same point as `testList`. +5. Thread it through the new exported `runTests` API. + +## Preforked Worker Support + +The current smoke implementation proved this internal flow: + +```ts +const worker = new WorkerHost(0); +await worker.prefork(); + +await testRunner.runTests(reporter, { + locations: [selectedTestFile], + projects: [selectedProjectName], + preforkedWorkers: [worker], + workerEnv, +}); +``` + +The maintained implementation should hide this behind an opaque prefork handle: + +```ts +const preforkedWorkers = await createPreforkedWorkers({ workers: 1 }); + +await runTests({ + configLocation, + config, + preforkedWorkers, + testSelection, +}); +``` + +Required internal Playwright changes remain: + +- Split process startup from runner initialization in `ProcessHost`. +- Allow `WorkerHost` to start without a `TestGroup` and initialize later. +- Allow `Dispatcher` to consume preforked delayed-init workers. +- Ensure preforked workers are leased safely across phases and not reused incompatibly. + +Avoid leaking these classes across the public export boundary. + +## Reporter Pipeline Ownership + +Reporter pipeline construction should stay in the Endform Node host. + +The Playwright fork should only need to support normal Playwright reporter declarations and optional direct reporter objects passed to `runTests`. Endform can continue to generate reporter modules on disk where that is the lowest-risk approach. + +The Endform Node host owns logic such as: + +- Preserve selected user reporters from the original user config. +- Drop unapproved user reporters. +- Inject a synchronous test-property reporter before blob reporter. +- Inject observer reporters that write events to the Rust-side reporter runtime transport. +- Inject `playwright-opentelemetry` with options when configured. +- Inject blob reporter for completion assets. + +This can be implemented by setting the mutated raw config's `reporter` field before calling Playwright: + +```ts +config.reporter = [ + [testPropertyReporterPath], + ...preservedUserReporters, + [testOutcomeObserverReporterPath], + [traceObserverReporterPath], + ...maybeOtelReporter, + ['blob'], +]; +``` + +The generated observer reporter transport can continue using file descriptor `3` initially. The Endform Node host, not the Playwright fork, should be responsible for opening/owning whatever transport is used between reporter JS and Rust or between reporter JS and the Node host. + +## Chrome Flags And Process Monkey Patching + +Do not include Chrome flag support in the Playwright fork API for this phase. + +Endform can continue using its current generated `NODE_OPTIONS` scripts to monkey patch: + +- `child_process.spawn` for Chromium launch flags. +- `child_process.fork` for propagation into Playwright worker processes. +- `worker_threads.Worker` for propagation into worker threads. + +This keeps the fork focused on runner orchestration and avoids opening another browser-launch-specific API surface before measurement proves it is needed. + +## Endform Node Host Responsibilities + +The Lambda Runner repository should add a Node host script/package that imports the Playwright fork export. + +Responsibilities: + +- Start immediately when Rust receives an invocation. +- Import Playwright and create preforked workers before scratch setup is complete. +- Expose a simple Rust-to-Node IPC protocol, likely over stdio or a Unix domain socket. +- Receive structured run parameters from Rust. +- Load the raw user Playwright config through the Playwright export. +- Mutate the config according to Endform policy. +- Generate any Endform reporter modules needed for the run. +- Build the final Playwright reporter list. +- Build structured test selection from Endform test attempt data. +- Call the Playwright fork's generic `runTests` export. +- Return status, timing data, stdout/stderr if captured, and any structured failure data Rust needs. +- Stop preforked workers and cleanup host resources on cancellation or invocation completion. + +This keeps rapidly changing Endform behavior in the Endform repository and keeps the fork as a lower-level runner API provider. + +## Rust Lambda Runner Changes + +The Rust controller should be reorganized so process execution can be backed either by the existing CLI path or by the new Node host path. + +Suggested phases: + +1. Start Node host early. +2. Run existing middleware setup. +3. Instead of collecting CLI args/config shim, collect structured run inputs for the Node host. +4. Send a `runTests` IPC request to the Node host. +5. Wait for completion/cancellation. +6. Run existing middleware teardown. +7. Keep the CLI backend as a fallback until parity is proven. + +The current `PlaywrightConfigMiddleware` should eventually split into more explicit responsibilities: + +- Lambda browser/runtime defaults. +- Endform config policy inputs. +- Test selection construction. +- Programmatic run options. +- Legacy CLI arg generation for fallback only. + +## Cancellation And Failure Handling + +The Node host should support cancellation explicitly: + +```text +Rust -> Node: cancel current run +Node -> Playwright: stop current run +Node -> Rust: cancelled/finished +``` + +The host must also handle: + +- Preforked worker exits before run starts. +- Worker crashes during initialization. +- Selected test not found. +- Config load errors. +- Reporter startup errors. +- Observer transport failure. +- Rust disconnecting or killing the invocation. + +For safety, the first implementation can fall back to starting a fresh worker when a preforked worker is unavailable or incompatible. + +## Measurement + +Add timings at the Playwright host level and return them to Rust: + +- host process start to ready +- Playwright import complete +- worker prefork start to ready +- run request received +- user config load +- Endform config mutation time, measured in the Endform Node host +- config normalization +- test collection/filtering +- runner-side test file load +- worker initialization +- worker-side test file load +- test execution +- reporter finalization +- total run time + +Only after these timings are available should we decide whether to optimize discovery, avoid double test-file load, or prewarm browsers. + +## Implementation Order + +1. Add the new Playwright package export with no Endform-specific types. +2. Export `loadUserConfig` or equivalent raw config loading helper. +3. Export generic `runTests({ configLocation, config, ... })` that can run from an already-loaded raw config object. +4. Add structured in-memory test selection and wire it into the same filtering phase as current test-list support. +5. Hide prefork support behind an opaque `PreforkedWorkers` handle. +6. Update the existing smoke test to consume the new export instead of importing `playwright/lib/runner` internals. +7. Add Playwright-side tests for config-object execution, structured test selection, preforked worker execution, late worker env, reporter lifecycle, attachments, stdout/stderr attribution, and selected-test-not-found behavior. +8. Add the Endform Node host in the Lambda Runner repository. +9. Implement config mutation and reporter pipeline construction inside the Endform Node host. +10. Add a Rust execution backend flag to switch between legacy CLI and new host backend. +11. Add parity tests comparing legacy CLI/shim output with the new host backend. +12. Enable the new backend gradually and retain CLI fallback until production metrics are stable. + +## Maintained Fork Strategy + +Keep the fork patch set focused on generic seams: + +- Programmatic `runTests` from a raw config object. +- Structured in-memory test selection. +- Process fork/init split. +- Delayed worker initialization. +- Opaque preforked worker pool consumed by dispatcher. + +Avoid placing these in the fork: + +- Endform config mutation policy. +- Endform reporter pipeline semantics. +- Rust-to-Node IPC protocol. +- Suite-control reporting details. +- Completion asset and trace upload details. +- Chrome flag policy. + +Maintain one fork branch per supported Playwright version line, with this file as the intended architecture reference. Each branch should carry the smallest possible version-specific adaptation of the same generic public export. + +## Success Criteria + +- Lambda Runner can start a Node host and prefork a worker before dependency setup completes. +- Endform can run the selected tests without invoking the Playwright CLI. +- Endform can run without modifying the user's Playwright config file on disk. +- Endform can select tests through structured in-memory data, not a test-list file. +- Existing reporter lifecycle remains intact: `onConfigure`, `onBegin`, `onTestBegin`, step hooks, stdio attribution, attachments, `onTestEnd`, `onError`, `onEnd`, and `onExit`. +- Endform-specific config and reporter behavior can evolve in the Lambda Runner repository without changing the Playwright fork API. +- The Playwright fork surface is small enough to rebase across Playwright versions with predictable conflicts. diff --git a/packages/playwright/package.json b/packages/playwright/package.json index ab52c2b8dfa7f..7a8ce1c833ee7 100644 --- a/packages/playwright/package.json +++ b/packages/playwright/package.json @@ -19,6 +19,12 @@ "default": "./index.js" }, "./package.json": "./package.json", + "./programmatic-runner": { + "types": "./programmatic-runner.d.ts", + "import": "./programmatic-runner.mjs", + "require": "./programmatic-runner.js", + "default": "./programmatic-runner.js" + }, "./lib/common": "./lib/common/index.js", "./lib/fsWatcher": "./lib/fsWatcher.js", "./lib/mcp/index": "./lib/mcp/index.js", diff --git a/packages/playwright/programmatic-runner.d.ts b/packages/playwright/programmatic-runner.d.ts new file mode 100644 index 0000000000000..bddbc671d7936 --- /dev/null +++ b/packages/playwright/programmatic-runner.d.ts @@ -0,0 +1,82 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Config, FullConfig, ReporterDescription, TestProject } from './types/test'; +import type { FullResult, Reporter } from './types/testReporter'; + +export type ConfigLocation = string | { + resolvedConfigFile?: string; + configDir: string; +}; + +export type ProgrammaticConfigOverrides = { + forbidOnly?: boolean; + fullyParallel?: boolean; + globalTimeout?: number; + maxFailures?: number; + outputDir?: string; + quiet?: boolean; + repeatEach?: number; + reporter?: ReporterDescription[]; + retries?: number; + shard?: FullConfig['shard']; + timeout?: number; + tsconfig?: string; + updateSnapshots?: 'all' | 'changed' | 'missing' | 'none'; + updateSourceMethod?: 'overwrite' | 'patch' | '3way'; + use?: TestProject['use']; + workers?: number | string; + metadata?: Config['metadata']; +}; + +export type StructuredTestSelection = { + tests: StructuredSelectedTest[]; +}; + +export type StructuredSelectedTest = { + projectName?: string; + file: string; + titlePath?: string[]; +}; + +export type PreforkedWorkers = unknown; + +export type RunTestsParams = { + configLocation: ConfigLocation; + config?: Config; + configOverrides?: ProgrammaticConfigOverrides; + ignoreProjectDependencies?: boolean; + projectFilter?: string[]; + locations?: string[]; + grep?: string; + grepInvert?: string; + testSelection?: StructuredTestSelection; + reporter?: Reporter | Reporter[]; + disableConfigReporters?: boolean; + preforkedWorkers?: PreforkedWorkers; + workerEnv?: Record; + metadata?: Config['metadata']; + passWithNoTests?: boolean; +}; + +export type RunTestsResult = { + status: FullResult['status']; +}; + +export function loadUserConfig(location: ConfigLocation): Promise; +export function runTests(params: RunTestsParams): Promise; +export function createPreforkedWorkers(params: { workers: number }): Promise; +export function disposePreforkedWorkers(workers: PreforkedWorkers): Promise; diff --git a/packages/playwright/programmatic-runner.js b/packages/playwright/programmatic-runner.js new file mode 100644 index 0000000000000..ec62430d35eda --- /dev/null +++ b/packages/playwright/programmatic-runner.js @@ -0,0 +1,17 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +module.exports = require('./lib/programmaticRunner'); diff --git a/packages/playwright/programmatic-runner.mjs b/packages/playwright/programmatic-runner.mjs new file mode 100644 index 0000000000000..40f0cbf416943 --- /dev/null +++ b/packages/playwright/programmatic-runner.mjs @@ -0,0 +1,22 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import programmaticRunner from './programmatic-runner.js'; + +export const loadUserConfig = programmaticRunner.loadUserConfig; +export const runTests = programmaticRunner.runTests; +export const createPreforkedWorkers = programmaticRunner.createPreforkedWorkers; +export const disposePreforkedWorkers = programmaticRunner.disposePreforkedWorkers; diff --git a/packages/playwright/src/DEPS.list b/packages/playwright/src/DEPS.list index a67d9689d0619..be6b095535bbf 100644 --- a/packages/playwright/src/DEPS.list +++ b/packages/playwright/src/DEPS.list @@ -10,6 +10,9 @@ node_modules/minimatch [program.ts] ** +[programmaticRunner.ts] +** + [testActions.ts] ** diff --git a/packages/playwright/src/common/configLoader.ts b/packages/playwright/src/common/configLoader.ts index 166313e06342b..10d6764ddbc16 100644 --- a/packages/playwright/src/common/configLoader.ts +++ b/packages/playwright/src/common/configLoader.ts @@ -93,7 +93,8 @@ export async function deserializeConfig(data: SerializedConfig): Promise { +export async function loadUserConfig(location: ConfigLocation, overrides?: ConfigCLIOverrides): Promise { + await setSingleTSConfig(overrides?.tsconfig); let object = location.resolvedConfigFile ? await requireOrImport(location.resolvedConfigFile) : {}; if (object && typeof object === 'object' && ('default' in object)) object = object['default']; @@ -115,6 +116,10 @@ export async function loadConfig(location: ConfigLocation, overrides?: ConfigCLI // 2. Load and validate playwright config. const userConfig = await loadUserConfig(location); + return await loadConfigFromObject(location, userConfig, overrides, ignoreProjectDependencies, metadata); +} + +export async function loadConfigFromObject(location: ConfigLocation, userConfig: Config, overrides?: ConfigCLIOverrides, ignoreProjectDependencies = false, metadata?: Config['metadata']): Promise { validateConfig(location.resolvedConfigFile || '', userConfig); const fullConfig = new FullConfigInternal(location, userConfig, overrides || {}, metadata); fullConfig.defineConfigWasUsed = !!(userConfig as any)[kDefineConfigWasUsed]; diff --git a/packages/playwright/src/common/ipc.ts b/packages/playwright/src/common/ipc.ts index 93c33005817c4..c4f045af60bd8 100644 --- a/packages/playwright/src/common/ipc.ts +++ b/packages/playwright/src/common/ipc.ts @@ -65,6 +65,7 @@ export type WorkerInitParams = { projectId: string; config: SerializedConfig; artifactsDir: string; + extraEnv?: Record; pauseOnError: boolean; pauseAtEnd: boolean; }; diff --git a/packages/playwright/src/programmaticRunner.ts b/packages/playwright/src/programmaticRunner.ts new file mode 100644 index 0000000000000..564ab0207b64e --- /dev/null +++ b/packages/playwright/src/programmaticRunner.ts @@ -0,0 +1,108 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { configLoader, ipc } from './common'; +import { testRunner, workerHost } from './runner'; + +import type { ConfigLocation } from './common'; +import type { StructuredTestSelection } from './runner/loadUtils'; +import type { AnyReporter } from './reporters/reporterV2'; +import type { Config } from '../types/test'; +import type { FullResult } from '../types/testReporter'; + +type ConfigLocationInput = string | ConfigLocation; + +type RunTestsParams = { + configLocation: ConfigLocationInput; + config?: Config; + configOverrides?: ipc.ConfigCLIOverrides; + ignoreProjectDependencies?: boolean; + projectFilter?: string[]; + locations?: string[]; + grep?: string; + grepInvert?: string; + testSelection?: StructuredTestSelection; + reporter?: AnyReporter | AnyReporter[]; + disableConfigReporters?: boolean; + preforkedWorkers?: PreforkedWorkers; + workerEnv?: Record; + metadata?: Config['metadata']; + passWithNoTests?: boolean; +}; + +type RunTestsResult = { + status: FullResult['status']; +}; + +export class PreforkedWorkers { + readonly workers: workerHost.WorkerHost[]; + + constructor(workers: workerHost.WorkerHost[]) { + this.workers = workers; + } +} + +export async function loadUserConfig(location: ConfigLocationInput): Promise { + return await configLoader.loadUserConfig(resolveLocation(location)); +} + +export async function runTests(params: RunTestsParams): Promise { + const location = resolveLocation(params.configLocation); + const userConfig = params.config || await configLoader.loadUserConfig(location, params.configOverrides); + const metadata = params.metadata ?? (params.configOverrides as any)?.metadata; + const config = await configLoader.loadConfigFromObject(location, userConfig, params.configOverrides, params.ignoreProjectDependencies, metadata); + const reporters = params.reporter ? Array.isArray(params.reporter) ? params.reporter : [params.reporter] : []; + const status = await testRunner.runAllTestsWithConfig(config, { + locations: params.locations, + grep: params.grep, + grepInvert: params.grepInvert, + projectFilter: params.projectFilter, + testSelection: params.testSelection, + passWithNoTests: params.passWithNoTests, + additionalReporterObjects: reporters, + disableConfigReporters: params.disableConfigReporters, + preforkedWorkers: params.preforkedWorkers?.workers, + workerEnv: params.workerEnv, + }); + return { status }; +} + +export async function createPreforkedWorkers(params: { workers: number }): Promise { + const workers: workerHost.WorkerHost[] = []; + try { + for (let i = 0; i < params.workers; i++) { + const worker = new workerHost.WorkerHost(i); + workers.push(worker); + const error = await worker.prefork(); + if (error) + throw new Error(`Worker process exited before it was ready (code=${error.code}, signal=${error.signal})`); + } + } catch (e) { + await Promise.all(workers.map(worker => worker.stop().catch(() => {}))); + throw e; + } + return new PreforkedWorkers(workers); +} + +export async function disposePreforkedWorkers(workers: PreforkedWorkers): Promise { + await Promise.all(workers.workers.map(worker => worker.stop().catch(() => {}))); +} + +function resolveLocation(location: ConfigLocationInput): ConfigLocation { + if (typeof location === 'string') + return configLoader.resolveConfigLocation(location); + return location; +} diff --git a/packages/playwright/src/runner/dispatcher.ts b/packages/playwright/src/runner/dispatcher.ts index 29f4f4e84eb1a..10e98fbfc9e20 100644 --- a/packages/playwright/src/runner/dispatcher.ts +++ b/packages/playwright/src/runner/dispatcher.ts @@ -46,8 +46,9 @@ export class Dispatcher { private _extraEnvByProjectId: EnvByProjectId = new Map(); private _producedEnvByProjectId: EnvByProjectId = new Map(); - constructor(testRun: TestRun) { + constructor(testRun: TestRun, preforkedWorkers: WorkerHost[] = []) { this._testRun = testRun; + this._workerSlots = preforkedWorkers.map(worker => ({ worker })); for (const project of testRun.config.projects) { if (project.workers) this._workerLimitPerProjectId.set(project.id, project.workers); @@ -117,7 +118,7 @@ export class Dispatcher { let worker = this._workerSlots[index].worker; // 1. Restart the worker if it has the wrong hash or is being stopped already. - if (worker && (worker.hash() !== job.workerHash || worker.didSendStop())) { + if (worker && (worker.didSendStop() || (worker.isInitialized() && worker.hash() !== job.workerHash))) { await worker.stop(); worker = undefined; if (this._isStopped) // Check stopped signal after async hop. @@ -133,6 +134,12 @@ export class Dispatcher { startError = await worker.start(); if (this._isStopped) // Check stopped signal after async hop. return; + } else if (!worker.isInitialized()) { + this._initializeWorker(worker, job, index, ipc.serializeConfig(this._testRun.config, true)); + worker.on('exit', () => this._workerSlots[index].worker = undefined); + startError = await worker.start(); + if (this._isStopped) // Check stopped signal after async hop. + return; } // 3. Finally, run some tests in the worker! Or fail all of them because of startup error... @@ -173,12 +180,15 @@ export class Dispatcher { } private _isWorkerRedundant(worker: WorkerHost) { + const hash = worker.hash(); + if (!hash) + return false; let workersWithSameHash = 0; for (const slot of this._workerSlots) { - if (slot.worker && !slot.worker.didSendStop() && slot.worker.hash() === worker.hash()) + if (slot.worker && !slot.worker.didSendStop() && slot.worker.hash() === hash) workersWithSameHash++; } - return workersWithSameHash > this._queuedOrRunningHashCount.get(worker.hash())!; + return workersWithSameHash > this._queuedOrRunningHashCount.get(hash)!; } private _updateCounterForWorkerHash(hash: string, delta: number) { @@ -191,12 +201,15 @@ export class Dispatcher { for (const group of testGroups) this._updateCounterForWorkerHash(group.workerHash, +1); this._isStopped = false; - this._workerSlots = []; + const preforkedSlots = this._workerSlots + .filter(slot => slot.worker && !slot.worker.isInitialized() && !slot.worker.didSendStop()) + .slice(0, this._testRun.config.config.workers); + this._workerSlots = preforkedSlots; // 0. Stop right away if we have reached max failures. if (this._testRun.hasReachedMaxFailures()) void this.stop(); // 1. Allocate workers. - for (let i = 0; i < this._testRun.config.config.workers; i++) + for (let i = this._workerSlots.length; i < this._testRun.config.config.workers; i++) this._workerSlots.push({}); // 2. Schedule enough jobs. for (let i = 0; i < this._workerSlots.length; i++) @@ -207,13 +220,14 @@ export class Dispatcher { await this._finished; } - _createWorker(testGroup: TestGroup, parallelIndex: number, loaderData: ipc.SerializedConfig) { + _initializeWorker(worker: WorkerHost, testGroup: TestGroup, parallelIndex: number, loaderData: ipc.SerializedConfig) { const project = this._testRun.config.projects.find(p => p.id === testGroup.projectId)!; const pauseAtEnd = this._testRun.topLevelProjects.includes(project) && !!this._testRun.options.pauseAtEnd; - const worker = new WorkerHost(testGroup, { + worker.initialize({ + testGroup, parallelIndex, config: loaderData, - extraEnv: this._extraEnvByProjectId.get(testGroup.projectId) || {}, + extraEnv: { ...this._testRun.options.workerEnv, ...this._extraEnvByProjectId.get(testGroup.projectId) }, outputDir: project.project.outputDir, pauseOnError: !!this._testRun.options.pauseOnError, pauseAtEnd, @@ -260,6 +274,11 @@ export class Dispatcher { const producedEnv = this._producedEnvByProjectId.get(testGroup.projectId) || {}; this._producedEnvByProjectId.set(testGroup.projectId, { ...producedEnv, ...worker.producedEnv() }); }); + } + + _createWorker(testGroup: TestGroup, parallelIndex: number, loaderData: ipc.SerializedConfig) { + const worker = new WorkerHost(parallelIndex); + this._initializeWorker(worker, testGroup, parallelIndex, loaderData); return worker; } diff --git a/packages/playwright/src/runner/index.ts b/packages/playwright/src/runner/index.ts index deca5ea33718e..9881886fba753 100644 --- a/packages/playwright/src/runner/index.ts +++ b/packages/playwright/src/runner/index.ts @@ -20,6 +20,7 @@ // imports. export * as testRunner from './testRunner'; +export * as workerHost from './workerHost'; export * as testServer from './testServer'; export * as watchMode from './watchMode'; export * as projectUtils from './projectUtils'; diff --git a/packages/playwright/src/runner/loadUtils.ts b/packages/playwright/src/runner/loadUtils.ts index a2cce3b386459..e8d4d22436645 100644 --- a/packages/playwright/src/runner/loadUtils.ts +++ b/packages/playwright/src/runner/loadUtils.ts @@ -32,6 +32,22 @@ import type { TestGroup } from './testGroups'; import type { FullConfig, Reporter, TestError } from '../../types/testReporter'; import type { Matcher, TestCaseFilter } from '../util'; +export type StructuredTestSelection = { + tests: StructuredSelectedTest[]; +}; + +export type StructuredSelectedTest = { + projectName?: string; + file: string; + titlePath?: string[]; +}; + +type TestDescription = { + project?: string; + file: string; + titlePath: string[]; +}; + export async function collectProjectsAndTestFiles(testRun: TestRun, doNotRunTestsOutsideProjectFilter: boolean) { const fsCache = new Map(); @@ -347,22 +363,67 @@ export async function loadTestList(config: FullConfigInternal, filePath: string) } return { project, file: toPosixPath(parseLocationArg(tokens[0]).file), titlePath: tokens.slice(1) }; }); - const testFilter = (test: testNs.TestCase) => descriptions.some(d => { - // Note: there is no root yet at the time of filtering. - const [projectName, , ...titles] = test.titlePath(); - if (d.project !== undefined && d.project !== projectName) - return false; - const relativeFile = toPosixPath(path.relative(config.config.rootDir, test.location.file)); - if (relativeFile !== d.file) - return false; - return d.titlePath.length <= titles.length && d.titlePath.every((_, index) => titles[index] === d.titlePath[index]); - }); - const fileFilter = (file: string) => { - const relativeFile = toPosixPath(path.relative(config.config.rootDir, file)); - return descriptions.some(d => d.file === relativeFile); - }; - return { testFilter, fileFilter }; + return createFilters(config, descriptions).filters; } catch (e) { throw errorWithFile(filePath, 'Cannot read test list file: ' + e.message); } } + +export function createStructuredTestSelectionFilters(config: FullConfigInternal, selection: StructuredTestSelection): { testFilter: TestCaseFilter, fileFilter: Matcher, unmatchedErrors: () => TestError[] } { + const descriptions = selection.tests.map(test => ({ + project: test.projectName, + file: normalizeSelectedFile(config, test.file), + titlePath: test.titlePath || [], + })); + const { filters, unmatchedErrors } = createFilters(config, descriptions); + return { ...filters, unmatchedErrors }; +} + +function createFilters(config: FullConfigInternal, descriptions: TestDescription[]): { filters: { testFilter: TestCaseFilter, fileFilter: Matcher }, unmatchedErrors: () => TestError[] } { + const matched = new Array(descriptions.length).fill(false); + const testFilter = (test: testNs.TestCase) => descriptions.some((d, index) => { + // Note: there is no root yet at the time of filtering. + const [projectName, , ...titles] = test.titlePath(); + if (d.project !== undefined && d.project !== projectName) + return false; + if (!matchesSelectedFile(config, test.location.file, d.file)) + return false; + const result = d.titlePath.length <= titles.length && d.titlePath.every((_, index) => titles[index] === d.titlePath[index]); + matched[index] ||= result; + return result; + }); + const fileFilter = (file: string) => { + return descriptions.some(d => matchesSelectedFile(config, file, d.file)); + }; + const unmatchedErrors = () => descriptions.filter((_, index) => !matched[index]).map(d => ({ message: `Error: selected test not found: ${formatTestDescription(d)}` })); + return { filters: { testFilter, fileFilter }, unmatchedErrors }; +} + +function normalizeSelectedFile(config: FullConfigInternal, file: string): string { + if (path.isAbsolute(file)) + return toPosixPath(path.relative(config.config.rootDir, file)); + return toPosixPath(parseLocationArg(file).file); +} + +function matchesSelectedFile(config: FullConfigInternal, file: string, selectedFile: string): boolean { + const relativeFile = toPosixPath(path.relative(config.config.rootDir, file)); + if (relativeFile === selectedFile) + return true; + const absoluteFile = path.resolve(file); + const absoluteSelectedFile = path.resolve(config.config.rootDir, selectedFile); + return toPosixPath(absoluteFile) === toPosixPath(absoluteSelectedFile) || toPosixPath(realpath(absoluteFile)) === toPosixPath(realpath(absoluteSelectedFile)); +} + +function realpath(file: string): string { + try { + return fs.realpathSync(file); + } catch { + return file; + } +} + +function formatTestDescription(description: TestDescription): string { + const tokens = [description.file, ...description.titlePath]; + const prefix = description.project === undefined ? '' : `[${description.project}] › `; + return prefix + tokens.join(' › '); +} diff --git a/packages/playwright/src/runner/processHost.ts b/packages/playwright/src/runner/processHost.ts index 5c0257933f8a8..e3d00ea537e57 100644 --- a/packages/playwright/src/runner/processHost.ts +++ b/packages/playwright/src/runner/processHost.ts @@ -32,6 +32,7 @@ export type ProcessExitData = { export class ProcessHost extends EventEmitter { private process: child_process.ChildProcess | undefined; + private _didSendInit = false; private _didSendStop = false; private _processDidExit = false; private _didExitAndRanOnExit = false; @@ -50,7 +51,7 @@ export class ProcessHost extends EventEmitter { this._extraEnv = env; } - async startRunner(runnerParams: any, options: { onStdOut?: (chunk: Buffer | string) => void, onStdErr?: (chunk: Buffer | string) => void } = {}): Promise { + async preforkRunner(options: { onStdOut?: (chunk: Buffer | string) => void, onStdErr?: (chunk: Buffer | string) => void } = {}): Promise { assert(!this.process, 'Internal error: starting the same process twice'); this.process = child_process.fork(this._entryScript, { // Note: we pass detached:false, so that workers are in the same process group. @@ -125,6 +126,12 @@ export class ProcessHost extends EventEmitter { if (error) return error; + } + + initRunner(runnerParams: any) { + assert(this.process, 'Internal error: initializing a process before it starts'); + assert(!this._didSendInit, 'Internal error: initializing the same process twice'); + this._didSendInit = true; const processParams: ipc.ProcessInitParams = { processName: this._processName, @@ -139,6 +146,17 @@ export class ProcessHost extends EventEmitter { }); } + async startRunner(runnerParams: any, options: { onStdOut?: (chunk: Buffer | string) => void, onStdErr?: (chunk: Buffer | string) => void } = {}): Promise { + const error = await this.preforkRunner(options); + if (error) + return error; + this.initRunner(runnerParams); + } + + hasProcess() { + return !!this.process; + } + sendMessage(message: { method: string, params?: any }) { const id = ++this._lastMessageId; this.send({ diff --git a/packages/playwright/src/runner/tasks.ts b/packages/playwright/src/runner/tasks.ts index 6edd4e887c2b0..77397fcc7b1de 100644 --- a/packages/playwright/src/runner/tasks.ts +++ b/packages/playwright/src/runner/tasks.ts @@ -23,8 +23,8 @@ import { ManualPromise } from '@isomorphic/manualPromise'; import { monotonicTime } from '@isomorphic/time'; import { removeFolders } from '@utils/fileUtils'; -import { Dispatcher } from './dispatcher'; -import { collectProjectsAndTestFiles, createRootSuite, loadFileSuites, loadGlobalHook, loadTestList } from './loadUtils'; +import { Dispatcher } from './dispatcher'; +import { collectProjectsAndTestFiles, createRootSuite, createStructuredTestSelectionFilters, loadFileSuites, loadGlobalHook, loadTestList } from './loadUtils'; import { buildDependentProjects, buildTeardownToSetupsMap, filterProjects } from './projectUtils'; import { applySuggestedRebaselines, clearSuggestedRebaselines } from './rebase'; import { TaskRunner } from './taskRunner'; @@ -35,12 +35,15 @@ import { createTitleMatcher, forceRegExp, removeDirAndLogToConsole } from '../ut import type { TestGroup } from '../runner/testGroups'; import type { EnvByProjectId } from './dispatcher'; +import type { StructuredTestSelection } from './loadUtils'; +import type { WorkerHost } from './workerHost'; import type { TestRunnerPluginRegistration } from '../plugins'; import type { Task } from './taskRunner'; import type { ReporterDescription } from '../../types/test'; import type { FullResult, TestError } from '../../types/testReporter'; import type { Matcher, TestCaseFilter } from '../util'; import type { InternalReporter } from '../reporters/internalReporter'; +import type { AnyReporter } from '../reporters/reporterV2'; const readDirAsync = promisify(fs.readdir); @@ -66,13 +69,18 @@ export type TestRunOptions = { lastFailed?: boolean; testList?: string; testListInvert?: string; + testSelection?: StructuredTestSelection; lastFailedTestIds?: string[]; pauseOnError?: boolean; pauseAtEnd?: boolean; onTestPaused?: (params: TestPausedParams) => void; preserveOutputDir?: boolean; additionalReporters?: ReporterDescription[]; + additionalReporterObjects?: AnyReporter[]; + disableConfigReporters?: boolean; shardWeights?: number[]; + preforkedWorkers?: WorkerHost[]; + workerEnv?: Record; }; export type TestPausedParams = { @@ -301,12 +309,20 @@ export function createLoadTask(mode: 'out-of-process' | 'in-process', options: { return { title: 'load tests', setup: async (testRun, errors, softErrors) => { + let unmatchedSelectionErrors: (() => TestError[]) | undefined; if (testRun.options.locations?.length) { const { testFilter, fileFilter } = suiteUtils.createFiltersFromArguments(testRun.options.locations); testRun.loadFileFilters.push(fileFilter); testRun.preOnlyTestFilters.push(testFilter); } + if (testRun.options.testSelection) { + const { testFilter, fileFilter, unmatchedErrors } = createStructuredTestSelectionFilters(testRun.config, testRun.options.testSelection); + testRun.preOnlyTestFilters.push(testFilter); + testRun.loadFileFilters.push(fileFilter); + unmatchedSelectionErrors = unmatchedErrors; + } + if (testRun.options.testList) { const { testFilter, fileFilter } = await loadTestList(testRun.config, testRun.options.testList); testRun.preOnlyTestFilters.push(testFilter); @@ -350,11 +366,13 @@ export function createLoadTask(mode: 'out-of-process' | 'in-process', options: { } await createRootSuite(testRun, options.failOnLoadErrors ? errors : softErrors, !!options.filterOnly); + if (unmatchedSelectionErrors) + (options.failOnLoadErrors ? errors : softErrors).push(...unmatchedSelectionErrors()); // Fail when no tests. if (options.failOnLoadErrors && !testRun.rootSuite?.allTests().length && !testRun.options.passWithNoTests && !testRun.config.config.shard && !testRun.options.onlyChanged - && !testRun.options.testList && !testRun.options.testListInvert) { + && !testRun.options.testList && !testRun.options.testListInvert && !testRun.options.testSelection) { if (testRun.options.locations?.length) { throw new Error([ `No tests found.`, @@ -414,7 +432,7 @@ function createPhasesTask(): Task { processed.add(project); if (phaseProjects.length) { let testGroupsInPhase = 0; - const phase: Phase = { dispatcher: new Dispatcher(testRun), projects: [] }; + const phase: Phase = { dispatcher: new Dispatcher(testRun, testRun.options.preforkedWorkers), projects: [] }; testRun.phases.push(phase); for (const project of phaseProjects) { const projectSuite = projectToSuite.get(project)!; diff --git a/packages/playwright/src/runner/testRunner.ts b/packages/playwright/src/runner/testRunner.ts index 3d4a74d499e1b..3a1bb02a4dd22 100644 --- a/packages/playwright/src/runner/testRunner.ts +++ b/packages/playwright/src/runner/testRunner.ts @@ -443,7 +443,7 @@ export async function runAllTestsWithConfig(config: FullConfigInternal, options: webServerPluginsForConfig(config).forEach(p => config.plugins.push({ factory: p })); const filteredProjects = filterProjects(config.projects, options.projectFilter); - const reporters = await createReporters(config, options.listMode ? 'list' : 'test', undefined, options); + const reporters = options.disableConfigReporters ? [] : await createReporters(config, options.listMode ? 'list' : 'test', undefined, options); const lastRun = new LastRunReporter(filteredProjects, options.listMode); if (options.lastFailed) { const lastFailedTestIds = await lastRun.filterLastFailed(); @@ -451,7 +451,7 @@ export async function runAllTestsWithConfig(config: FullConfigInternal, options: options = { ...options, lastFailedTestIds }; } - const reporter = new InternalReporter([...reporters, lastRun]); + const reporter = new InternalReporter([...reporters, ...(options.additionalReporterObjects || []), lastRun]); const tasks = options.listMode ? [ createLoadTask('in-process', { failOnLoadErrors: true, filterOnly: false }), createReportBeginTask(), diff --git a/packages/playwright/src/runner/workerHost.ts b/packages/playwright/src/runner/workerHost.ts index 2e1781c2d0762..3b9d6236a7f2a 100644 --- a/packages/playwright/src/runner/workerHost.ts +++ b/packages/playwright/src/runner/workerHost.ts @@ -17,6 +17,7 @@ import fs from 'fs'; import path from 'path'; +import { assert } from '@isomorphic/assert'; import { removeFolders } from '@utils/fileUtils'; import { ProcessHost } from './processHost'; @@ -37,46 +38,81 @@ type WorkerHostOptions = { pauseAtEnd: boolean; }; +type WorkerHostInitOptions = WorkerHostOptions & { + testGroup: TestGroup; +}; + export class WorkerHost extends ProcessHost { - readonly parallelIndex: number; readonly workerIndex: number; - private _hash: string; - private _params: ipc.WorkerInitParams; + private _parallelIndex: number; + private _hash: string | undefined; + private _params: ipc.WorkerInitParams | undefined; private _didFail = false; - constructor(testGroup: TestGroup, options: WorkerHostOptions) { + constructor(testGroup: TestGroup, options: WorkerHostOptions); + constructor(parallelIndex: number); + constructor(testGroupOrParallelIndex: TestGroup | number, options?: WorkerHostOptions) { const workerIndex = lastWorkerIndex++; + const extraEnv = options?.extraEnv || {}; super(require.resolve('../worker/workerProcessEntry.js'), `worker-${workerIndex}`, { - ...options.extraEnv, + ...extraEnv, FORCE_COLOR: '1', DEBUG_COLORS: process.env.DEBUG_COLORS === undefined ? '1' : process.env.DEBUG_COLORS, }); this.workerIndex = workerIndex; - this.parallelIndex = options.parallelIndex; - this._hash = testGroup.workerHash; + this._parallelIndex = typeof testGroupOrParallelIndex === 'number' ? testGroupOrParallelIndex : options!.parallelIndex; + + if (typeof testGroupOrParallelIndex !== 'number') + this.initialize({ ...options!, testGroup: testGroupOrParallelIndex }); + } + + get parallelIndex() { + return this._parallelIndex; + } + + initialize(options: WorkerHostInitOptions) { + if (this._params) + return; + + this._parallelIndex = options.parallelIndex; + this._hash = options.testGroup.workerHash; this._params = { workerIndex: this.workerIndex, parallelIndex: options.parallelIndex, - repeatEachIndex: testGroup.repeatEachIndex, - projectId: testGroup.projectId, + repeatEachIndex: options.testGroup.repeatEachIndex, + projectId: options.testGroup.projectId, config: options.config, - artifactsDir: path.join(options.outputDir, artifactsFolderName(workerIndex)), + artifactsDir: path.join(options.outputDir, artifactsFolderName(this.workerIndex)), + extraEnv: options.extraEnv, pauseOnError: options.pauseOnError, pauseAtEnd: options.pauseAtEnd, }; } async start() { + assert(this._params, 'Internal error: starting a worker before it is initialized'); await fs.promises.mkdir(this._params.artifactsDir, { recursive: true }); + if (this.hasProcess()) { + this.initRunner(this._params); + return; + } return await this.startRunner(this._params, { onStdOut: chunk => this.emit('stdOut', ipc.stdioChunkToParams(chunk)), onStdErr: chunk => this.emit('stdErr', ipc.stdioChunkToParams(chunk)), }); } + async prefork() { + return await this.preforkRunner({ + onStdOut: chunk => this.emit('stdOut', ipc.stdioChunkToParams(chunk)), + onStdErr: chunk => this.emit('stdErr', ipc.stdioChunkToParams(chunk)), + }); + } + override async onExit() { - await removeFolders([this._params.artifactsDir]); + if (this._params) + await removeFolders([this._params.artifactsDir]); } override async stop(didFail?: boolean) { @@ -101,8 +137,12 @@ export class WorkerHost extends ProcessHost { return this._hash; } + isInitialized() { + return !!this._params; + } + projectId() { - return this._params.projectId; + return this._params?.projectId; } didFail() { diff --git a/packages/playwright/src/worker/workerMain.ts b/packages/playwright/src/worker/workerMain.ts index 22e8f46451639..409f94c2264c8 100644 --- a/packages/playwright/src/worker/workerMain.ts +++ b/packages/playwright/src/worker/workerMain.ts @@ -64,6 +64,12 @@ export class WorkerMain extends ProcessRunner { constructor(params: ipc.WorkerInitParams) { super(); + for (const [key, value] of Object.entries(params.extraEnv || {})) { + if (value === undefined) + delete process.env[key]; + else + process.env[key] = value; + } process.env.TEST_WORKER_INDEX = String(params.workerIndex); process.env.TEST_PARALLEL_INDEX = String(params.parallelIndex); globals.setIsWorkerProcess(); diff --git a/tests/playwright-test/programmatic-runner.spec.ts b/tests/playwright-test/programmatic-runner.spec.ts new file mode 100644 index 0000000000000..0b95924db8943 --- /dev/null +++ b/tests/playwright-test/programmatic-runner.spec.ts @@ -0,0 +1,31 @@ +/** + * Copyright Microsoft Corporation. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import path from 'path'; + +import { test, expect } from './playwright-test-fixtures'; + +test('programmatic runner public export can run selected test with preforked worker', async ({ childProcess }, testInfo) => { + const repoRoot = path.resolve(__dirname, '..', '..'); + const script = path.join(repoRoot, 'utils', 'endform', 'programmatic-runner-smoke.js'); + const proc = childProcess({ + command: ['node', script, '--playwright-root', repoRoot], + cwd: testInfo.outputPath(), + }); + const { exitCode } = await proc.exited; + expect(proc.output).toContain('PROGRAMMATIC_RUNNER_SMOKE_OK'); + expect(exitCode).toBe(0); +}); diff --git a/utils/endform/programmatic-runner-smoke.js b/utils/endform/programmatic-runner-smoke.js new file mode 100644 index 0000000000000..8e341f158471e --- /dev/null +++ b/utils/endform/programmatic-runner-smoke.js @@ -0,0 +1,249 @@ +#!/usr/bin/env node +/** + * Copyright Microsoft Corporation. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +function parseArgs() { + const result = { playwrightRoot: process.cwd() }; + for (let i = 2; i < process.argv.length; i++) { + const arg = process.argv[i]; + if (arg === '--playwright-root') + result.playwrightRoot = path.resolve(process.argv[++i]); + else if (arg.startsWith('--playwright-root=')) + result.playwrightRoot = path.resolve(arg.substring('--playwright-root='.length)); + else + throw new Error(`Unknown argument: ${arg}`); + } + return result; +} + +function requireFirst(candidates, label) { + const errors = []; + for (const candidate of candidates) { + try { + if (fs.existsSync(candidate)) + return require(candidate); + } catch (e) { + errors.push(`${candidate}: ${e.message}`); + } + } + throw new Error([ + `Could not load ${label}.`, + `Tried:`, + ...candidates.map(candidate => ` ${candidate}`), + `Build Playwright first, or pass --playwright-root to a built patched checkout/package.`, + ...errors.map(error => ` ${error}`), + ].join('\n')); +} + +function publicRunner(root) { + return requireFirst(publicRunnerCandidates(root, '.js'), 'playwright/programmatic-runner'); +} + +function publicRunnerCandidates(root, extension) { + return [ + path.join(root, 'packages', 'playwright', `programmatic-runner${extension}`), + path.join(root, `programmatic-runner${extension}`), + path.join(root, 'node_modules', 'playwright', `programmatic-runner${extension}`), + ]; +} + +function firstExisting(candidates, label) { + const result = candidates.find(candidate => fs.existsSync(candidate)); + if (!result) + throw new Error(`Could not find ${label}. Tried:\n${candidates.map(candidate => ` ${candidate}`).join('\n')}`); + return result; +} + +function publicRunnerESM(root) { + return firstExisting(publicRunnerCandidates(root, '.mjs'), 'playwright/programmatic-runner ESM entry'); +} + +function playwrightTestEntry(root) { + return firstExisting([ + path.join(root, 'packages', 'playwright', 'test.js'), + path.join(root, 'test.js'), + path.join(root, 'node_modules', 'playwright', 'test.js'), + ], 'playwright/test entry'); +} + +async function writeFile(filePath, text) { + await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); + await fs.promises.writeFile(filePath, text); +} + +function assert(condition, message) { + if (!condition) + throw new Error(message); +} + +class SmokeReporter { + constructor() { + this.events = []; + this.stdoutWithTest = false; + this.stderrWithTest = false; + this.attachments = []; + } + + version() { + return 'v2'; + } + + onConfigure(config) { + this.events.push('onConfigure'); + this.configMetadata = config.metadata; + } + + onBegin(suite) { + this.events.push('onBegin'); + this.testCount = suite.allTests().length; + } + + onTestBegin(test, result) { + this.events.push('onTestBegin'); + this.testTitle = test.title; + this.workerIndex = result.workerIndex; + } + + onStepBegin(test, result, step) { + this.events.push('onStepBegin:' + step.title); + } + + onStepEnd(test, result, step) { + this.events.push('onStepEnd:' + step.title); + } + + onStdOut(chunk, test, result) { + if (String(chunk).includes('stdout-from-programmatic-test')) { + this.events.push('onStdOut'); + this.stdoutWithTest = !!test && !!result; + } + } + + onStdErr(chunk, test, result) { + if (String(chunk).includes('stderr-from-programmatic-test')) { + this.events.push('onStdErr'); + this.stderrWithTest = !!test && !!result; + } + } + + onTestEnd(test, result) { + this.events.push('onTestEnd:' + result.status); + this.attachments = result.attachments.map(a => ({ name: a.name, contentType: a.contentType, body: a.body && a.body.toString() })); + } + + onError(error) { + this.events.push('onError'); + this.errors = this.errors || []; + this.errors.push(error.message); + } + + onEnd(result) { + this.events.push('onEnd:' + result.status); + this.finalStatus = result.status; + } + + onExit() { + this.events.push('onExit'); + } +} + +async function main() { + const { playwrightRoot } = parseArgs(); + const runner = publicRunner(playwrightRoot); + const esmRunner = await import(publicRunnerESM(playwrightRoot)); + assert(typeof esmRunner.runTests === 'function', 'Expected ESM entry to export runTests'); + const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pw-programmatic-runner-smoke-')); + const configFile = path.join(tmpDir, 'playwright.config.js'); + const testFile = path.join(tmpDir, 'programmatic-smoke.spec.js'); + const testEntry = playwrightTestEntry(playwrightRoot); + const preforkedWorkers = await runner.createPreforkedWorkers({ workers: 1 }); + + await writeFile(path.join(tmpDir, 'node_modules', '@playwright', 'test', 'index.js'), `module.exports = require(${JSON.stringify(testEntry)});\n`); + await writeFile(configFile, ` +module.exports = { + testDir: ${JSON.stringify(tmpDir)}, + workers: 4, + metadata: { fromConfigFile: true }, + reporter: 'line', +}; +`); + await writeFile(testFile, ` +const { test, expect } = require('@playwright/test'); + +test('programmatic smoke', async ({}, testInfo) => { + expect(process.env.ENDFORM_LATE_ENV).toBe('from-init'); + console.log('stdout-from-programmatic-test'); + console.error('stderr-from-programmatic-test'); + await test.step('programmatic step', async () => { + expect(1 + 1).toBe(2); + }); + await testInfo.attach('programmatic-attachment', { + body: Buffer.from('attachment-body'), + contentType: 'text/plain', + }); +}); + +test('not selected', async () => { + throw new Error('This test should not run'); +}); +`); + + try { + const config = await runner.loadUserConfig(configFile); + config.workers = 1; + config.reporter = 'null'; + config.metadata = { fromMutatedConfig: true }; + + const reporter = new SmokeReporter(); + const result = await runner.runTests({ + configLocation: configFile, + config, + configOverrides: { workers: 1, retries: 0 }, + testSelection: { tests: [{ file: testFile, titlePath: ['programmatic smoke'] }] }, + reporter, + disableConfigReporters: true, + preforkedWorkers, + workerEnv: { ENDFORM_LATE_ENV: 'from-init' }, + }); + + assert(result.status === 'passed', `Expected run status passed, got ${result.status}. Test count: ${reporter.testCount}. Events: ${reporter.events.join(', ')}. Errors: ${JSON.stringify(reporter.errors || [])}`); + assert(reporter.finalStatus === 'passed', `Expected reporter final status passed, got ${reporter.finalStatus}`); + assert(reporter.testCount === 1, `Expected one test in onBegin, got ${reporter.testCount}`); + assert(reporter.testTitle === 'programmatic smoke', `Expected programmatic smoke test, got ${reporter.testTitle}`); + assert(reporter.configMetadata.fromMutatedConfig, `Expected mutated metadata, got ${JSON.stringify(reporter.configMetadata)}`); + assert(reporter.stdoutWithTest, 'Expected stdout to be attributed to test/result'); + assert(reporter.stderrWithTest, 'Expected stderr to be attributed to test/result'); + assert(reporter.events.includes('onStepBegin:programmatic step'), `Missing step begin. Events: ${reporter.events.join(', ')}`); + assert(reporter.events.includes('onStepEnd:programmatic step'), `Missing step end. Events: ${reporter.events.join(', ')}`); + assert(reporter.attachments.some(a => a.name === 'programmatic-attachment' && a.body === 'attachment-body'), `Missing attachment. Attachments: ${JSON.stringify(reporter.attachments)}`); + for (const event of ['onConfigure', 'onBegin', 'onTestBegin', 'onStdOut', 'onStdErr', 'onTestEnd:passed', 'onEnd:passed', 'onExit']) + assert(reporter.events.includes(event), `Missing reporter event ${event}. Events: ${reporter.events.join(', ')}`); + + console.log('PROGRAMMATIC_RUNNER_SMOKE_OK'); + console.log(JSON.stringify({ events: reporter.events })); + } finally { + await runner.disposePreforkedWorkers(preforkedWorkers); + } +} + +main().catch(e => { + console.error(e.stack || e.message || String(e)); + process.exit(1); +}); From 492abab54cb4492206194b2e9acf280c6d0a21db Mon Sep 17 00:00:00 2001 From: Oliver Stenbom Date: Mon, 1 Jun 2026 14:48:29 +0200 Subject: [PATCH 10/13] Update programmatic runner types --- ...bda-playwright-programmatic-runner-plan.md | 35 ++++-------- packages/playwright/programmatic-runner.d.ts | 51 ++++------------- packages/playwright/src/programmaticRunner.ts | 55 +++++++++---------- packages/playwright/src/runner/loadUtils.ts | 6 +- utils/endform/programmatic-runner-smoke.js | 27 ++++++--- 5 files changed, 73 insertions(+), 101 deletions(-) diff --git a/endform-lambda-playwright-programmatic-runner-plan.md b/endform-lambda-playwright-programmatic-runner-plan.md index 9319c957a329c..b8dfa65ed4edf 100644 --- a/endform-lambda-playwright-programmatic-runner-plan.md +++ b/endform-lambda-playwright-programmatic-runner-plan.md @@ -68,18 +68,10 @@ These should become structured parameters to `runTests`: await runTests({ config, configLocation, - configOverrides: { - retries: 0, - workers: 1, - maxFailures: 0, - timeout, - updateSnapshots, - }, ignoreProjectDependencies: true, testSelection, workerEnv, preforkedWorkers, - reporter, }); ``` @@ -131,10 +123,10 @@ config.reporter = buildEndformReporterList(config.reporter, request); await runTests({ configLocation, config, - configOverrides, + ignoreProjectDependencies: true, testSelection, - reporter, preforkedWorkers, + workerEnv, }); ``` @@ -222,25 +214,18 @@ The run parameters should use Playwright concepts and avoid Endform-specific nam type RunTestsParams = { configLocation: ConfigLocation; config: Config; - configOverrides?: ConfigCLIOverrides; - ignoreProjectDependencies?: boolean; - projectFilter?: string[]; - testSelection?: StructuredTestSelection; - reporter?: Reporter | Reporter[]; - disableConfigReporters?: boolean; - preforkedWorkers?: PreforkedWorkers; - workerEnv?: Record; - metadata?: Record; + ignoreProjectDependencies: boolean; + testSelection: StructuredTestSelection; + preforkedWorkers: PreforkedWorkers; + workerEnv: Record; }; ``` Notes: -- `config` is the raw user config object, possibly mutated by the caller before `runTests` receives it. -- `configOverrides` should map to the existing `ConfigCLIOverrides` structure where possible. +- `config` is the raw user config object, possibly mutated by the caller before `runTests` receives it, and is the single source of truth for normal Playwright configuration such as workers, retries, timeouts, snapshot policy, metadata, and reporters. - `ignoreProjectDependencies` is the programmatic equivalent of `--no-deps`. -- `reporter` should allow the caller to provide additional in-process reporters without requiring them to exist in `config.reporter`. -- `disableConfigReporters` is useful for tests, but Endform will usually build the desired config reporter list itself and pass it in `config.reporter`. +- Reporters are owned by the caller through the mutated `config.reporter`; the programmatic runner should not expose a parallel reporter assembly path. - `preforkedWorkers` should be an opaque handle, not an array of internal `WorkerHost` instances. ## Structured Test Selection @@ -255,7 +240,7 @@ type StructuredTestSelection = { }; type StructuredSelectedTest = { - projectName?: string; + projectName: string; file: string; titlePath: string[]; }; @@ -314,6 +299,8 @@ await runTests({ config, preforkedWorkers, testSelection, + workerEnv, + ignoreProjectDependencies: true, }); ``` diff --git a/packages/playwright/programmatic-runner.d.ts b/packages/playwright/programmatic-runner.d.ts index bddbc671d7936..bc359db09d4d6 100644 --- a/packages/playwright/programmatic-runner.d.ts +++ b/packages/playwright/programmatic-runner.d.ts @@ -14,62 +14,35 @@ * limitations under the License. */ -import type { Config, FullConfig, ReporterDescription, TestProject } from './types/test'; -import type { FullResult, Reporter } from './types/testReporter'; +import type { Config } from './types/test'; +import type { FullResult } from './types/testReporter'; export type ConfigLocation = string | { resolvedConfigFile?: string; configDir: string; }; -export type ProgrammaticConfigOverrides = { - forbidOnly?: boolean; - fullyParallel?: boolean; - globalTimeout?: number; - maxFailures?: number; - outputDir?: string; - quiet?: boolean; - repeatEach?: number; - reporter?: ReporterDescription[]; - retries?: number; - shard?: FullConfig['shard']; - timeout?: number; - tsconfig?: string; - updateSnapshots?: 'all' | 'changed' | 'missing' | 'none'; - updateSourceMethod?: 'overwrite' | 'patch' | '3way'; - use?: TestProject['use']; - workers?: number | string; - metadata?: Config['metadata']; -}; - export type StructuredTestSelection = { tests: StructuredSelectedTest[]; }; export type StructuredSelectedTest = { - projectName?: string; + projectName: string; file: string; - titlePath?: string[]; + titlePath: string[]; }; -export type PreforkedWorkers = unknown; +export type PreforkedWorkers = { + readonly __brand: unique symbol; +}; export type RunTestsParams = { configLocation: ConfigLocation; - config?: Config; - configOverrides?: ProgrammaticConfigOverrides; - ignoreProjectDependencies?: boolean; - projectFilter?: string[]; - locations?: string[]; - grep?: string; - grepInvert?: string; - testSelection?: StructuredTestSelection; - reporter?: Reporter | Reporter[]; - disableConfigReporters?: boolean; - preforkedWorkers?: PreforkedWorkers; - workerEnv?: Record; - metadata?: Config['metadata']; - passWithNoTests?: boolean; + config: Config; + ignoreProjectDependencies: boolean; + testSelection: StructuredTestSelection; + preforkedWorkers: PreforkedWorkers; + workerEnv: Record; }; export type RunTestsResult = { diff --git a/packages/playwright/src/programmaticRunner.ts b/packages/playwright/src/programmaticRunner.ts index 564ab0207b64e..bdb772e5636f9 100644 --- a/packages/playwright/src/programmaticRunner.ts +++ b/packages/playwright/src/programmaticRunner.ts @@ -14,12 +14,11 @@ * limitations under the License. */ -import { configLoader, ipc } from './common'; +import { configLoader } from './common'; import { testRunner, workerHost } from './runner'; import type { ConfigLocation } from './common'; import type { StructuredTestSelection } from './runner/loadUtils'; -import type { AnyReporter } from './reporters/reporterV2'; import type { Config } from '../types/test'; import type { FullResult } from '../types/testReporter'; @@ -27,20 +26,11 @@ type ConfigLocationInput = string | ConfigLocation; type RunTestsParams = { configLocation: ConfigLocationInput; - config?: Config; - configOverrides?: ipc.ConfigCLIOverrides; - ignoreProjectDependencies?: boolean; - projectFilter?: string[]; - locations?: string[]; - grep?: string; - grepInvert?: string; - testSelection?: StructuredTestSelection; - reporter?: AnyReporter | AnyReporter[]; - disableConfigReporters?: boolean; - preforkedWorkers?: PreforkedWorkers; - workerEnv?: Record; - metadata?: Config['metadata']; - passWithNoTests?: boolean; + config: Config; + ignoreProjectDependencies: boolean; + testSelection: StructuredTestSelection; + preforkedWorkers: PreforkedWorkers; + workerEnv: Record; }; type RunTestsResult = { @@ -61,25 +51,34 @@ export async function loadUserConfig(location: ConfigLocationInput): Promise { const location = resolveLocation(params.configLocation); - const userConfig = params.config || await configLoader.loadUserConfig(location, params.configOverrides); - const metadata = params.metadata ?? (params.configOverrides as any)?.metadata; - const config = await configLoader.loadConfigFromObject(location, userConfig, params.configOverrides, params.ignoreProjectDependencies, metadata); - const reporters = params.reporter ? Array.isArray(params.reporter) ? params.reporter : [params.reporter] : []; + const config = await configLoader.loadConfigFromObject(location, params.config, {}, params.ignoreProjectDependencies); + validateTestSelection(params.testSelection); const status = await testRunner.runAllTestsWithConfig(config, { - locations: params.locations, - grep: params.grep, - grepInvert: params.grepInvert, - projectFilter: params.projectFilter, + projectFilter: projectFilterFromSelection(params.testSelection), testSelection: params.testSelection, - passWithNoTests: params.passWithNoTests, - additionalReporterObjects: reporters, - disableConfigReporters: params.disableConfigReporters, - preforkedWorkers: params.preforkedWorkers?.workers, + preforkedWorkers: params.preforkedWorkers.workers, workerEnv: params.workerEnv, }); return { status }; } +function validateTestSelection(selection: StructuredTestSelection) { + if (!selection || !selection.tests.length) + throw new Error('Programmatic runner requires at least one selected test'); + for (const test of selection.tests) { + if (test.projectName === undefined) + throw new Error('Programmatic runner selected test must specify projectName'); + if (!test.file) + throw new Error('Programmatic runner selected test must specify file'); + if (!test.titlePath?.length) + throw new Error('Programmatic runner selected test must specify non-empty titlePath'); + } +} + +function projectFilterFromSelection(selection: StructuredTestSelection): string[] { + return [...new Set(selection.tests.map(test => test.projectName))]; +} + export async function createPreforkedWorkers(params: { workers: number }): Promise { const workers: workerHost.WorkerHost[] = []; try { diff --git a/packages/playwright/src/runner/loadUtils.ts b/packages/playwright/src/runner/loadUtils.ts index e8d4d22436645..be0fd95da7637 100644 --- a/packages/playwright/src/runner/loadUtils.ts +++ b/packages/playwright/src/runner/loadUtils.ts @@ -37,9 +37,9 @@ export type StructuredTestSelection = { }; export type StructuredSelectedTest = { - projectName?: string; + projectName: string; file: string; - titlePath?: string[]; + titlePath: string[]; }; type TestDescription = { @@ -373,7 +373,7 @@ export function createStructuredTestSelectionFilters(config: FullConfigInternal, const descriptions = selection.tests.map(test => ({ project: test.projectName, file: normalizeSelectedFile(config, test.file), - titlePath: test.titlePath || [], + titlePath: test.titlePath, })); const { filters, unmatchedErrors } = createFilters(config, descriptions); return { ...filters, unmatchedErrors }; diff --git a/utils/endform/programmatic-runner-smoke.js b/utils/endform/programmatic-runner-smoke.js index 8e341f158471e..f7724ec7789f5 100644 --- a/utils/endform/programmatic-runner-smoke.js +++ b/utils/endform/programmatic-runner-smoke.js @@ -94,7 +94,8 @@ function assert(condition, message) { } class SmokeReporter { - constructor() { + constructor(options) { + this.eventsFile = options.eventsFile; this.events = []; this.stdoutWithTest = false; this.stderrWithTest = false; @@ -161,6 +162,17 @@ class SmokeReporter { onExit() { this.events.push('onExit'); + fs.writeFileSync(this.eventsFile, JSON.stringify({ + events: this.events, + configMetadata: this.configMetadata, + finalStatus: this.finalStatus, + testCount: this.testCount, + testTitle: this.testTitle, + stdoutWithTest: this.stdoutWithTest, + stderrWithTest: this.stderrWithTest, + attachments: this.attachments, + errors: this.errors || [], + })); } } @@ -171,11 +183,14 @@ async function main() { assert(typeof esmRunner.runTests === 'function', 'Expected ESM entry to export runTests'); const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pw-programmatic-runner-smoke-')); const configFile = path.join(tmpDir, 'playwright.config.js'); + const reporterFile = path.join(tmpDir, 'smoke-reporter.js'); + const eventsFile = path.join(tmpDir, 'smoke-events.json'); const testFile = path.join(tmpDir, 'programmatic-smoke.spec.js'); const testEntry = playwrightTestEntry(playwrightRoot); const preforkedWorkers = await runner.createPreforkedWorkers({ workers: 1 }); await writeFile(path.join(tmpDir, 'node_modules', '@playwright', 'test', 'index.js'), `module.exports = require(${JSON.stringify(testEntry)});\n`); + await writeFile(reporterFile, `const fs = require('fs');\nmodule.exports = ${SmokeReporter.toString()};\n`); await writeFile(configFile, ` module.exports = { testDir: ${JSON.stringify(tmpDir)}, @@ -208,20 +223,18 @@ test('not selected', async () => { try { const config = await runner.loadUserConfig(configFile); config.workers = 1; - config.reporter = 'null'; + config.reporter = [[reporterFile, { eventsFile }]]; config.metadata = { fromMutatedConfig: true }; - const reporter = new SmokeReporter(); const result = await runner.runTests({ configLocation: configFile, config, - configOverrides: { workers: 1, retries: 0 }, - testSelection: { tests: [{ file: testFile, titlePath: ['programmatic smoke'] }] }, - reporter, - disableConfigReporters: true, + ignoreProjectDependencies: true, + testSelection: { tests: [{ projectName: '', file: testFile, titlePath: ['programmatic smoke'] }] }, preforkedWorkers, workerEnv: { ENDFORM_LATE_ENV: 'from-init' }, }); + const reporter = JSON.parse(await fs.promises.readFile(eventsFile, 'utf-8')); assert(result.status === 'passed', `Expected run status passed, got ${result.status}. Test count: ${reporter.testCount}. Events: ${reporter.events.join(', ')}. Errors: ${JSON.stringify(reporter.errors || [])}`); assert(reporter.finalStatus === 'passed', `Expected reporter final status passed, got ${reporter.finalStatus}`); From 56db7790185d66c2e7c2a428015e994ce006f096 Mon Sep 17 00:00:00 2001 From: Oliver Stenbom Date: Mon, 1 Jun 2026 15:44:46 +0200 Subject: [PATCH 11/13] Add publish script helpers --- utils/endform/README.md | 159 ++++++++++++++++ utils/endform/publish_endform_packages.sh | 113 ++++++++++++ utils/endform/stage_endform_packages.js | 212 ++++++++++++++++++++++ 3 files changed, 484 insertions(+) create mode 100644 utils/endform/README.md create mode 100755 utils/endform/publish_endform_packages.sh create mode 100755 utils/endform/stage_endform_packages.js diff --git a/utils/endform/README.md b/utils/endform/README.md new file mode 100644 index 0000000000000..1841c6e5771fe --- /dev/null +++ b/utils/endform/README.md @@ -0,0 +1,159 @@ +# Endform Playwright Packages + +This directory contains Endform-only tooling for publishing this Playwright fork under scoped npm package names while preserving canonical Playwright module names at runtime. + +## Published Packages + +The fork is published as three scoped packages: + +| Package | Source package | Purpose | +| --- | --- | --- | +| `@endform/playwright-core` | `packages/playwright-core` | Browser automation engine | +| `@endform/playwright` | `packages/playwright` | Public Playwright package plus Endform's `playwright/programmatic-runner` export | +| `@endform/playwright-test` | `packages/playwright-test` | Wrapper equivalent of upstream `@playwright/test` | + +The source workspace package names stay unchanged: `playwright-core`, `playwright`, and `@playwright/test`. This keeps normal Playwright build tooling, tests, generated files, and internal imports working without a fork-wide rename. + +## Why Runtime Names Stay Canonical + +Playwright packages intentionally import each other by canonical package name. Examples: + +```js +require('playwright-core') +require('playwright/test') +``` + +The Endform packages therefore use npm alias dependencies in their staged `package.json` files: + +```json +{ + "name": "@endform/playwright", + "dependencies": { + "playwright-core": "npm:@endform/playwright-core@1.60.0-beta.1" + } +} +``` + +```json +{ + "name": "@endform/playwright-test", + "dependencies": { + "playwright": "npm:@endform/playwright@1.60.0-beta.1" + } +} +``` + +This means code can keep using canonical imports when a project installs the fork through aliases: + +```json +{ + "dependencies": { + "playwright-core": "npm:@endform/playwright-core@1.60.0-beta.1", + "playwright": "npm:@endform/playwright@1.60.0-beta.1", + "@playwright/test": "npm:@endform/playwright-test@1.60.0-beta.1" + } +} +``` + +With that install shape, these imports all resolve to the Endform fork: + +```js +require('playwright') +require('playwright/test') +require('playwright/programmatic-runner') +require('@playwright/test') +``` + +## LambdaRunner Symlink Strategy + +LambdaRunner can install both upstream Playwright and Endform Playwright into isolated roots in the Docker image: + +```text +/opt/playwright-upstream/node_modules/ + playwright + playwright-core + @playwright/test + +/opt/playwright-endform/node_modules/ + playwright + playwright-core + @playwright/test +``` + +The Endform root should be installed with canonical alias names that point at the scoped packages: + +```json +{ + "dependencies": { + "playwright-core": "npm:@endform/playwright-core@1.60.0-beta.1", + "playwright": "npm:@endform/playwright@1.60.0-beta.1", + "@playwright/test": "npm:@endform/playwright-test@1.60.0-beta.1" + } +} +``` + +At invocation time, LambdaRunner should select a matched package set and create execution-directory symlinks such as: + +```text +/tmp/node_modules/playwright -> selected root/node_modules/playwright +/tmp/node_modules/playwright-core -> selected root/node_modules/playwright-core +/tmp/node_modules/@playwright/test -> selected root/node_modules/@playwright/test +``` + +Do not symlink only one package. `@playwright/test`, `playwright`, and `playwright-core` must be selected as a matched set so that all canonical package imports bind to the same implementation. + +The switch must happen before the Node host imports Playwright. Once Node loads `playwright`, `playwright-core`, or `@playwright/test`, the module cache makes in-process switching unsafe. + +Avoid using `NODE_OPTIONS=--preserve-symlinks` as the main solution. It changes module identity globally and can create duplicate-module problems. Prefer isolated install roots plus canonical symlinks. + +## Staging Packages + +Build Playwright first: + +```bash +npm run build +``` + +Stage and validate Endform tarballs: + +```bash +node utils/endform/stage_endform_packages.js +``` + +By default, tarballs are written to `endform-packages/`: + +```text +endform-packages/endform-playwright-core-.tgz +endform-packages/endform-playwright-.tgz +endform-packages/endform-playwright-test-.tgz +``` + +The staging script validates canonical alias installation, which is the install shape LambdaRunner needs for symlink selection. Direct scoped installation requires the packages to already exist in the npm registry because the staged packages intentionally depend on each other through npm aliases. + +## Publishing + +For a beta release: + +```bash +node utils/workspace.js --set-version 1.60.0-beta.1 +npm run build +utils/endform/publish_endform_packages.sh --beta --dry-run +utils/endform/publish_endform_packages.sh --beta +``` + +For a stable release: + +```bash +node utils/workspace.js --set-version 1.60.0 +npm run build +utils/endform/publish_endform_packages.sh --release --dry-run +utils/endform/publish_endform_packages.sh --release +``` + +`--beta` publishes with the `beta` dist-tag and requires a version containing `-beta`. `--release` publishes with the `latest` dist-tag and rejects pre-release versions. + +The publish order is dependency-safe: + +1. `@endform/playwright-core` +2. `@endform/playwright` +3. `@endform/playwright-test` diff --git a/utils/endform/publish_endform_packages.sh b/utils/endform/publish_endform_packages.sh new file mode 100755 index 0000000000000..c7892cfa61d90 --- /dev/null +++ b/utils/endform/publish_endform_packages.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +set -euo pipefail + +function usage { + echo "usage: $(basename "$0") [--beta|--release] [--dry-run] [--out-dir ] [--skip-smoke]" + echo + echo "Stages and publishes @endform Playwright packages." + echo + echo "--beta publish a pre-release version under the beta dist-tag" + echo "--release publish a stable version under the latest dist-tag" + echo "--dry-run stage and validate tarballs without publishing" +} + +MODE="" +DRY_RUN=0 +OUT_DIR="" +SKIP_SMOKE=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --beta|--release) + MODE="$1" + shift + ;; + --dry-run) + DRY_RUN=1 + shift + ;; + --out-dir) + OUT_DIR="$2" + shift 2 + ;; + --out-dir=*) + OUT_DIR="${1#--out-dir=}" + shift + ;; + --skip-smoke) + SKIP_SMOKE=1 + shift + ;; + --help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +if [[ -z "${MODE}" ]]; then + echo "Please specify --beta or --release" >&2 + usage >&2 + exit 1 +fi + +if ! command -v npm >/dev/null; then + echo "ERROR: npm is not found" >&2 + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +ROOT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd -P)" +cd "${ROOT_DIR}" + +VERSION="$(node -e 'console.log(require("./package.json").version)')" +NPM_TAG="" + +if [[ "${MODE}" == "--release" ]]; then + if [[ "${VERSION}" == *-* ]]; then + echo "ERROR: cannot publish pre-release version ${VERSION} with --release" >&2 + exit 1 + fi + NPM_TAG="latest" +else + if [[ "${VERSION}" != *-beta* ]]; then + echo "ERROR: --beta requires a beta version, got ${VERSION}" >&2 + exit 1 + fi + NPM_TAG="beta" +fi + +STAGE_ARGS=() +if [[ -n "${OUT_DIR}" ]]; then + STAGE_ARGS+=("--out-dir" "${OUT_DIR}") +else + OUT_DIR="${ROOT_DIR}/endform-packages" +fi +if [[ "${SKIP_SMOKE}" == "1" ]]; then + STAGE_ARGS+=("--skip-smoke") +fi + +node "${SCRIPT_DIR}/stage_endform_packages.js" "${STAGE_ARGS[@]}" + +CORE_TGZ="${OUT_DIR}/endform-playwright-core-${VERSION}.tgz" +PLAYWRIGHT_TGZ="${OUT_DIR}/endform-playwright-${VERSION}.tgz" +TEST_TGZ="${OUT_DIR}/endform-playwright-test-${VERSION}.tgz" + +if [[ "${DRY_RUN}" == "1" ]]; then + echo "Dry run complete. Tarballs are ready in ${OUT_DIR}:" + echo " ${CORE_TGZ}" + echo " ${PLAYWRIGHT_TGZ}" + echo " ${TEST_TGZ}" + exit 0 +fi + +npm publish --access=public --tag="${NPM_TAG}" "${CORE_TGZ}" +npm publish --access=public --tag="${NPM_TAG}" "${PLAYWRIGHT_TGZ}" +npm publish --access=public --tag="${NPM_TAG}" "${TEST_TGZ}" + +echo "Published @endform Playwright ${VERSION} with dist-tag ${NPM_TAG}." diff --git a/utils/endform/stage_endform_packages.js b/utils/endform/stage_endform_packages.js new file mode 100755 index 0000000000000..8c0d48ab56d7c --- /dev/null +++ b/utils/endform/stage_endform_packages.js @@ -0,0 +1,212 @@ +#!/usr/bin/env node +/** + * Copyright Microsoft Corporation. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const childProcess = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const ROOT = path.resolve(__dirname, '..', '..'); +const DEFAULT_OUT_DIR = path.join(ROOT, 'endform-packages'); + +const packages = [ + { + sourceDir: path.join(ROOT, 'packages', 'playwright-core'), + stagedName: '@endform/playwright-core', + }, + { + sourceDir: path.join(ROOT, 'packages', 'playwright'), + stagedName: '@endform/playwright', + dependencies: version => ({ + 'playwright-core': `npm:@endform/playwright-core@${version}`, + }), + }, + { + sourceDir: path.join(ROOT, 'packages', 'playwright-test'), + stagedName: '@endform/playwright-test', + dependencies: version => ({ + 'playwright': `npm:@endform/playwright@${version}`, + }), + }, +]; + +function parseArgs() { + const result = { + outDir: DEFAULT_OUT_DIR, + skipSmoke: false, + keepTemp: false, + }; + for (let i = 2; i < process.argv.length; i++) { + const arg = process.argv[i]; + if (arg === '--out-dir') + result.outDir = path.resolve(process.argv[++i]); + else if (arg.startsWith('--out-dir=')) + result.outDir = path.resolve(arg.substring('--out-dir='.length)); + else if (arg === '--skip-smoke') + result.skipSmoke = true; + else if (arg === '--keep-temp') + result.keepTemp = true; + else if (arg === '--help') { + usage(); + process.exit(0); + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + return result; +} + +function usage() { + console.log(`usage: stage_endform_packages.js [--out-dir ] [--skip-smoke] [--keep-temp]\n\nStages @endform Playwright packages by packing the canonical workspace packages, rewriting only staged package metadata, and producing final tarballs.`); +} + +function run(command, args, options = {}) { + const result = childProcess.spawnSync(command, args, { + cwd: options.cwd || ROOT, + env: { ...process.env, PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1', ...options.env }, + encoding: 'utf8', + stdio: options.capture ? 'pipe' : 'inherit', + shell: process.platform === 'win32', + }); + if (result.status !== 0) { + if (options.capture) { + process.stdout.write(result.stdout || ''); + process.stderr.write(result.stderr || ''); + } + throw new Error(`${command} ${args.join(' ')} failed with exit code ${result.status}`); + } + return result.stdout ? result.stdout.trim() : ''; +} + +async function rm(dir) { + await fs.promises.rm(dir, { recursive: true, force: true }); +} + +async function readJSON(file) { + return JSON.parse(await fs.promises.readFile(file, 'utf8')); +} + +async function writeJSON(file, object) { + await fs.promises.writeFile(file, JSON.stringify(object, null, 2) + '\n'); +} + +function tarballBaseName(name, version) { + const unscoped = name.replace(/^@/, '').replace('/', '-'); + return `${unscoped}-${version}.tgz`; +} + +async function packSourcePackage(sourceDir, tempDir) { + const packDir = path.join(tempDir, 'source-packs'); + await fs.promises.mkdir(packDir, { recursive: true }); + const output = run('npm', ['pack', sourceDir, '--pack-destination', packDir], { capture: true }); + const tgzName = output.split('\n').filter(Boolean).pop(); + return path.join(packDir, tgzName); +} + +async function extractPackage(tgzPath, destination) { + await fs.promises.mkdir(destination, { recursive: true }); + run('tar', ['-xzf', tgzPath, '-C', destination]); + return path.join(destination, 'package'); +} + +async function rewritePackageJSON(packageDir, descriptor, version) { + const packageJSONPath = path.join(packageDir, 'package.json'); + const packageJSON = await readJSON(packageJSONPath); + packageJSON.name = descriptor.stagedName; + packageJSON.version = version; + if (descriptor.dependencies) + packageJSON.dependencies = descriptor.dependencies(version); + await writeJSON(packageJSONPath, packageJSON); +} + +async function packStagedPackage(packageDir, outDir, expectedName) { + const output = run('npm', ['pack', packageDir, '--pack-destination', outDir], { capture: true }); + const tgzName = output.split('\n').filter(Boolean).pop(); + const tgzPath = path.join(outDir, tgzName); + const expectedPath = path.join(outDir, expectedName); + if (tgzPath !== expectedPath) { + await fs.promises.rename(tgzPath, expectedPath); + return expectedPath; + } + return tgzPath; +} + +async function smokeInstall(tarballs, version) { + const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'endform-playwright-smoke-')); + try { + // Local pre-publish validation uses the same canonical alias shape LambdaRunner + // installs in its isolated Endform root. Direct scoped installation requires + // the scoped packages to already exist in the npm registry because staged + // package dependencies intentionally use npm aliases. + await writeJSON(path.join(tempDir, 'package.json'), { + private: true, + dependencies: { + 'playwright-core': tarballs['@endform/playwright-core'], + 'playwright': tarballs['@endform/playwright'], + '@playwright/test': tarballs['@endform/playwright-test'], + }, + }); + run('npm', ['install', '--ignore-scripts'], { cwd: tempDir }); + run('node', ['-e', [ + `const core = require('playwright-core/package.json');`, + `const pw = require('playwright/package.json');`, + `const pwt = require('@playwright/test/package.json');`, + `if (core.version !== ${JSON.stringify(version)} || pw.version !== ${JSON.stringify(version)} || pwt.version !== ${JSON.stringify(version)}) throw new Error('version mismatch');`, + `if (pw.dependencies['playwright-core'] !== ${JSON.stringify(`npm:@endform/playwright-core@${version}`)}) throw new Error('bad playwright-core alias');`, + `if (pwt.dependencies.playwright !== ${JSON.stringify(`npm:@endform/playwright@${version}`)}) throw new Error('bad playwright alias');`, + `require('playwright');`, + `require('playwright/test');`, + `require('playwright/programmatic-runner');`, + `require('@playwright/test');`, + ].join('')], { cwd: tempDir }); + } finally { + await rm(tempDir); + } +} + +async function main() { + const options = parseArgs(); + const version = (await readJSON(path.join(ROOT, 'package.json'))).version; + const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'endform-playwright-stage-')); + const tarballs = {}; + try { + await rm(options.outDir); + await fs.promises.mkdir(options.outDir, { recursive: true }); + for (const descriptor of packages) { + const sourceTgz = await packSourcePackage(descriptor.sourceDir, tempDir); + const packageDir = await extractPackage(sourceTgz, path.join(tempDir, descriptor.stagedName.replace('/', '-').replace('@', ''))); + await rewritePackageJSON(packageDir, descriptor, version); + const outputName = tarballBaseName(descriptor.stagedName, version); + tarballs[descriptor.stagedName] = await packStagedPackage(packageDir, options.outDir, outputName); + } + + if (!options.skipSmoke) + await smokeInstall(tarballs, version); + + console.log(JSON.stringify({ version, outDir: options.outDir, tarballs }, null, 2)); + } finally { + if (options.keepTemp) + console.error(`Kept temp staging directory: ${tempDir}`); + else + await rm(tempDir); + } +} + +main().catch(error => { + console.error(error.stack || error.message || String(error)); + process.exit(1); +}); From ed749ed68d5dba9bbd4688546984084899228ac5 Mon Sep 17 00:00:00 2001 From: Oliver Stenbom Date: Mon, 1 Jun 2026 15:47:01 +0200 Subject: [PATCH 12/13] fix script --- utils/endform/publish_endform_packages.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/utils/endform/publish_endform_packages.sh b/utils/endform/publish_endform_packages.sh index c7892cfa61d90..b95c004d0edff 100755 --- a/utils/endform/publish_endform_packages.sh +++ b/utils/endform/publish_endform_packages.sh @@ -92,7 +92,11 @@ if [[ "${SKIP_SMOKE}" == "1" ]]; then STAGE_ARGS+=("--skip-smoke") fi -node "${SCRIPT_DIR}/stage_endform_packages.js" "${STAGE_ARGS[@]}" +if [[ ${#STAGE_ARGS[@]} -eq 0 ]]; then + node "${SCRIPT_DIR}/stage_endform_packages.js" +else + node "${SCRIPT_DIR}/stage_endform_packages.js" "${STAGE_ARGS[@]}" +fi CORE_TGZ="${OUT_DIR}/endform-playwright-core-${VERSION}.tgz" PLAYWRIGHT_TGZ="${OUT_DIR}/endform-playwright-${VERSION}.tgz" From 818dc45f0eefe70d03ae0f17f723e96b8f843307 Mon Sep 17 00:00:00 2001 From: Oliver Stenbom Date: Tue, 2 Jun 2026 11:30:42 +0200 Subject: [PATCH 13/13] Config overrides updates --- packages/playwright/programmatic-runner.d.ts | 24 +- .../playwright/src/common/configLoader.ts | 329 ++++++++++++++---- packages/playwright/src/common/ipc.ts | 51 ++- packages/playwright/src/programmaticRunner.ts | 116 +++++- packages/playwright/src/runner/processHost.ts | 1 + utils/endform/programmatic-runner-smoke.js | 286 ++++++++++----- 6 files changed, 612 insertions(+), 195 deletions(-) diff --git a/packages/playwright/programmatic-runner.d.ts b/packages/playwright/programmatic-runner.d.ts index bc359db09d4d6..4c918a29be005 100644 --- a/packages/playwright/programmatic-runner.d.ts +++ b/packages/playwright/programmatic-runner.d.ts @@ -14,13 +14,15 @@ * limitations under the License. */ -import type { Config } from './types/test'; -import type { FullResult } from './types/testReporter'; +import type { Config } from "./types/test"; +import type { FullResult } from "./types/testReporter"; -export type ConfigLocation = string | { - resolvedConfigFile?: string; - configDir: string; -}; +export type ConfigLocation = + | string + | { + resolvedConfigFile?: string; + configDir: string; + }; export type StructuredTestSelection = { tests: StructuredSelectedTest[]; @@ -46,10 +48,14 @@ export type RunTestsParams = { }; export type RunTestsResult = { - status: FullResult['status']; + status: FullResult["status"]; }; export function loadUserConfig(location: ConfigLocation): Promise; export function runTests(params: RunTestsParams): Promise; -export function createPreforkedWorkers(params: { workers: number }): Promise; -export function disposePreforkedWorkers(workers: PreforkedWorkers): Promise; +export function createPreforkedWorkers(params: { + workers: number; +}): Promise; +export function disposePreforkedWorkers( + workers: PreforkedWorkers, +): Promise; diff --git a/packages/playwright/src/common/configLoader.ts b/packages/playwright/src/common/configLoader.ts index 10d6764ddbc16..de7d7b75d11bf 100644 --- a/packages/playwright/src/common/configLoader.ts +++ b/packages/playwright/src/common/configLoader.ts @@ -19,15 +19,23 @@ import path from 'path'; import { isRegExp } from '@isomorphic/rtti'; -import { requireOrImport, setSingleTSConfig, setTransformConfig } from '../transform/transform'; +import { addToCompilationCache } from '../transform/compilationCache'; +import { + requireOrImport, + setSingleTSConfig, + setTransformConfig, +} from '../transform/transform'; import { errorWithFile, fileIsModule } from '../util'; import { FullConfigInternal } from './config'; -import { configureESMLoader, configureESMLoaderTransformConfig, registerESMLoader } from './esmLoaderHost'; -import { addToCompilationCache } from '../transform/compilationCache'; +import { + configureESMLoader, + configureESMLoaderTransformConfig, + registerESMLoader, +} from './esmLoaderHost'; +import type { Config, Project } from '../../types/test'; import type { ConfigLocation } from './config'; import type { ConfigCLIOverrides, SerializedConfig } from './ipc'; -import type { Config, Project } from '../../types/test'; const kDefineConfigWasUsed = Symbol('defineConfigWasUsed'); export const defineConfig = (...configs: any[]) => { @@ -51,9 +59,17 @@ export const defineConfig = (...configs: any[]) => { ...config.build, }, webServer: [ - ...(Array.isArray(result.webServer) ? result.webServer : (result.webServer ? [result.webServer] : [])), - ...(Array.isArray(config.webServer) ? config.webServer : (config.webServer ? [config.webServer] : [])), - ] + ...(Array.isArray(result.webServer) + ? result.webServer + : result.webServer + ? [result.webServer] + : []), + ...(Array.isArray(config.webServer) + ? config.webServer + : config.webServer + ? [config.webServer] + : []), + ], }; if (!result.projects && !config.projects) @@ -73,7 +89,7 @@ export const defineConfig = (...configs: any[]) => { use: { ...project.use, ...projectOverride.use, - } + }, }); projectOverrides.delete(project.name); } else { @@ -87,41 +103,99 @@ export const defineConfig = (...configs: any[]) => { return result; }; -export async function deserializeConfig(data: SerializedConfig): Promise { +export async function deserializeConfig( + data: SerializedConfig, +): Promise { if (data.compilationCache) addToCompilationCache(data.compilationCache); - return await loadConfig(data.location, data.configCLIOverrides, undefined, data.metadata ? JSON.parse(data.metadata) : undefined); + if (data.programmaticUserConfig) { + await prepareConfigLoading(data.location, data.configCLIOverrides); + return await loadConfigFromObject( + data.location, + data.programmaticUserConfig, + data.configCLIOverrides, + undefined, + data.metadata ? JSON.parse(data.metadata) : undefined, + ); + } + return await loadConfig( + data.location, + data.configCLIOverrides, + undefined, + data.metadata ? JSON.parse(data.metadata) : undefined, + ); } -export async function loadUserConfig(location: ConfigLocation, overrides?: ConfigCLIOverrides): Promise { +export async function loadUserConfig( + location: ConfigLocation, + overrides?: ConfigCLIOverrides, +): Promise { await setSingleTSConfig(overrides?.tsconfig); - let object = location.resolvedConfigFile ? await requireOrImport(location.resolvedConfigFile) : {}; - if (object && typeof object === 'object' && ('default' in object)) + let object = location.resolvedConfigFile + ? await requireOrImport(location.resolvedConfigFile) + : {}; + if (object && typeof object === 'object' && 'default' in object) object = object['default']; return object as Config; } -export async function loadConfig(location: ConfigLocation, overrides?: ConfigCLIOverrides, ignoreProjectDependencies = false, metadata?: Config['metadata']): Promise { +export async function loadConfig( + location: ConfigLocation, + overrides?: ConfigCLIOverrides, + ignoreProjectDependencies = false, + metadata?: Config['metadata'], +): Promise { + await prepareConfigLoading(location, overrides); + + // 2. Load and validate playwright config. + const userConfig = await loadUserConfig(location); + return await loadConfigFromObject( + location, + userConfig, + overrides, + ignoreProjectDependencies, + metadata, + ); +} + +export async function prepareConfigLoading( + location: ConfigLocation, + overrides?: ConfigCLIOverrides, +) { // 0. Setup ESM loader if needed. if (!registerESMLoader()) { // In Node.js < 18, complain if the config file is ESM. Historically, we would restart // the process with --loader, but now we require newer Node.js. - if (location.resolvedConfigFile && fileIsModule(location.resolvedConfigFile)) - throw errorWithFile(location.resolvedConfigFile, `Playwright requires Node.js 18.19 or higher to load esm modules. Please update your version of Node.js.`); + if ( + location.resolvedConfigFile && + fileIsModule(location.resolvedConfigFile) + ) { + throw errorWithFile( + location.resolvedConfigFile, + `Playwright requires Node.js 18.19 or higher to load esm modules. Please update your version of Node.js.`, + ); + } } // 1. Setup tsconfig; configure ESM loader with tsconfig and compilation cache. setSingleTSConfig(overrides?.tsconfig); await configureESMLoader(); - - // 2. Load and validate playwright config. - const userConfig = await loadUserConfig(location); - return await loadConfigFromObject(location, userConfig, overrides, ignoreProjectDependencies, metadata); } -export async function loadConfigFromObject(location: ConfigLocation, userConfig: Config, overrides?: ConfigCLIOverrides, ignoreProjectDependencies = false, metadata?: Config['metadata']): Promise { +export async function loadConfigFromObject( + location: ConfigLocation, + userConfig: Config, + overrides?: ConfigCLIOverrides, + ignoreProjectDependencies = false, + metadata?: Config['metadata'], +): Promise { validateConfig(location.resolvedConfigFile || '', userConfig); - const fullConfig = new FullConfigInternal(location, userConfig, overrides || {}, metadata); + const fullConfig = new FullConfigInternal( + location, + userConfig, + overrides || {}, + metadata, + ); fullConfig.defineConfigWasUsed = !!(userConfig as any)[kDefineConfigWasUsed]; if (ignoreProjectDependencies) { for (const project of fullConfig.projects) { @@ -131,7 +205,8 @@ export async function loadConfigFromObject(location: ConfigLocation, userConfig: } // 3. Load transform options from the playwright config. - const babelPlugins = (userConfig as any)['@playwright/test']?.babelPlugins || []; + const babelPlugins = + (userConfig as any)['@playwright/test']?.babelPlugins || []; const external = userConfig.build?.external || []; const jsxImportSource = path.dirname(require.resolve('playwright')); setTransformConfig({ babelPlugins, external, jsxImportSource }); @@ -158,8 +233,12 @@ function validateConfig(file: string, config: Config) { if ('globalSetup' in config && config.globalSetup !== undefined) { if (Array.isArray(config.globalSetup)) { config.globalSetup.forEach((item, index) => { - if (typeof item !== 'string') - throw errorWithFile(file, `config.globalSetup[${index}] must be a string`); + if (typeof item !== 'string') { + throw errorWithFile( + file, + `config.globalSetup[${index}] must be a string`, + ); + } }); } else if (typeof config.globalSetup !== 'string') { throw errorWithFile(file, `config.globalSetup must be a string`); @@ -169,8 +248,12 @@ function validateConfig(file: string, config: Config) { if ('globalTeardown' in config && config.globalTeardown !== undefined) { if (Array.isArray(config.globalTeardown)) { config.globalTeardown.forEach((item, index) => { - if (typeof item !== 'string') - throw errorWithFile(file, `config.globalTeardown[${index}] must be a string`); + if (typeof item !== 'string') { + throw errorWithFile( + file, + `config.globalTeardown[${index}] must be a string`, + ); + } }); } else if (typeof config.globalTeardown !== 'string') { throw errorWithFile(file, `config.globalTeardown must be a string`); @@ -178,8 +261,12 @@ function validateConfig(file: string, config: Config) { } if ('globalTimeout' in config && config.globalTimeout !== undefined) { - if (typeof config.globalTimeout !== 'number' || config.globalTimeout < 0) - throw errorWithFile(file, `config.globalTimeout must be a non-negative number`); + if (typeof config.globalTimeout !== 'number' || config.globalTimeout < 0) { + throw errorWithFile( + file, + `config.globalTimeout must be a non-negative number`, + ); + } } if ('grep' in config && config.grep !== undefined) { @@ -196,8 +283,12 @@ function validateConfig(file: string, config: Config) { if ('grepInvert' in config && config.grepInvert !== undefined) { if (Array.isArray(config.grepInvert)) { config.grepInvert.forEach((item, index) => { - if (!isRegExp(item)) - throw errorWithFile(file, `config.grepInvert[${index}] must be a RegExp`); + if (!isRegExp(item)) { + throw errorWithFile( + file, + `config.grepInvert[${index}] must be a RegExp`, + ); + } }); } else if (!isRegExp(config.grepInvert)) { throw errorWithFile(file, `config.grepInvert must be a RegExp`); @@ -205,13 +296,24 @@ function validateConfig(file: string, config: Config) { } if ('maxFailures' in config && config.maxFailures !== undefined) { - if (typeof config.maxFailures !== 'number' || config.maxFailures < 0) - throw errorWithFile(file, `config.maxFailures must be a non-negative number`); + if (typeof config.maxFailures !== 'number' || config.maxFailures < 0) { + throw errorWithFile( + file, + `config.maxFailures must be a non-negative number`, + ); + } } if ('preserveOutput' in config && config.preserveOutput !== undefined) { - if (typeof config.preserveOutput !== 'string' || !['always', 'never', 'failures-only'].includes(config.preserveOutput)) - throw errorWithFile(file, `config.preserveOutput must be one of "always", "never" or "failures-only"`); + if ( + typeof config.preserveOutput !== 'string' || + !['always', 'never', 'failures-only'].includes(config.preserveOutput) + ) { + throw errorWithFile( + file, + `config.preserveOutput must be one of "always", "never" or "failures-only"`, + ); + } } if ('projects' in config && config.projects !== undefined) { @@ -230,35 +332,88 @@ function validateConfig(file: string, config: Config) { if ('reporter' in config && config.reporter !== undefined) { if (Array.isArray(config.reporter)) { config.reporter.forEach((item, index) => { - if (!Array.isArray(item) || item.length <= 0 || item.length > 2 || typeof item[0] !== 'string') - throw errorWithFile(file, `config.reporter[${index}] must be a tuple [name, optionalArgument]`); + if ( + !Array.isArray(item) || + item.length <= 0 || + item.length > 2 || + typeof item[0] !== 'string' + ) { + throw errorWithFile( + file, + `config.reporter[${index}] must be a tuple [name, optionalArgument]`, + ); + } }); } else if (typeof config.reporter !== 'string') { throw errorWithFile(file, `config.reporter must be a string`); } } - if ('reportSlowTests' in config && config.reportSlowTests !== undefined && config.reportSlowTests !== null) { + if ( + 'reportSlowTests' in config && + config.reportSlowTests !== undefined && + config.reportSlowTests !== null + ) { if (!config.reportSlowTests || typeof config.reportSlowTests !== 'object') throw errorWithFile(file, `config.reportSlowTests must be an object`); - if (!('max' in config.reportSlowTests) || typeof config.reportSlowTests.max !== 'number' || config.reportSlowTests.max < 0) - throw errorWithFile(file, `config.reportSlowTests.max must be a non-negative number`); - if (!('threshold' in config.reportSlowTests) || typeof config.reportSlowTests.threshold !== 'number' || config.reportSlowTests.threshold < 0) - throw errorWithFile(file, `config.reportSlowTests.threshold must be a non-negative number`); + if ( + !('max' in config.reportSlowTests) || + typeof config.reportSlowTests.max !== 'number' || + config.reportSlowTests.max < 0 + ) { + throw errorWithFile( + file, + `config.reportSlowTests.max must be a non-negative number`, + ); + } + if ( + !('threshold' in config.reportSlowTests) || + typeof config.reportSlowTests.threshold !== 'number' || + config.reportSlowTests.threshold < 0 + ) { + throw errorWithFile( + file, + `config.reportSlowTests.threshold must be a non-negative number`, + ); + } } - if ('shard' in config && config.shard !== undefined && config.shard !== null) { + if ( + 'shard' in config && + config.shard !== undefined && + config.shard !== null + ) { if (!config.shard || typeof config.shard !== 'object') throw errorWithFile(file, `config.shard must be an object`); - if (!('total' in config.shard) || typeof config.shard.total !== 'number' || config.shard.total < 1) + if ( + !('total' in config.shard) || + typeof config.shard.total !== 'number' || + config.shard.total < 1 + ) throw errorWithFile(file, `config.shard.total must be a positive number`); - if (!('current' in config.shard) || typeof config.shard.current !== 'number' || config.shard.current < 1 || config.shard.current > config.shard.total) - throw errorWithFile(file, `config.shard.current must be a positive number, not greater than config.shard.total`); + if ( + !('current' in config.shard) || + typeof config.shard.current !== 'number' || + config.shard.current < 1 || + config.shard.current > config.shard.total + ) { + throw errorWithFile( + file, + `config.shard.current must be a positive number, not greater than config.shard.total`, + ); + } } if ('updateSnapshots' in config && config.updateSnapshots !== undefined) { - if (typeof config.updateSnapshots !== 'string' || !['all', 'changed', 'missing', 'none'].includes(config.updateSnapshots)) - throw errorWithFile(file, `config.updateSnapshots must be one of "all", "changed", "missing" or "none"`); + if ( + typeof config.updateSnapshots !== 'string' || + !['all', 'changed', 'missing', 'none'].includes(config.updateSnapshots) + ) { + throw errorWithFile( + file, + `config.updateSnapshots must be one of "all", "changed", "missing" or "none"`, + ); + } } if ('tsconfig' in config && config.tsconfig !== undefined) { @@ -284,13 +439,21 @@ function validateProject(file: string, project: Project, title: string) { } if ('repeatEach' in project && project.repeatEach !== undefined) { - if (typeof project.repeatEach !== 'number' || project.repeatEach < 0) - throw errorWithFile(file, `${title}.repeatEach must be a non-negative number`); + if (typeof project.repeatEach !== 'number' || project.repeatEach < 0) { + throw errorWithFile( + file, + `${title}.repeatEach must be a non-negative number`, + ); + } } if ('retries' in project && project.retries !== undefined) { - if (typeof project.retries !== 'number' || project.retries < 0) - throw errorWithFile(file, `${title}.retries must be a non-negative number`); + if (typeof project.retries !== 'number' || project.retries < 0) { + throw errorWithFile( + file, + `${title}.retries must be a non-negative number`, + ); + } } if ('testDir' in project && project.testDir !== undefined) { @@ -303,18 +466,29 @@ function validateProject(file: string, project: Project, title: string) { const value = project[prop]; if (Array.isArray(value)) { value.forEach((item, index) => { - if (typeof item !== 'string' && !isRegExp(item)) - throw errorWithFile(file, `${title}.${prop}[${index}] must be a string or a RegExp`); + if (typeof item !== 'string' && !isRegExp(item)) { + throw errorWithFile( + file, + `${title}.${prop}[${index}] must be a string or a RegExp`, + ); + } }); } else if (typeof value !== 'string' && !isRegExp(value)) { - throw errorWithFile(file, `${title}.${prop} must be a string or a RegExp`); + throw errorWithFile( + file, + `${title}.${prop} must be a string or a RegExp`, + ); } } } if ('timeout' in project && project.timeout !== undefined) { - if (typeof project.timeout !== 'number' || project.timeout < 0) - throw errorWithFile(file, `${title}.timeout must be a non-negative number`); + if (typeof project.timeout !== 'number' || project.timeout < 0) { + throw errorWithFile( + file, + `${title}.timeout must be a non-negative number`, + ); + } } if ('use' in project && project.use !== undefined) { @@ -328,19 +502,30 @@ function validateProject(file: string, project: Project, title: string) { } if ('workers' in project && project.workers !== undefined) { - if (typeof project.workers === 'number' && project.workers <= 0) - throw errorWithFile(file, `${title}.workers must be a positive number`); - else if (typeof project.workers === 'string' && !project.workers.endsWith('%')) - throw errorWithFile(file, `${title}.workers must be a number or percentage`); + if (typeof project.workers === 'number' && project.workers <= 0) {throw errorWithFile(file, `${title}.workers must be a positive number`);} else if ( + typeof project.workers === 'string' && + !project.workers.endsWith('%') + ) { + throw errorWithFile( + file, + `${title}.workers must be a number or percentage`, + ); + } } } -export function resolveConfigLocation(configFile: string | undefined): ConfigLocation { - const configFileOrDirectory = configFile ? path.resolve(process.cwd(), configFile) : process.cwd(); +export function resolveConfigLocation( + configFile: string | undefined, +): ConfigLocation { + const configFileOrDirectory = configFile + ? path.resolve(process.cwd(), configFile) + : process.cwd(); const resolvedConfigFile = resolveConfigFile(configFileOrDirectory); return { resolvedConfigFile, - configDir: resolvedConfigFile ? path.dirname(resolvedConfigFile) : configFileOrDirectory, + configDir: resolvedConfigFile + ? path.dirname(resolvedConfigFile) + : configFileOrDirectory, }; } @@ -352,7 +537,9 @@ function resolveConfigFile(configFileOrDirectory: string): string | undefined { const resolveConfigFileFromDirectory = (directory: string) => { for (const ext of ['.ts', '.js', '.mts', '.mjs', '.cts', '.cjs']) { - const configFile = resolveConfig(path.resolve(directory, 'playwright.config' + ext)); + const configFile = resolveConfig( + path.resolve(directory, 'playwright.config' + ext), + ); if (configFile) return configFile; } @@ -372,8 +559,16 @@ function resolveConfigFile(configFileOrDirectory: string): string | undefined { return configFileOrDirectory!; } -export async function loadConfigFromFile(configFile: string | undefined, overrides?: ConfigCLIOverrides, ignoreDeps?: boolean): Promise { - return await loadConfig(resolveConfigLocation(configFile), overrides, ignoreDeps); +export async function loadConfigFromFile( + configFile: string | undefined, + overrides?: ConfigCLIOverrides, + ignoreDeps?: boolean, +): Promise { + return await loadConfig( + resolveConfigLocation(configFile), + overrides, + ignoreDeps, + ); } export async function loadEmptyConfigForMergeReports() { diff --git a/packages/playwright/src/common/ipc.ts b/packages/playwright/src/common/ipc.ts index c4f045af60bd8..315960e9eef16 100644 --- a/packages/playwright/src/common/ipc.ts +++ b/packages/playwright/src/common/ipc.ts @@ -18,9 +18,14 @@ import util from 'util'; import { serializeCompilationCache } from '../transform/compilationCache'; +import type { + Config, + ReporterDescription, + TestInfoError, + TestStatus, +} from '../../types/test'; +import type { SerializedCompilationCache } from '../transform/compilationCache'; import type { ConfigLocation, FullConfigInternal } from './config'; -import type { ReporterDescription, TestInfoError, TestStatus } from '../../types/test'; -import type { SerializedCompilationCache } from '../transform/compilationCache'; export type ConfigCLIOverrides = { debug?: 'inspector' | 'cli'; @@ -35,19 +40,20 @@ export type ConfigCLIOverrides = { repeatEach?: number; retries?: number; reporter?: ReporterDescription[]; - shard?: { current: number, total: number }; + shard?: { current: number; total: number }; timeout?: number; tsconfig?: string; ignoreSnapshots?: boolean; updateSnapshots?: 'all' | 'changed' | 'missing' | 'none'; updateSourceMethod?: 'overwrite' | 'patch' | '3way'; workers?: number | string; - projects?: { name: string, use?: any }[], + projects?: { name: string; use?: any }[]; use?: any; }; export type SerializedConfig = { location: ConfigLocation; + programmaticUserConfig?: Config; configCLIOverrides: ConfigCLIOverrides; compilationCache?: SerializedCompilationCache; metadata?: string; @@ -72,7 +78,7 @@ export type WorkerInitParams = { export type TestBeginPayload = { testId: string; - startWallTime: number; // milliseconds since unix epoch + startWallTime: number; // milliseconds since unix epoch }; export type AttachmentPayload = { @@ -116,7 +122,7 @@ export type TestEndPayload = { errors: TestInfoErrorPayload[]; hasNonRetriableError: boolean; expectedStatus: TestStatus; - annotations: { type: string, description?: string }[]; + annotations: { type: string; description?: string }[]; timeout: number; }; @@ -126,17 +132,17 @@ export type StepBeginPayload = { parentStepId: string | undefined; title: string; category: string; - wallTime: number; // milliseconds since unix epoch - location?: { file: string, line: number, column: number }; + wallTime: number; // milliseconds since unix epoch + location?: { file: string; line: number; column: number }; }; export type StepEndPayload = { testId: string; stepId: string; - wallTime: number; // milliseconds since unix epoch + wallTime: number; // milliseconds since unix epoch error?: TestInfoErrorPayload; suggestedRebaseline?: string; - annotations: { type: string, description?: string }[]; + annotations: { type: string; description?: string }[]; }; export type TestEntry = { @@ -151,7 +157,7 @@ export type RunPayload = { export type DonePayload = { fatalErrors: TestInfoErrorPayload[]; - skipTestsDueToSetupFailure: string[]; // test ids + skipTestsDueToSetupFailure: string[]; // test ids fatalUnknownTestIds?: string[]; stoppedDueToUnhandledErrorInTestFail?: boolean; }; @@ -167,11 +173,20 @@ export type TeardownErrorsPayload = { export type EnvProducedPayload = [string, string | null][]; -export function serializeConfig(config: FullConfigInternal, passCompilationCache: boolean): SerializedConfig { +export function serializeConfig( + config: FullConfigInternal, + passCompilationCache: boolean, +): SerializedConfig { const result: SerializedConfig = { - location: { configDir: config.configDir, resolvedConfigFile: config.config.configFile }, + location: { + configDir: config.configDir, + resolvedConfigFile: config.config.configFile, + }, + programmaticUserConfig: (config as any).__programmaticUserConfig, configCLIOverrides: config.configCLIOverrides, - compilationCache: passCompilationCache ? serializeCompilationCache() : undefined, + compilationCache: passCompilationCache + ? serializeCompilationCache() + : undefined, }; try { @@ -181,7 +196,9 @@ export function serializeConfig(config: FullConfigInternal, passCompilationCache return result; } -export function stdioChunkToParams(chunk: Uint8Array | string): TestOutputPayload { +export function stdioChunkToParams( + chunk: Uint8Array | string, +): TestOutputPayload { if (chunk instanceof Uint8Array) return { buffer: Buffer.from(chunk).toString('base64') }; if (typeof chunk !== 'string') @@ -189,7 +206,9 @@ export function stdioChunkToParams(chunk: Uint8Array | string): TestOutputPayloa return { text: chunk }; } -export function toTestInfoErrorPayload(error: TestInfoError): TestInfoErrorPayload { +export function toTestInfoErrorPayload( + error: TestInfoError, +): TestInfoErrorPayload { const result: TestInfoErrorPayload = {}; if (error.message !== undefined) result.message = error.message; diff --git a/packages/playwright/src/programmaticRunner.ts b/packages/playwright/src/programmaticRunner.ts index bdb772e5636f9..cc8a844bde4f3 100644 --- a/packages/playwright/src/programmaticRunner.ts +++ b/packages/playwright/src/programmaticRunner.ts @@ -17,10 +17,12 @@ import { configLoader } from './common'; import { testRunner, workerHost } from './runner'; -import type { ConfigLocation } from './common'; -import type { StructuredTestSelection } from './runner/loadUtils'; import type { Config } from '../types/test'; import type { FullResult } from '../types/testReporter'; +import type { ConfigLocation } from './common'; +import type { FullConfigInternal } from './common/config'; +import type { ConfigCLIOverrides } from './common/ipc'; +import type { StructuredTestSelection } from './runner/loadUtils'; type ConfigLocationInput = string | ConfigLocation; @@ -45,13 +47,25 @@ export class PreforkedWorkers { } } -export async function loadUserConfig(location: ConfigLocationInput): Promise { +export async function loadUserConfig( + location: ConfigLocationInput, +): Promise { return await configLoader.loadUserConfig(resolveLocation(location)); } -export async function runTests(params: RunTestsParams): Promise { +export async function runTests( + params: RunTestsParams, +): Promise { const location = resolveLocation(params.configLocation); - const config = await configLoader.loadConfigFromObject(location, params.config, {}, params.ignoreProjectDependencies); + await configLoader.prepareConfigLoading(location); + const config = await configLoader.loadConfigFromObject( + location, + params.config, + {}, + params.ignoreProjectDependencies, + ); + (config as any).__programmaticUserConfig = params.config; + applyWorkerConfigCLIOverrides(config); validateTestSelection(params.testSelection); const status = await testRunner.runAllTestsWithConfig(config, { projectFilter: projectFilterFromSelection(params.testSelection), @@ -66,28 +80,41 @@ function validateTestSelection(selection: StructuredTestSelection) { if (!selection || !selection.tests.length) throw new Error('Programmatic runner requires at least one selected test'); for (const test of selection.tests) { - if (test.projectName === undefined) - throw new Error('Programmatic runner selected test must specify projectName'); + if (test.projectName === undefined) { + throw new Error( + 'Programmatic runner selected test must specify projectName', + ); + } if (!test.file) throw new Error('Programmatic runner selected test must specify file'); - if (!test.titlePath?.length) - throw new Error('Programmatic runner selected test must specify non-empty titlePath'); + if (!test.titlePath?.length) { + throw new Error( + 'Programmatic runner selected test must specify non-empty titlePath', + ); + } } } -function projectFilterFromSelection(selection: StructuredTestSelection): string[] { +function projectFilterFromSelection( + selection: StructuredTestSelection, +): string[] { return [...new Set(selection.tests.map(test => test.projectName))]; } -export async function createPreforkedWorkers(params: { workers: number }): Promise { +export async function createPreforkedWorkers(params: { + workers: number; +}): Promise { const workers: workerHost.WorkerHost[] = []; try { for (let i = 0; i < params.workers; i++) { const worker = new workerHost.WorkerHost(i); workers.push(worker); const error = await worker.prefork(); - if (error) - throw new Error(`Worker process exited before it was ready (code=${error.code}, signal=${error.signal})`); + if (error) { + throw new Error( + `Worker process exited before it was ready (code=${error.code}, signal=${error.signal})`, + ); + } } } catch (e) { await Promise.all(workers.map(worker => worker.stop().catch(() => {}))); @@ -96,8 +123,12 @@ export async function createPreforkedWorkers(params: { workers: number }): Promi return new PreforkedWorkers(workers); } -export async function disposePreforkedWorkers(workers: PreforkedWorkers): Promise { - await Promise.all(workers.workers.map(worker => worker.stop().catch(() => {}))); +export async function disposePreforkedWorkers( + workers: PreforkedWorkers, +): Promise { + await Promise.all( + workers.workers.map(worker => worker.stop().catch(() => {})), + ); } function resolveLocation(location: ConfigLocationInput): ConfigLocation { @@ -105,3 +136,58 @@ function resolveLocation(location: ConfigLocationInput): ConfigLocation { return configLoader.resolveConfigLocation(location); return location; } + +function applyWorkerConfigCLIOverrides(config: FullConfigInternal) { + const overrides: ConfigCLIOverrides = { + ...config.configCLIOverrides, + failOnFlakyTests: config.failOnFlakyTests, + forbidOnly: config.config.forbidOnly, + fullyParallel: config.config.fullyParallel, + globalTimeout: config.config.globalTimeout, + reporter: config.config.reporter, + quiet: config.config.quiet, + workers: config.config.workers, + maxFailures: config.config.maxFailures, + shard: config.config.shard || undefined, + updateSnapshots: config.config.updateSnapshots, + updateSourceMethod: config.config.updateSourceMethod, + }; + + const ignoreSnapshots = commonProjectValue(config, 'ignoreSnapshots'); + if (ignoreSnapshots !== undefined) + overrides.ignoreSnapshots = ignoreSnapshots; + const repeatEach = commonProjectValue(config, 'repeatEach'); + if (repeatEach !== undefined) + overrides.repeatEach = repeatEach; + const timeout = commonProjectValue(config, 'timeout'); + if (timeout !== undefined) + overrides.timeout = timeout; + const retries = commonProjectValue(config, 'retries'); + if (retries !== undefined) + overrides.retries = retries; + const outputDir = commonProjectValue(config, 'outputDir'); + if (outputDir !== undefined) + overrides.outputDir = outputDir; + + Object.assign(config.configCLIOverrides, overrides); +} + +function commonProjectValue< + T extends + | 'ignoreSnapshots' + | 'repeatEach' + | 'timeout' + | 'retries' + | 'outputDir', +>( + config: FullConfigInternal, + property: T, +): FullConfigInternal['projects'][number]['project'][T] | undefined { + const projects = config.projects; + if (!projects.length) + return undefined; + const first = projects[0].project[property]; + return projects.every(project => project.project[property] === first) + ? first + : undefined; +} diff --git a/packages/playwright/src/runner/processHost.ts b/packages/playwright/src/runner/processHost.ts index e3d00ea537e57..5db8180c03a03 100644 --- a/packages/playwright/src/runner/processHost.ts +++ b/packages/playwright/src/runner/processHost.ts @@ -68,6 +68,7 @@ export class ProcessHost extends EventEmitter { (options.onStdErr && !process.env.PW_RUNNER_DEBUG) ? 'pipe' : 'inherit', 'ipc', ], + serialization: 'advanced', }); this.process.on('exit', async (code, signal) => { this._processDidExit = true; diff --git a/utils/endform/programmatic-runner-smoke.js b/utils/endform/programmatic-runner-smoke.js index f7724ec7789f5..11f036b41197b 100644 --- a/utils/endform/programmatic-runner-smoke.js +++ b/utils/endform/programmatic-runner-smoke.js @@ -15,20 +15,21 @@ * limitations under the License. */ -const fs = require('fs'); -const os = require('os'); -const path = require('path'); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); function parseArgs() { const result = { playwrightRoot: process.cwd() }; for (let i = 2; i < process.argv.length; i++) { const arg = process.argv[i]; - if (arg === '--playwright-root') + if (arg === "--playwright-root") result.playwrightRoot = path.resolve(process.argv[++i]); - else if (arg.startsWith('--playwright-root=')) - result.playwrightRoot = path.resolve(arg.substring('--playwright-root='.length)); - else - throw new Error(`Unknown argument: ${arg}`); + else if (arg.startsWith("--playwright-root=")) + result.playwrightRoot = path.resolve( + arg.substring("--playwright-root=".length), + ); + else throw new Error(`Unknown argument: ${arg}`); } return result; } @@ -37,50 +38,72 @@ function requireFirst(candidates, label) { const errors = []; for (const candidate of candidates) { try { - if (fs.existsSync(candidate)) - return require(candidate); + if (fs.existsSync(candidate)) return require(candidate); } catch (e) { errors.push(`${candidate}: ${e.message}`); } } - throw new Error([ - `Could not load ${label}.`, - `Tried:`, - ...candidates.map(candidate => ` ${candidate}`), - `Build Playwright first, or pass --playwright-root to a built patched checkout/package.`, - ...errors.map(error => ` ${error}`), - ].join('\n')); + throw new Error( + [ + `Could not load ${label}.`, + `Tried:`, + ...candidates.map((candidate) => ` ${candidate}`), + `Build Playwright first, or pass --playwright-root to a built patched checkout/package.`, + ...errors.map((error) => ` ${error}`), + ].join("\n"), + ); } function publicRunner(root) { - return requireFirst(publicRunnerCandidates(root, '.js'), 'playwright/programmatic-runner'); + return requireFirst( + publicRunnerCandidates(root, ".js"), + "playwright/programmatic-runner", + ); } function publicRunnerCandidates(root, extension) { return [ - path.join(root, 'packages', 'playwright', `programmatic-runner${extension}`), + path.join( + root, + "packages", + "playwright", + `programmatic-runner${extension}`, + ), path.join(root, `programmatic-runner${extension}`), - path.join(root, 'node_modules', 'playwright', `programmatic-runner${extension}`), + path.join( + root, + "node_modules", + "playwright", + `programmatic-runner${extension}`, + ), ]; } function firstExisting(candidates, label) { - const result = candidates.find(candidate => fs.existsSync(candidate)); + const result = candidates.find((candidate) => fs.existsSync(candidate)); if (!result) - throw new Error(`Could not find ${label}. Tried:\n${candidates.map(candidate => ` ${candidate}`).join('\n')}`); + throw new Error( + `Could not find ${label}. Tried:\n${candidates.map((candidate) => ` ${candidate}`).join("\n")}`, + ); return result; } function publicRunnerESM(root) { - return firstExisting(publicRunnerCandidates(root, '.mjs'), 'playwright/programmatic-runner ESM entry'); + return firstExisting( + publicRunnerCandidates(root, ".mjs"), + "playwright/programmatic-runner ESM entry", + ); } function playwrightTestEntry(root) { - return firstExisting([ - path.join(root, 'packages', 'playwright', 'test.js'), - path.join(root, 'test.js'), - path.join(root, 'node_modules', 'playwright', 'test.js'), - ], 'playwright/test entry'); + return firstExisting( + [ + path.join(root, "packages", "playwright", "test.js"), + path.join(root, "test.js"), + path.join(root, "node_modules", "playwright", "test.js"), + ], + "playwright/test entry", + ); } async function writeFile(filePath, text) { @@ -89,8 +112,7 @@ async function writeFile(filePath, text) { } function assert(condition, message) { - if (!condition) - throw new Error(message); + if (!condition) throw new Error(message); } class SmokeReporter { @@ -103,76 +125,85 @@ class SmokeReporter { } version() { - return 'v2'; + return "v2"; } onConfigure(config) { - this.events.push('onConfigure'); + this.events.push("onConfigure"); this.configMetadata = config.metadata; } onBegin(suite) { - this.events.push('onBegin'); + this.events.push("onBegin"); this.testCount = suite.allTests().length; } onTestBegin(test, result) { - this.events.push('onTestBegin'); + this.events.push("onTestBegin"); this.testTitle = test.title; this.workerIndex = result.workerIndex; } onStepBegin(test, result, step) { - this.events.push('onStepBegin:' + step.title); + this.events.push("onStepBegin:" + step.title); } onStepEnd(test, result, step) { - this.events.push('onStepEnd:' + step.title); + this.events.push("onStepEnd:" + step.title); } onStdOut(chunk, test, result) { - if (String(chunk).includes('stdout-from-programmatic-test')) { - this.events.push('onStdOut'); + if (String(chunk).includes("stdout-from-programmatic-test")) { + this.events.push("onStdOut"); this.stdoutWithTest = !!test && !!result; } } onStdErr(chunk, test, result) { - if (String(chunk).includes('stderr-from-programmatic-test')) { - this.events.push('onStdErr'); + if (String(chunk).includes("stderr-from-programmatic-test")) { + this.events.push("onStdErr"); this.stderrWithTest = !!test && !!result; } } onTestEnd(test, result) { - this.events.push('onTestEnd:' + result.status); - this.attachments = result.attachments.map(a => ({ name: a.name, contentType: a.contentType, body: a.body && a.body.toString() })); + this.events.push("onTestEnd:" + result.status); + this.resultErrors = result.errors.map((error) => error.message); + this.attachments = result.attachments.map((a) => ({ + name: a.name, + contentType: a.contentType, + body: a.body && a.body.toString(), + })); } onError(error) { - this.events.push('onError'); + this.events.push("onError"); this.errors = this.errors || []; this.errors.push(error.message); } onEnd(result) { - this.events.push('onEnd:' + result.status); + this.events.push("onEnd:" + result.status); this.finalStatus = result.status; } onExit() { - this.events.push('onExit'); - fs.writeFileSync(this.eventsFile, JSON.stringify({ - events: this.events, - configMetadata: this.configMetadata, - finalStatus: this.finalStatus, - testCount: this.testCount, - testTitle: this.testTitle, - stdoutWithTest: this.stdoutWithTest, - stderrWithTest: this.stderrWithTest, - attachments: this.attachments, - errors: this.errors || [], - })); + this.events.push("onExit"); + fs.writeFileSync( + this.eventsFile, + JSON.stringify({ + events: this.events, + configMetadata: this.configMetadata, + finalStatus: this.finalStatus, + testCount: this.testCount, + testTitle: this.testTitle, + stdoutWithTest: this.stdoutWithTest, + stderrWithTest: this.stderrWithTest, + attachments: this.attachments, + resultErrors: this.resultErrors || [], + errors: this.errors || [], + }), + ); } } @@ -180,30 +211,53 @@ async function main() { const { playwrightRoot } = parseArgs(); const runner = publicRunner(playwrightRoot); const esmRunner = await import(publicRunnerESM(playwrightRoot)); - assert(typeof esmRunner.runTests === 'function', 'Expected ESM entry to export runTests'); - const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pw-programmatic-runner-smoke-')); - const configFile = path.join(tmpDir, 'playwright.config.js'); - const reporterFile = path.join(tmpDir, 'smoke-reporter.js'); - const eventsFile = path.join(tmpDir, 'smoke-events.json'); - const testFile = path.join(tmpDir, 'programmatic-smoke.spec.js'); + assert( + typeof esmRunner.runTests === "function", + "Expected ESM entry to export runTests", + ); + const tmpDir = await fs.promises.mkdtemp( + path.join(os.tmpdir(), "pw-programmatic-runner-smoke-"), + ); + const configFile = path.join(tmpDir, "playwright.config.js"); + const reporterFile = path.join(tmpDir, "smoke-reporter.js"); + const eventsFile = path.join(tmpDir, "smoke-events.json"); + const testFile = path.join(tmpDir, "programmatic-smoke.spec.js"); const testEntry = playwrightTestEntry(playwrightRoot); const preforkedWorkers = await runner.createPreforkedWorkers({ workers: 1 }); - await writeFile(path.join(tmpDir, 'node_modules', '@playwright', 'test', 'index.js'), `module.exports = require(${JSON.stringify(testEntry)});\n`); - await writeFile(reporterFile, `const fs = require('fs');\nmodule.exports = ${SmokeReporter.toString()};\n`); - await writeFile(configFile, ` + await writeFile( + path.join(tmpDir, "node_modules", "@playwright", "test", "index.js"), + `module.exports = require(${JSON.stringify(testEntry)});\n`, + ); + await writeFile( + reporterFile, + `const fs = require('fs');\nmodule.exports = ${SmokeReporter.toString()};\n`, + ); + await writeFile( + configFile, + ` module.exports = { testDir: ${JSON.stringify(tmpDir)}, + testMatch: /programmatic-smoke\.spec\.js/, workers: 4, metadata: { fromConfigFile: true }, - reporter: 'line', + reporter: './local-only-reporter.js', + use: { storageState: { origins: [{ origin: 'https://top-level.example', localStorage: [] }] } }, + projects: [{ + name: 'programmatic-project', + use: { storageState: { origins: [{ origin: 'https://project-level.example', localStorage: [] }] } }, + }], }; -`); - await writeFile(testFile, ` +`, + ); + await writeFile( + testFile, + ` const { test, expect } = require('@playwright/test'); test('programmatic smoke', async ({}, testInfo) => { expect(process.env.ENDFORM_LATE_ENV).toBe('from-init'); + expect(testInfo.project.use.storageState.origins[0].origin).toBe('https://project-level.example'); console.log('stdout-from-programmatic-test'); console.error('stderr-from-programmatic-test'); await test.step('programmatic step', async () => { @@ -218,7 +272,8 @@ test('programmatic smoke', async ({}, testInfo) => { test('not selected', async () => { throw new Error('This test should not run'); }); -`); +`, + ); try { const config = await runner.loadUserConfig(configFile); @@ -230,33 +285,88 @@ test('not selected', async () => { configLocation: configFile, config, ignoreProjectDependencies: true, - testSelection: { tests: [{ projectName: '', file: testFile, titlePath: ['programmatic smoke'] }] }, + testSelection: { + tests: [ + { + projectName: "programmatic-project", + file: testFile, + titlePath: ["programmatic smoke"], + }, + ], + }, preforkedWorkers, - workerEnv: { ENDFORM_LATE_ENV: 'from-init' }, + workerEnv: { ENDFORM_LATE_ENV: "from-init" }, }); - const reporter = JSON.parse(await fs.promises.readFile(eventsFile, 'utf-8')); - - assert(result.status === 'passed', `Expected run status passed, got ${result.status}. Test count: ${reporter.testCount}. Events: ${reporter.events.join(', ')}. Errors: ${JSON.stringify(reporter.errors || [])}`); - assert(reporter.finalStatus === 'passed', `Expected reporter final status passed, got ${reporter.finalStatus}`); - assert(reporter.testCount === 1, `Expected one test in onBegin, got ${reporter.testCount}`); - assert(reporter.testTitle === 'programmatic smoke', `Expected programmatic smoke test, got ${reporter.testTitle}`); - assert(reporter.configMetadata.fromMutatedConfig, `Expected mutated metadata, got ${JSON.stringify(reporter.configMetadata)}`); - assert(reporter.stdoutWithTest, 'Expected stdout to be attributed to test/result'); - assert(reporter.stderrWithTest, 'Expected stderr to be attributed to test/result'); - assert(reporter.events.includes('onStepBegin:programmatic step'), `Missing step begin. Events: ${reporter.events.join(', ')}`); - assert(reporter.events.includes('onStepEnd:programmatic step'), `Missing step end. Events: ${reporter.events.join(', ')}`); - assert(reporter.attachments.some(a => a.name === 'programmatic-attachment' && a.body === 'attachment-body'), `Missing attachment. Attachments: ${JSON.stringify(reporter.attachments)}`); - for (const event of ['onConfigure', 'onBegin', 'onTestBegin', 'onStdOut', 'onStdErr', 'onTestEnd:passed', 'onEnd:passed', 'onExit']) - assert(reporter.events.includes(event), `Missing reporter event ${event}. Events: ${reporter.events.join(', ')}`); - - console.log('PROGRAMMATIC_RUNNER_SMOKE_OK'); + const reporter = JSON.parse( + await fs.promises.readFile(eventsFile, "utf-8"), + ); + + assert( + result.status === "passed", + `Expected run status passed, got ${result.status}. Test count: ${reporter.testCount}. Events: ${reporter.events.join(", ")}. Errors: ${JSON.stringify(reporter.errors || [])}. Result errors: ${JSON.stringify(reporter.resultErrors || [])}`, + ); + assert( + reporter.finalStatus === "passed", + `Expected reporter final status passed, got ${reporter.finalStatus}`, + ); + assert( + reporter.testCount === 1, + `Expected one test in onBegin, got ${reporter.testCount}`, + ); + assert( + reporter.testTitle === "programmatic smoke", + `Expected programmatic smoke test, got ${reporter.testTitle}`, + ); + assert( + reporter.configMetadata.fromMutatedConfig, + `Expected mutated metadata, got ${JSON.stringify(reporter.configMetadata)}`, + ); + assert( + reporter.stdoutWithTest, + "Expected stdout to be attributed to test/result", + ); + assert( + reporter.stderrWithTest, + "Expected stderr to be attributed to test/result", + ); + assert( + reporter.events.includes("onStepBegin:programmatic step"), + `Missing step begin. Events: ${reporter.events.join(", ")}`, + ); + assert( + reporter.events.includes("onStepEnd:programmatic step"), + `Missing step end. Events: ${reporter.events.join(", ")}`, + ); + assert( + reporter.attachments.some( + (a) => + a.name === "programmatic-attachment" && a.body === "attachment-body", + ), + `Missing attachment. Attachments: ${JSON.stringify(reporter.attachments)}`, + ); + for (const event of [ + "onConfigure", + "onBegin", + "onTestBegin", + "onStdOut", + "onStdErr", + "onTestEnd:passed", + "onEnd:passed", + "onExit", + ]) + assert( + reporter.events.includes(event), + `Missing reporter event ${event}. Events: ${reporter.events.join(", ")}`, + ); + + console.log("PROGRAMMATIC_RUNNER_SMOKE_OK"); console.log(JSON.stringify({ events: reporter.events })); } finally { await runner.disposePreforkedWorkers(preforkedWorkers); } } -main().catch(e => { +main().catch((e) => { console.error(e.stack || e.message || String(e)); process.exit(1); });