diff --git a/.github/workflows/cr.yml b/.github/workflows/cr.yml index f74b0cd4daa..39f7c46e3a2 100644 --- a/.github/workflows/cr.yml +++ b/.github/workflows/cr.yml @@ -31,5 +31,14 @@ jobs: NODE_ENV: production GITHUB_AUTH: ${{ secrets.GITHUB_TOKEN }} - - name: Publish - run: pnpx pkg-pr-new publish --pnpm './packages/*' + - name: Publish Preview Packages + # Allow PR #52 previews for Testplane integration testing, but not fork PRs. + if: >- + github.event_name == 'push' || + (github.event.pull_request.number == 52 && + github.event.pull_request.head.repo.full_name == github.repository) + run: >- + pnpm exec pkg-pr-new publish --pnpm --no-compact --commentWithSha + './packages/wdio-types' './packages/wdio-logger' './packages/wdio-protocols' + './packages/wdio-repl' './packages/wdio-utils' './packages/wdio-config' + './packages/webdriver' './packages/webdriverio' './packages/devtools' diff --git a/.github/workflows/test-component.yml b/.github/workflows/test-component.yml index 7762f06c50f..c97cf10c617 100644 --- a/.github/workflows/test-component.yml +++ b/.github/workflows/test-component.yml @@ -11,7 +11,7 @@ jobs: strategy: fail-fast: false matrix: - node: ['18', '20', '22'] + node: ['22.12.0', '22', '24'] os: [ # 'ubuntu-latest', 'windows-latest', diff --git a/.github/workflows/test-launch.yml b/.github/workflows/test-launch.yml index 5ea3f30025d..283dfae7792 100644 --- a/.github/workflows/test-launch.yml +++ b/.github/workflows/test-launch.yml @@ -11,7 +11,7 @@ jobs: strategy: fail-fast: false matrix: - os: ['ubuntu-latest', 'windows-latest', 'macos-13'] + os: ['ubuntu-latest', 'windows-latest', 'macos-15-intel'] runs-on: ${{ matrix.os }} steps: - name: ⬇️ Checkout Code diff --git a/.github/workflows/test-multiremote.yml b/.github/workflows/test-multiremote.yml index fd6262febce..6ed068e8ebc 100644 --- a/.github/workflows/test-multiremote.yml +++ b/.github/workflows/test-multiremote.yml @@ -11,7 +11,7 @@ jobs: strategy: fail-fast: false matrix: - os: ['windows-latest', 'macos-13'] + os: ['windows-latest', 'macos-15-intel'] runs-on: ${{ matrix.os }} steps: - name: ⬇️ Checkout Code diff --git a/.github/workflows/test-smoke.yml b/.github/workflows/test-smoke.yml index b3cca5d07ed..1c80e4269bd 100644 --- a/.github/workflows/test-smoke.yml +++ b/.github/workflows/test-smoke.yml @@ -11,7 +11,7 @@ jobs: strategy: fail-fast: false matrix: - node: ['18.20.0', '20', '22'] + node: ['22.12.0', '22', '24'] os: ['ubuntu-latest', 'windows-latest', 'macos-latest'] runs-on: ${{ matrix.os }} steps: diff --git a/.github/workflows/test-standalone.yml b/.github/workflows/test-standalone.yml index bd65a922e33..096bb4c5cc5 100644 --- a/.github/workflows/test-standalone.yml +++ b/.github/workflows/test-standalone.yml @@ -11,7 +11,7 @@ jobs: strategy: fail-fast: false matrix: - os: ['windows-latest', 'macos-13'] + os: ['windows-latest', 'macos-15-intel'] runs-on: ${{ matrix.os }} steps: - name: ⬇️ Checkout Code diff --git a/.github/workflows/test-testrunner.yml b/.github/workflows/test-testrunner.yml index cd208ed4df6..cc6d943a376 100644 --- a/.github/workflows/test-testrunner.yml +++ b/.github/workflows/test-testrunner.yml @@ -11,7 +11,7 @@ jobs: strategy: fail-fast: false matrix: - os: ['windows-latest', 'macos-13'] + os: ['windows-latest', 'macos-15-intel'] runs-on: ${{ matrix.os }} steps: - name: ⬇️ Checkout Code diff --git a/.github/workflows/test-unit.yml b/.github/workflows/test-unit.yml index d4838145ab5..da549bde5f0 100644 --- a/.github/workflows/test-unit.yml +++ b/.github/workflows/test-unit.yml @@ -11,7 +11,7 @@ jobs: strategy: fail-fast: false matrix: - node: ['18', '20', '22'] + node: ['22.12.0', '22', '24'] os: ['ubuntu-latest', 'windows-latest', 'macos-latest'] runs-on: ${{ matrix.os }} steps: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1e074372335..15fd673fa00 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -46,33 +46,28 @@ jobs: needs: [build_core, static_code_analysis] uses: ./.github/workflows/test-smoke.yml - component_tests: - name: Component Tests - needs: [build_core, static_code_analysis] - uses: ./.github/workflows/test-component.yml - launch_tests: name: E2E Tests - needs: [smoke_tests, component_tests, unit_tests, interop_tests, typing_tests] + needs: [smoke_tests, unit_tests, interop_tests, typing_tests] uses: ./.github/workflows/test-launch.yml cloud_tests: name: E2E Tests - needs: [smoke_tests, component_tests, unit_tests, interop_tests, typing_tests] + needs: [smoke_tests, unit_tests, interop_tests, typing_tests] uses: ./.github/workflows/test-cloud.yml secrets: inherit testrunner_tests: name: E2E Tests - needs: [smoke_tests, component_tests, unit_tests, interop_tests, typing_tests] + needs: [smoke_tests, unit_tests, interop_tests, typing_tests] uses: ./.github/workflows/test-testrunner.yml multiremote_tests: name: E2E Tests - needs: [smoke_tests, component_tests, unit_tests, interop_tests, typing_tests] + needs: [smoke_tests, unit_tests, interop_tests, typing_tests] uses: ./.github/workflows/test-multiremote.yml standalone_tests: name: E2E Tests - needs: [smoke_tests, component_tests, unit_tests, interop_tests, typing_tests] + needs: [smoke_tests, unit_tests, interop_tests, typing_tests] uses: ./.github/workflows/test-standalone.yml diff --git a/.nvmrc b/.nvmrc index ee09fac75c8..c6a66a6e6a6 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v20.11.1 +v22.21.1 diff --git a/__mocks__/@testplane/wdio-config.ts b/__mocks__/@testplane/wdio-config.ts new file mode 100644 index 00000000000..1db7612abd7 --- /dev/null +++ b/__mocks__/@testplane/wdio-config.ts @@ -0,0 +1 @@ +export * from '../@wdio/config.js' diff --git a/__mocks__/@testplane/wdio-config/node.ts b/__mocks__/@testplane/wdio-config/node.ts new file mode 100644 index 00000000000..a9890b923a7 --- /dev/null +++ b/__mocks__/@testplane/wdio-config/node.ts @@ -0,0 +1 @@ +export * from '../../@wdio/config/node.js' diff --git a/__mocks__/@testplane/wdio-repl.ts b/__mocks__/@testplane/wdio-repl.ts new file mode 100644 index 00000000000..a8a917f4683 --- /dev/null +++ b/__mocks__/@testplane/wdio-repl.ts @@ -0,0 +1 @@ +export { default } from '../@wdio/repl.js' diff --git a/__mocks__/@testplane/wdio-utils.ts b/__mocks__/@testplane/wdio-utils.ts new file mode 100644 index 00000000000..31cd20b8de2 --- /dev/null +++ b/__mocks__/@testplane/wdio-utils.ts @@ -0,0 +1 @@ +export * from '../@wdio/utils.js' diff --git a/__mocks__/@testplane/wdio-utils/node.ts b/__mocks__/@testplane/wdio-utils/node.ts new file mode 100644 index 00000000000..61977a1f52c --- /dev/null +++ b/__mocks__/@testplane/wdio-utils/node.ts @@ -0,0 +1 @@ +export * from '../../@wdio/utils/node.js' diff --git a/__mocks__/fetch.ts b/__mocks__/fetch.ts index 4fee0b308be..025ca8c2de8 100644 --- a/__mocks__/fetch.ts +++ b/__mocks__/fetch.ts @@ -1,6 +1,16 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { transferableAbortController } from 'node:util' import { vi } from 'vitest' +// JSDOM supplies AbortController/AbortSignal but no fetch/Request. Keep the +// cancellation primitives in the same realm as Node's native Request; Node 24 +// rejects JSDOM's signal before our fetch mock can receive the request. +if (typeof window !== 'undefined') { + const controller = transferableAbortController() + vi.stubGlobal('AbortController', controller.constructor) + vi.stubGlobal('AbortSignal', controller.signal.constructor) +} + /** * This flag helps to indicate that WebdriverIO is running in a unit test environment. * Setting this environment changes the behavior of some functions to e.g. not exit @@ -81,7 +91,7 @@ const requestMock: any = vi.fn().mockImplementation((uri, params) => { if ( body && body.capabilities && - body.capabilities.alwaysMatch.jsonwpMode + (body.capabilities.alwaysMatch.jsonwpMode || body.capabilities.alwaysMatch.browserName?.endsWith('-noW3C')) ) { jsonwpMode = true sessionResponse = { @@ -518,4 +528,28 @@ requestMock.resetSessionId = () => { sessionId = defaultSessionId } -vi.stubGlobal('fetch', requestMock) +/** + * Keep protocol assertions independent of fetch's two equivalent call forms. + * Ky sends Request objects; other callers send URL + RequestInit. Record both + * as URL + RequestInit only after reading the actual serialized request body. + * Forward all spy controls to the same recorder (including mockClear/reset). + */ +const fetchMock = new Proxy(requestMock, { + async apply (target, thisArg, [input, init]) { + if (input instanceof Request) { + const request = input.clone() + const body = request.body ? await request.text() : undefined + return Reflect.apply(target, thisArg, [new URL(request.url), { + method: request.method, + headers: Object.fromEntries(request.headers), + body, + json: body ? JSON.parse(body) : undefined, + signal: request.signal, + ...init + }]) + } + return Reflect.apply(target, thisArg, [input, init]) + } +}) + +vi.stubGlobal('fetch', fetchMock) diff --git a/__mocks__/puppeteer-core.ts b/__mocks__/puppeteer-core.ts index a8a27c9ee8e..fd5b15ce69a 100644 --- a/__mocks__/puppeteer-core.ts +++ b/__mocks__/puppeteer-core.ts @@ -66,6 +66,8 @@ class PageMock2 extends PageMock { const page2 = new PageMock2() class PuppeteerMock { + connected = true + disconnect = vi.fn().mockImplementation(async () => { this.connected = false }) on = vi.fn() off = vi.fn() waitForTarget = vi.fn().mockImplementation(() => target) diff --git a/e2e/__fixtures__/pages.ts b/e2e/__fixtures__/pages.ts new file mode 100644 index 00000000000..f11b5e6e993 --- /dev/null +++ b/e2e/__fixtures__/pages.ts @@ -0,0 +1,130 @@ +import http from 'node:http' +import { once } from 'node:events' +import type { AddressInfo } from 'node:net' + +// Connection/lifecycle tests need deterministic pages, not an external site's +// availability, redirects or third-party subresources. +export async function startTestPages () { + let finishDeferredPage: () => void + const deferredPage = new Promise(resolve => { finishDeferredPage = resolve }) + const server = http.createServer((req, res) => { + const pathname = new URL(req.url!, 'http://localhost').pathname + res.setHeader('Cache-Control', 'no-store') + if (pathname === '/deferred.html') { + res.setHeader('Content-Type', 'text/html; charset=utf-8') + res.write('Deferred response
') + void deferredPage.then(() => res.end('')) + return + } + if (pathname === '/resource.js') { + res.setHeader('Content-Type', 'application/javascript') + res.end("document.getElementById('useragent').textContent = navigator.userAgent") + return + } + if (pathname === '/basic_auth' && req.headers.authorization !== `Basic ${Buffer.from('admin:admin').toString('base64')}`) { + res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="test"' }) + res.end('Authentication required') + return + } + if (pathname === '/pointer.html' || pathname === '/pointer-inner.html') { + res.setHeader('Content-Type', 'text/html; charset=utf-8') + res.end(`Pointer fixture + +
+ + ${pathname === '/pointer.html' ? '' : ''}`) + return + } + if (pathname === '/contribute.html') { + res.setHeader('Content-Type', 'text/html; charset=utf-8') + res.end('Contribute | WebdriverIO

Contribute

') + return + } + const framePages: Record = { + '/iframe.html': 'Frame Demo

Frame Demo

', + '/iframeA.html': 'IFrame A

IFrame A

', + '/iframeA1.html': 'IFrame A1

IFrame A1

', + '/iframeA2.html': 'IFrame A2

IFrame A2

', + '/nested_frames': '', + '/frame_top': '', + '/frame_left': '

Left frame

', + '/frame_right': '

Right frame

', + '/iframe': '', + '/editor.html': '
Editable frame
', + '/iframeNavigation.html': '', + '/iframeNavigationInner.html': 'Navigate top', + '/iframeTarget.html': '

Iframe Target

', + } + if (pathname in framePages) { + res.setHeader('Content-Type', 'text/html; charset=utf-8') + res.end(`${framePages[pathname]}`) + return + } + if (pathname === '/shadow.html') { + res.setHeader('Content-Type', 'text/html; charset=utf-8') + res.end(`

Shadow DOM

  • In a list!
+ `) + return + } + if (pathname === '/reloadCounter.html') { + res.setHeader('Content-Type', 'text/html; charset=utf-8') + res.end(`
+ `) + return + } + if (pathname === '/other.html') { + res.setHeader('Content-Type', 'text/html; charset=utf-8') + res.end('Another test page

Another page

') + return + } + const title = pathname === '/two.html' ? 'two' : 'WebdriverJS Testpage' + res.setHeader('Content-Type', 'text/html; charset=utf-8') + res.end(`${title}

${title}

+

Test CSS Attributes

+
Red box
+ + + +
Footer
+
+ + ${pathname === '/basic_auth' ? '

Congratulations! You must have the proper credentials.

' : ''} + ${pathname === '/two.html' + ? '
Second window
Second page!
' + : 'Open new tabOpen second window'}`) + }) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + return { + url: `http://127.0.0.1:${(server.address() as AddressInfo).port}`, + finishDeferredPage: () => finishDeferredPage(), + close: async () => { + server.closeAllConnections() + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())) + } + } +} diff --git a/e2e/__fixtures__/pwa.ts b/e2e/__fixtures__/pwa.ts new file mode 100644 index 00000000000..4d0906791eb --- /dev/null +++ b/e2e/__fixtures__/pwa.ts @@ -0,0 +1,63 @@ +import http from 'node:http' +import { readFileSync } from 'node:fs' +import { once } from 'node:events' +import type { AddressInfo } from 'node:net' + +/** A real installable app, independent of public websites and their redirects. */ +export async function startPWAPages () { + const icons = new Map(['icon-192x192.png', 'icon-512x512.png', 'maskable_icon.png'].map(name => [ + `/${name}`, readFileSync(new URL(`../../website/static/img/icons/${name}`, import.meta.url)) + ])) + const server = http.createServer((req, res) => { + res.setHeader('Cache-Control', 'no-store') + const icon = icons.get(req.url || '') + if (icon) { + res.setHeader('Content-Type', 'image/png') + return res.end(icon) + } + if (req.url === '/manifest.json') { + res.setHeader('Content-Type', 'application/manifest+json') + return res.end(JSON.stringify({ + name: 'Local WebdriverIO PWA', short_name: 'WDIO', start_url: '/', scope: '/', + display: 'standalone', background_color: '#ffffff', theme_color: '#ea5906', + icons: [ + { src: '/icon-192x192.png', sizes: '192x192', type: 'image/png' }, + { src: '/icon-512x512.png', sizes: '512x512', type: 'image/png' }, + { src: '/maskable_icon.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' } + ] + })) + } + if (req.url === '/sw.js') { + res.setHeader('Content-Type', 'text/javascript') + return res.end(` + self.addEventListener('install', event => event.waitUntil(self.skipWaiting())); + self.addEventListener('activate', event => event.waitUntil(self.clients.claim())); + self.addEventListener('fetch', event => event.respondWith(fetch(event.request))); + `) + } + res.setHeader('Content-Type', 'text/html; charset=utf-8') + if (req.url === '/plain') { + return res.end('Not a PWA

Plain rendered page

No manifest

') + } + res.end(` + + + + + Local WebdriverIO PWA +

Local installable app

Real Lighthouse audit fixture.

+ `) + }) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + return { + url: `http://127.0.0.1:${(server.address() as AddressInfo).port}`, + close: async () => { + server.closeAllConnections() + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())) + } + } +} diff --git a/e2e/browser-runner/__snapshots__/lit.test.js.snap b/e2e/browser-runner/__snapshots__/lit.test.js.snap index 82927700136..5a06dfe081f 100644 --- a/e2e/browser-runner/__snapshots__/lit.test.js.snap +++ b/e2e/browser-runner/__snapshots__/lit.test.js.snap @@ -1,27 +1,6 @@ // Snapshot v1 -exports[`snapshot testing > of elements 1`] = ` -" - -" -`; +exports[`snapshot testing > of elements 1`] = `""`; exports[`snapshot testing > of objects 1`] = ` { diff --git a/e2e/browser-runner/__snapshots__/react.test.tsx.snap b/e2e/browser-runner/__snapshots__/react.test.tsx.snap index a0a38c6af48..9ba765261b0 100644 --- a/e2e/browser-runner/__snapshots__/react.test.tsx.snap +++ b/e2e/browser-runner/__snapshots__/react.test.tsx.snap @@ -1,13 +1,7 @@ // Snapshot v1 -exports[`React Component Testing > supports snapshot tests 1`] = ` -"
- -
" -`; +exports[`React Component Testing > supports snapshot tests 1`] = `"
"`; exports[`React Component Testing > supports snapshot tests 3`] = ` -"
- -
" +"
" `; diff --git a/e2e/browser-runner/lit.test.js b/e2e/browser-runner/lit.test.js index f7a68d0a18d..bca5cc0ac38 100644 --- a/e2e/browser-runner/lit.test.js +++ b/e2e/browser-runner/lit.test.js @@ -118,25 +118,10 @@ describe('Lit Component testing', () => { */ const closedNode = $('closed-node') await expect(closedNode).toHaveText('Hello,') - await expect(closedNode).toMatchInlineSnapshot(` - "Hello, - - " - `) + await expect(closedNode).toMatchInlineSnapshot('"Hello, "') + await expect(closedNode.$('closed-node-nested').$('h2')).toHaveText('Deep Closed Node') + await expect(closedNode.$('closed-node-nested').$('.findMe')).toHaveText('I am hidden!') + }) it('can fetch multiple elements within various closed shadow roots', async function () { @@ -180,28 +165,13 @@ describe('Lit Component testing', () => { const elem = $('simple-greeting') await expect(elem).toMatchSnapshot() - await expect(elem).toMatchInlineSnapshot(` - " - - " - `) + await expect(elem).toMatchInlineSnapshot('""') + expect(await elem.getHTML(true)).toBe('') + expect(await elem.getHTML(false)).toBe('') + await expect(elem.$('p')).toHaveText('Hello Sir, WebdriverIO! Does this work?') + await expect(elem.$('sub-elem').$('.selectMeToo')) + .toHaveText('I am within another shadow root element as well') + }) it('of objects', async () => { @@ -227,7 +197,7 @@ describe('Lit Component testing', () => { `) }) - it('should be able to fetch elements that created without registering to the element registry', async () => { + it('finds shadow content when a regular host attaches its root before being connected', async () => { /** * only run snapshot tests in non-Safari browsers as shadow dom piercing * is not yet supported in Safari @@ -239,10 +209,10 @@ describe('Lit Component testing', () => { const shadowResult = await browser.execute(() => { const shadowElement = document.createElement('div') shadowElement.id = 'helloshadow' - document.body.appendChild(shadowElement) const shadowCreated = shadowElement.attachShadow({ mode: 'open' }) shadowCreated.innerHTML = '

Hello World

' + document.body.appendChild(shadowElement) const shadowExists = document.querySelector('#helloshadow')?.shadowRoot return shadowExists ? 'created' : 'failed' }) @@ -391,7 +361,6 @@ describe('Lit Component testing', () => { }) describe('Selector Tests', () => { - const getHTMLOptions = { includeSelectorTag: false, prettify: false } it('fetches element by content correctly', async () => { render( @@ -415,10 +384,10 @@ describe('Lit Component testing', () => { html`
Find me
`, document.body ) - expect(await $('div.foo*=me').getHTML(getHTMLOptions)).toBe('
Find me
') - expect(await $('.foo*=me').getHTML(getHTMLOptions)).toBe('
Find me
') - expect(await $('div#bar*=me').getHTML(getHTMLOptions)).toBe('
Find me
') - expect(await $('#bar*=me').getHTML(getHTMLOptions)).toBe('
Find me
') + expect(await $('div.foo*=me').getHTML(false)).toBe('
Find me
') + expect(await $('.foo*=me').getHTML(false)).toBe('
Find me
') + expect(await $('div#bar*=me').getHTML(false)).toBe('
Find me
') + expect(await $('#bar*=me').getHTML(false)).toBe('
Find me
') }) const outerClassLists = ['foo', 'bar foo', 'foo bar baz', 'bar foo baz', 'bar baz foo'] @@ -430,7 +399,7 @@ describe('Lit Component testing', () => { html`
Find me
`, document.body ) - expect(await $('.foo*=Find').getHTML(getHTMLOptions)).toBe(`
Find me
`) + expect(await $('.foo*=Find').getHTML(false)).toBe(`
Find me
`) }) } } @@ -452,10 +421,10 @@ describe('Lit Component testing', () => {
Find me
`, document.body ) - expect(await $('div.foo*=me').getHTML(getHTMLOptions)).toBe('
Find me
') - expect(await $('.foo*=me').getHTML(getHTMLOptions)).toBe('
Find me
') - expect(await $('div#bar*=me').getHTML(getHTMLOptions)).toBe('
Find me
') - expect(await $('#bar*=me').getHTML(getHTMLOptions)).toBe('
Find me
') + expect(await $('div.foo*=me').getHTML(false)).toBe('
Find me
') + expect(await $('.foo*=me').getHTML(false)).toBe('
Find me
') + expect(await $('div#bar*=me').getHTML(false)).toBe('
Find me
') + expect(await $('#bar*=me').getHTML(false)).toBe('
Find me
') }) it('fetches inner element by content correctly with nested attribute selector', async () => { @@ -475,10 +444,10 @@ describe('Lit Component testing', () => {
Find me
`, document.body ) - expect(await $('[data-testid="foobar"]*=me').getHTML(getHTMLOptions)).toBe('
Find me
') - expect(await $('[data-testid]*=me').getHTML(getHTMLOptions)).toBe('
Find me
') - expect(await $('div[data-testid="foobar"]*=me').getHTML(getHTMLOptions)).toBe('
Find me
') - expect(await $('div[data-testid]*=me').getHTML(getHTMLOptions)).toBe('
Find me
') + expect(await $('[data-testid="foobar"]*=me').getHTML(false)).toBe('
Find me
') + expect(await $('[data-testid]*=me').getHTML(false)).toBe('
Find me
') + expect(await $('div[data-testid="foobar"]*=me').getHTML(false)).toBe('
Find me
') + expect(await $('div[data-testid]*=me').getHTML(false)).toBe('
Find me
') }) it('fetches the parent element by content correctly', async () => { diff --git a/e2e/browser-runner/react.test.tsx b/e2e/browser-runner/react.test.tsx index 07086e06cf2..fc288b7b589 100644 --- a/e2e/browser-runner/react.test.tsx +++ b/e2e/browser-runner/react.test.tsx @@ -20,10 +20,6 @@ describe('React Component Testing', () => { it('supports snapshot tests', async () => { const { container } = render() await expect(container).toMatchSnapshot() - await expect(container).toMatchInlineSnapshot(` - "
- -
" - `) + await expect(container).toMatchInlineSnapshot('"
"') }) }) diff --git a/e2e/browser-runner/wdio.conf.js b/e2e/browser-runner/wdio.conf.js index f2152195d31..0d509513692 100644 --- a/e2e/browser-runner/wdio.conf.js +++ b/e2e/browser-runner/wdio.conf.js @@ -4,8 +4,6 @@ import path from 'node:path' import { loadEnv } from 'vite' import { expect } from '@wdio/globals' -const isMac = os.platform() === 'darwin' && process.env.CI - /** * skip tests if: */ @@ -16,12 +14,7 @@ if ( * see https://github.com/testing-library/vue-testing-library/issues/292 * Please ignore and remove this in your project! */ - (process.env.CI && process.env.WDIO_PRESET === 'vue') || - /** - * We are running network mocking tests on Safari in CI where Safari has no support for - * Bidi just yet. - */ - (process.env.CI && isMac && process.argv.includes('mock.test.ts')) + process.env.CI && os.platform() === 'win32' && process.env.WDIO_PRESET === 'vue' ) { process.exit(0) } @@ -39,16 +32,13 @@ export const config = { /** * capabilities */ - capabilities: [ - isMac - ? { - browserName: 'safari' - } - : { - browserName: 'chrome', - browserVersion: 'canary', - } - ], + capabilities: [{ + browserName: 'chrome', + browserVersion: 'canary', + 'goog:chromeOptions': { + args: ['--headless=new'] + } + }], /** * test configurations @@ -72,11 +62,10 @@ export const config = { }, coverage: { enabled: true, - // we skip some tests on Mac, therefor lower coverage treshold /** * Todo(@christian-bromann): set treshold back to 100 */ - functions: isMac ? 60 : 80 + functions: 80 } }], diff --git a/e2e/launch/reloadSession.test.ts b/e2e/launch/reloadSession.test.ts index 39f4d522e5e..dc83a8ed11c 100644 --- a/e2e/launch/reloadSession.test.ts +++ b/e2e/launch/reloadSession.test.ts @@ -1,15 +1,22 @@ -import { test, expect } from 'vitest' -/** - * in order to run this file make sure you have `webdriverio` - * installed using NPM before running it: - * - * $ npm install webdriverio - * - */ +import { afterEach, test, expect } from 'vitest' +import { startTestPages } from '../__fixtures__/pages.js' + import { remote } from '@testplane/webdriverio' +let browser: WebdriverIO.Browser | undefined +let pages: Awaited> | undefined + +afterEach(async () => { + try { + await browser?.deleteSession().catch(() => {}) + } finally { + await pages?.close() + } +}) + test('can reconnect to WebDriver Bidi session', async () => { - const browser = await remote({ + pages = await startTestPages() + browser = await remote({ capabilities: { browserName: 'chrome', 'goog:chromeOptions': { @@ -23,7 +30,7 @@ test('can reconnect to WebDriver Bidi session', async () => { console.log('\n\nRESTARTED\n\n') expect(typeof await browser.browsingContextGetTree({})).toBe('object') - await browser.url('https://guinea-pig.webdriver.io') + await browser.url(pages.url) expect(await browser.getTitle()).toBe('WebdriverJS Testpage') const h1 = await browser.$('h1') expect(await h1.getText()).toBe('WebdriverJS Testpage') diff --git a/e2e/standalone/attach.test.ts b/e2e/standalone/attach.test.ts index c41f96e0022..9f4358fa735 100644 --- a/e2e/standalone/attach.test.ts +++ b/e2e/standalone/attach.test.ts @@ -1,14 +1,20 @@ import os from 'node:os' -import { test, expect } from 'vitest' -/** - * in order to run this file make sure you have `webdriverio` - * installed using NPM before running it: - * - * $ npm install webdriverio - * - */ +import { afterEach, test, expect } from 'vitest' +import { startTestPages } from '../__fixtures__/pages.js' + import { remote, attach } from '@testplane/webdriverio' +let browser: WebdriverIO.Browser | undefined +let pages: Awaited> | undefined + +afterEach(async () => { + try { + await browser?.deleteSession().catch(() => {}) + } finally { + await pages?.close() + } +}) + test('allow to attach to an existing session', async () => { /** * fails in windows due to timeout: @@ -18,7 +24,8 @@ test('allow to attach to an existing session', async () => { return } - const browser = await remote({ + pages = await startTestPages() + browser = await remote({ capabilities: { browserName: 'chrome', 'goog:chromeOptions': { @@ -27,7 +34,7 @@ test('allow to attach to an existing session', async () => { } }) - await browser.url('https://guinea-pig.webdriver.io') + await browser.url(pages.url) expect(await browser.getTitle()).toBe('WebdriverJS Testpage') const origContextTree = await browser.browsingContextGetTree({ maxDepth: 1 }) expect(origContextTree.contexts).toHaveLength(1) @@ -41,7 +48,7 @@ test('allow to attach to an existing session', async () => { /** * can open other pages which requires e.g. network manager to be reinitialized correctly */ - await otherBrowser.url('https://guinea-pig.webdriver.io/two.html') + await otherBrowser.url(`${pages.url}/two.html`) expect(await otherBrowser.getTitle()).toBe('two') await otherBrowser.deleteSession() @@ -50,6 +57,6 @@ test('allow to attach to an existing session', async () => { * verify that browser session is deleted */ const error = await browser.status().catch((err) => err) - expect(error.message).not.toBe('ChromeDriver ready for new sessions.') - expect(error.message).toEqual(expect.stringContaining('Request failed with error code ECONNREFUSED')) + expect(error).toBeInstanceOf(Error) + expect(error.code).toBe('ECONNREFUSED') }) diff --git a/e2e/wdio/headless/bidi.e2e.ts b/e2e/wdio/headless/bidi.e2e.ts index fe54b705261..8ee8f550d93 100644 --- a/e2e/wdio/headless/bidi.e2e.ts +++ b/e2e/wdio/headless/bidi.e2e.ts @@ -1,8 +1,14 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { browser, expect } from '@wdio/globals' import type { local } from '@testplane/webdriver' +import { readFileSync } from 'node:fs' +import { startTestPages } from '../../__fixtures__/pages.js' describe('bidi e2e test', () => { + let pages: Awaited> + before(async () => { pages = await startTestPages() }) + after(async () => { await pages?.close() }) + describe('execute', () => { it('generates a nice stack trace', async function () { /** @@ -23,7 +29,9 @@ describe('bidi e2e test', () => { } } }).catch(err => err) - expect(result.stack).toContain('16 │ if(a){if(a){throw new Error("Hello Bidi")}}}') + const sourceLines = readFileSync(new URL(import.meta.url), 'utf8').split('\n') + const executeLine = sourceLines.findIndex(line => line.includes('const result = await browser.execute(async')) + 1 + expect(result.stack).toContain(`${executeLine} │ if(a){if(a){throw new Error("Hello Bidi")}}}`) const result2 = await browser.execute(async () => { const a: number = 1 @@ -34,7 +42,8 @@ describe('bidi e2e test', () => { } } }).catch(err => err) - expect(result2.stack).toContain('27 │ if(a){if(a){await Promise.reject(new Error("Hello Bidi"))}}}') + const executeAsyncLine = sourceLines.findIndex(line => line.includes('const result2 = await browser.execute(async')) + 1 + expect(result2.stack).toContain(`${executeAsyncLine} │ if(a){if(a){await Promise.reject(new Error("Hello Bidi"))}}}`) }) }) @@ -207,7 +216,7 @@ describe('bidi e2e test', () => { }) it('supports execute with bidi on element scope', async () => { - await browser.url('https://guinea-pig.webdriver.io') + await browser.url(pages.url) const result = await browser.$('.findme').execute(function (elem, a, b, c, d) { return (elem as unknown as HTMLElement).innerText.length + a + b + c + d }, 1, 2, 3, 4) @@ -237,7 +246,7 @@ describe('bidi e2e test', () => { }) it('works on element scope', async () => { - await browser.url('https://guinea-pig.webdriver.io') + await browser.url(pages.url) const result = await browser.$('.findme').executeAsync(function (elem, a, b, c, d, done) { // browser context - you may not access client or console setTimeout(() => { diff --git a/e2e/wdio/headless/classic.e2e.ts b/e2e/wdio/headless/classic.e2e.ts index 3309a9dcab4..25d83057580 100644 --- a/e2e/wdio/headless/classic.e2e.ts +++ b/e2e/wdio/headless/classic.e2e.ts @@ -1,9 +1,14 @@ import { browser } from '@wdio/globals' import scripts from './__fixtures__/script.js' +import { startTestPages } from '../../__fixtures__/pages.js' + +let pages: Awaited> +before(async () => { pages = await startTestPages() }) +after(async () => { await pages?.close() }) describe('__name polyfill', () => { it('suppports __name polyfill for classic sessions', async () => { - await browser.url('https://guinea-pig.webdriver.io') + await browser.url(pages.url) expect(await browser.execute(scripts.someScript, 'foo')).toBe('Hello World! foo') expect(await browser.executeAsync(scripts.someAsyncScript, 'foo')).toBe('Hello World! foo') }) @@ -11,19 +16,20 @@ describe('__name polyfill', () => { describe('handle windows in webdriver classic', () => { it('should handle window closing and switching in WebDriver Classic mode', async () => { - await browser.url('https://the-internet.herokuapp.com/iframe') - const elementalSeleniumLink = await $('/html/body/div[3]/div/div/a') - await elementalSeleniumLink.waitForDisplayed() - await elementalSeleniumLink.click() + await browser.url(pages.url) + const newWindowLink = await $('#open-window') + await newWindowLink.waitForDisplayed() + await newWindowLink.click() await browser.waitUntil(async () => (await browser.getWindowHandles()).length === 2) - await browser.switchWindow('https://elementalselenium.com/') - await $('#__docusaurus_skipToContent_fallback').waitForDisplayed() + await browser.switchWindow(`${pages.url}/two.html`) + await $('#second-window').waitForDisplayed() await browser.closeWindow() - await $('#__docusaurus_skipToContent_fallback').waitForDisplayed({ reverse: true }) await browser.waitUntil(async () => (await browser.getWindowHandles()).length === 1) - await browser.switchWindow('https://the-internet.herokuapp.com/iframe') + // Classic closeWindow closes the context but does not select another one. + await browser.switchWindow(pages.url) + await $('#second-window').waitForDisplayed({ reverse: true }) // Verify we're on the original window - expect(await $('.example h3').getText()).toBe('An iFrame containing the TinyMCE WYSIWYG Editor') + expect(await $('h1').getText()).toBe('WebdriverJS Testpage') }) }) diff --git a/e2e/wdio/headless/launch.e2e.ts b/e2e/wdio/headless/launch.e2e.ts index cce81fecbc2..9043f74816d 100644 --- a/e2e/wdio/headless/launch.e2e.ts +++ b/e2e/wdio/headless/launch.e2e.ts @@ -1,12 +1,17 @@ import { browser, $, expect } from '@wdio/globals' +import { startTestPages } from '../../__fixtures__/pages.js' describe('Launch Test', () => { + let pages: Awaited> + before(async () => { pages = await startTestPages() }) + after(async () => { await pages?.close() }) + it('should verify that right browser was initiated', async () => { const caps = browser.capabilities as WebdriverIO.Capabilities const assertionValue = caps.browserName?.toLowerCase().includes('edge') ? 'Edg/' : caps.browserName && caps.browserName.includes('chrome') ? 'chrome/' : caps.browserName!.toLowerCase() - await browser.url('https://guinea-pig.webdriver.io/') + await browser.url(pages.url) await expect($('#useragent')).toHaveText(expect.stringContaining(assertionValue), { ignoreCase: true }) // @ts-expect-error diff --git a/e2e/wdio/headless/mocking.e2e.ts b/e2e/wdio/headless/mocking.e2e.ts index 893f67edc40..f9d51e28e43 100644 --- a/e2e/wdio/headless/mocking.e2e.ts +++ b/e2e/wdio/headless/mocking.e2e.ts @@ -1,9 +1,14 @@ import { browser } from '@wdio/globals' +import { startTestPages } from '../../__fixtures__/pages.js' describe('network mocking', () => { + let pages: Awaited> + before(async () => { pages = await startTestPages() }) + after(async () => { await pages.close() }) + it('marks a request as mocked even without overwrites', async () => { - const baseUrl = 'https://guinea-pig.webdriver.io/' - const mock = await browser.mock(`${baseUrl}components/hammerjs/hammer.js`, { + const baseUrl = `${pages.url}/` + const mock = await browser.mock(`${baseUrl}resource.js`, { method: 'get', statusCode: 200, }) diff --git a/e2e/wdio/headless/reloadSession.e2e.ts b/e2e/wdio/headless/reloadSession.e2e.ts index 43c33cb1274..91e133578c9 100644 --- a/e2e/wdio/headless/reloadSession.e2e.ts +++ b/e2e/wdio/headless/reloadSession.e2e.ts @@ -5,12 +5,12 @@ describe('reloadSession', () => { const sessionId = browser.sessionId expect(browser.capabilities.browserName).toBe('chrome') await browser.reloadSession({ - browserName: 'edge', - 'ms:edgeOptions': { - args: ['headless', 'disable-gpu'] + browserName: 'firefox', + 'moz:firefoxOptions': { + args: ['-headless'] } }) - expect(browser.capabilities.browserName).toContain('MicrosoftEdge') + expect(browser.capabilities.browserName).toBe('firefox') expect(browser.sessionId).not.toBe(sessionId) }) }) diff --git a/e2e/wdio/headless/test.e2e.ts b/e2e/wdio/headless/test.e2e.ts index 423fa1b3483..13d4d92ccc4 100644 --- a/e2e/wdio/headless/test.e2e.ts +++ b/e2e/wdio/headless/test.e2e.ts @@ -6,20 +6,32 @@ import path from 'node:path' import { browser, $, expect } from '@wdio/globals' import { imageSize } from 'image-size' +import { startTestPages } from '../../__fixtures__/pages.js' +import { startPWAPages } from '../../__fixtures__/pwa.js' const __dirname = path.dirname(url.fileURLToPath(import.meta.url)) describe('main suite 1', () => { + let pages: Awaited> + let pwa: Awaited> + before(async () => { + pages = await startTestPages() + pwa = await startPWAPages() + }) + after(async () => { + await pages?.close() + await pwa?.close() + }) + it('supports snapshot testing', async () => { - await browser.url('https://guinea-pig.webdriver.io/') + await browser.url(`${pages.url}/`) await expect($('.findme')).toMatchSnapshot() await expect($('.findme')).toMatchInlineSnapshot('"

Test CSS Attributes

"') }) it('should allow to check for PWA', async () => { - await browser.url('https://webdriver.io') - // eslint-disable-next-line wdio/no-pause - await browser.pause(100) + await browser.url(pwa.url) + await browser.waitUntil(() => browser.execute(() => Boolean(navigator.serviceWorker.controller))) expect((await browser.checkPWA([ 'isInstallable', 'splashScreen', @@ -32,12 +44,12 @@ describe('main suite 1', () => { }) it('should also detect non PWAs', async () => { - await browser.url('https://json.org') + await browser.url(`${pwa.url}/plain`) expect((await browser.checkPWA()).passed).toBe(false) }) it('can query shadow elements', async () => { - await browser.url('https://the-internet.herokuapp.com/shadowdom') + await browser.url(`${pages.url}/shadow.html`) await $('h1').waitForDisplayed() await expect($('ul[slot="my-text"] li:last-child')).toHaveText('In a list!') }) @@ -54,7 +66,7 @@ describe('main suite 1', () => { }) it('should be able to use async-iterators', async () => { - await browser.url('https://webdriver.io') + await browser.url(pages.url) await browser.$('aria/Toggle navigation bar').click() const contributeLink = await browser.waitUntil(async () => { const contributeLink = await browser.$$('.navbar-sidebar a.menu__link').find( @@ -79,7 +91,7 @@ describe('main suite 1', () => { before(() => browser.enablePerformanceAudits()) it('should allow to do performance tests', async () => { - await browser.url('http://json.org') + await browser.url(`${pwa.url}/plain`) const metrics = await browser.getMetrics() expect(typeof metrics.serverResponseTime).toBe('number') expect(typeof metrics.domContentLoaded).toBe('number') @@ -108,7 +120,7 @@ describe('main suite 1', () => { return } - await browser.url('https://webdriver.io/') + await browser.url(pages.url) const currentScrollPosition = await browser.execute(() => [ window.scrollX, window.scrollY ]) @@ -127,10 +139,13 @@ describe('main suite 1', () => { expect(sameScrollPosition).toEqual([x, y]) await browser.scroll(0, Math.floor(-y)) - const oldScrollPosition = await browser.execute(() => [ + await browser.waitUntil(async () => ( + await browser.execute(() => window.scrollY) + ) === 0, { timeoutMsg: 'Expected the upward wheel action to reach the top of the page' }) + const topScrollPosition = await browser.execute(() => [ window.scrollX, window.scrollY ]) - expect(oldScrollPosition).toEqual([x, y]) + expect(topScrollPosition).toEqual([x, 0]) }) describe('moveTo tests', () => { @@ -142,7 +157,7 @@ describe('main suite 1', () => { ] before(async () => { - await browser.url('https://guinea-pig.webdriver.io/pointer.html') + await browser.url(`${pages.url}/pointer.html`) await browser.$('#parent').waitForExist() }) @@ -182,10 +197,7 @@ describe('main suite 1', () => { }) }) - /** - * test started to fail for unclear reason and block release - */ - it.skip('moveTo in iframe', async () => { + it('moveTo in iframe', async () => { const iframe = await browser.$('iframe.code-tabs__result') await browser.switchFrame(iframe) await browser.$('#parent').moveTo() @@ -271,7 +283,7 @@ describe('main suite 1', () => { }) beforeEach(async () => { - await browser.url('https://guinea-pig.webdriver.io') + await browser.url(pages.url) await browser.setWindowSize(500, 500) }) @@ -333,7 +345,7 @@ describe('main suite 1', () => { describe('url command', () => { it('supports basic auth', async () => { - await browser.url('https://the-internet.herokuapp.com/basic_auth', { + await browser.url(`${pages.url}/basic_auth`, { auth: { user: 'admin', pass: 'admin' @@ -344,41 +356,48 @@ describe('main suite 1', () => { }) it('should return a request object', async () => { - const request = await browser.url('https://guinea-pig.webdriver.io/') + const request = await browser.url(`${pages.url}/`) if (!request) { throw new Error('Request object is not defined') } + // Network events may arrive just after the navigation result; the + // returned request stays live as its subresources finish. + await browser.waitUntil(() => request.children!.some(child => child.url === `${pages.url}/resource.js`)) expect(request.children!.length > 0).toBe(true) - expect(Object.keys(request.response?.headers || {})).toContain('x-amz-version-id') + expect(Object.keys(request.response?.headers || {}).map(name => name.toLowerCase())).toContain('content-type') }) it('should not contain any children due to "none" wait property', async () => { - const request = await browser.url('https://guinea-pig.webdriver.io/', { - wait: 'none' - }) - - if (!request) { - throw new Error('Request object is not defined') + try { + const request = await browser.url(`${pages.url}/deferred.html`, { + wait: 'none' + }) + if (!request) { + throw new Error('Request object is not defined') + } + expect(request.children!.length).toBe(0) + } finally { + pages.finishDeferredPage() } - expect(request.children!.length).toBe(0) + await browser.waitUntil(async () => await browser.execute(() => document.readyState) === 'complete') }) it('should allow to load a script before loading the page', async () => { - await browser.url('https://webdriver.io', { + await browser.url(pages.url, { onBeforeLoad: () => { Math.random = () => 42 } }) expect(await browser.execute(() => Math.random())).toBe(42) - await browser.url('https://webdriver.io') + await browser.url(pages.url) expect(await browser.execute(() => Math.random())).not.toBe(42) }) }) describe('dialog handling', () => { it('should automatically accept alerts', async () => { - await browser.url('https://guinea-pig.webdriver.io') + await browser.url(pages.url) await browser.execute(() => alert('123')) @@ -393,13 +412,20 @@ describe('main suite 1', () => { * fails due to https://github.com/GoogleChromeLabs/chromium-bidi/issues/2556 */ it('should be able to handle dialogs', async () => { - await browser.url('https://guinea-pig.webdriver.io') - browser.execute(() => alert('123')) - const dialog = await new Promise((resolve) => browser.on('dialog', resolve)) + await browser.url(pages.url) + let onDialog: (dialog: WebdriverIO.Dialog) => void + const opened = new Promise((resolve) => { + onDialog = resolve + browser.on('dialog', onDialog) + }) + const alert = browser.execute(() => window.alert('123')) + const dialog = await opened expect(dialog.type()).toBe('alert') expect(dialog.message()).toBe('123') await dialog.dismiss() + browser.off('dialog', onDialog!) + await alert }) }) @@ -409,11 +435,11 @@ describe('main suite 1', () => { Math.random = () => seed }, 42) - await browser.url('https://webdriver.io') + await browser.url(pages.url) expect(await browser.execute(() => Math.random())).toBe(42) await script.remove() - await browser.url('https://webdriver.io') + await browser.url(pages.url) expect(await browser.execute(() => Math.random())).not.toBe(42) }) @@ -421,11 +447,12 @@ describe('main suite 1', () => { const script = await browser.addInitScript((num, str, bool, emit) => { setTimeout(() => emit(JSON.stringify([num, str, bool])), 500) }, 1, '2', true) - browser.url('https://webdriver.io') - const data = await new Promise((resolve) => { - script.on('data', (data) => resolve(data as string[])) + const emitted = new Promise((resolve) => { + script.on('data', (data) => resolve(data as string)) }) - expect(data).toBe('[1,"2",true]') + await browser.url(pages.url) + expect(await emitted).toBe('[1,"2",true]') + await script.remove() }) }) @@ -438,7 +465,7 @@ describe('main suite 1', () => { await browser.emulate('clock', { now }) expect((await browser.execute(getDateString)).toLocaleString('en-GB', { timeZone: 'UTC' })) .toBe(mockedDateString) - await browser.url('https://guinea-pig.webdriver.io') + await browser.url(pages.url) expect((await browser.execute(getDateString)).toLocaleString('en-GB', { timeZone: 'UTC' })) .toBe(mockedDateString) }) @@ -447,7 +474,7 @@ describe('main suite 1', () => { await browser.restore('clock') expect((await browser.execute(getDateString)).toLocaleString('en-GB', { timeZone: 'UTC' })) .not.toBe(mockedDateString) - await browser.url('https://guinea-pig.webdriver.io/pointer.html') + await browser.url(`${pages.url}/pointer.html`) expect((await browser.execute(getDateString)).toLocaleString('en-GB', { timeZone: 'UTC' })) .not.toBe(mockedDateString) }) @@ -455,7 +482,7 @@ describe('main suite 1', () => { describe('shadow root piercing', () => { it('recognises new shadow root ids when page refreshes', async () => { - await browser.url('https://todomvc.com/examples/lit/dist/') + await browser.url(`${pages.url}/shadow.html`) await expect($('.new-todo')).toBePresent() await browser.refresh() await expect($('.new-todo')).toBePresent() @@ -475,29 +502,29 @@ describe('main suite 1', () => { } it('should allow user to switch between contexts', async () => { - await browser.url('https://guinea-pig.webdriver.io/') + await browser.url(`${pages.url}/`) - await browser.newWindow('https://webdriver.io') + await browser.newWindow(`${pages.url}/other.html`) await expect($('.hero__subtitle')).toBePresent() await expect($('.red')).not.toBePresent() - await browser.switchWindow('guinea-pig.webdriver.io') + await browser.switchWindow(new RegExp(`${pages.url}/$`)) await expect($('.red')).toBePresent() await expect($('.hero__subtitle')).not.toBePresent() - await browser.switchWindow('Next-gen browser and mobile automation test framework for Node.js') + await browser.switchWindow('Another test page') await expect($('.hero__subtitle')).toBePresent() await expect($('.red')).not.toBePresent() }) it('should not switch window if requested window was not found', async () => { await closeAllWindowsButFirst() - await browser.navigateTo('https://guinea-pig.webdriver.io/') + await browser.navigateTo(`${pages.url}/`) const firstWindowHandle = await browser.getWindowHandle() - await browser.newWindow('https://webdriver.io') + await browser.newWindow(`${pages.url}/other.html`) - await browser.switchWindow('guinea-pig.webdriver.io') + await browser.switchWindow(new RegExp(`${pages.url}/$`)) expect(await browser.getWindowHandle()).toBe(firstWindowHandle) const err = await browser.switchWindow('not-existing-window').catch((err) => err) @@ -508,7 +535,7 @@ describe('main suite 1', () => { it('Should update BiDi browsingContext when performing switchToWindow in WebDriver Classic', async () => { await closeAllWindowsButFirst() - await browser.url('https://guinea-pig.webdriver.io/') + await browser.url(`${pages.url}/`) await $('#newWindow').click() const handles = await browser.getWindowHandles() @@ -519,17 +546,17 @@ describe('main suite 1', () => { }) it('should see that content is no longer displayed when window is closed', async () => { - await browser.url('https://the-internet.herokuapp.com/iframe') - const elementalSeleniumLink = await $('/html/body/div[3]/div/div/a') - await elementalSeleniumLink.waitForDisplayed() - await elementalSeleniumLink.click() - await browser.waitUntil(async () => (await browser.getWindowHandles()).length === 3) - await browser.switchWindow('https://elementalselenium.com/') - await $('#__docusaurus_skipToContent_fallback').waitForDisplayed() - await browser.closeWindow() - await $('#__docusaurus_skipToContent_fallback').waitForDisplayed({ reverse: true }) + await closeAllWindowsButFirst() + await browser.url(pages.url) + const original = await browser.getWindowHandle() + await $('#newWindow').click() await browser.waitUntil(async () => (await browser.getWindowHandles()).length === 2) - await browser.switchWindow('https://the-internet.herokuapp.com/iframe') + await browser.switchWindow(`${pages.url}/two.html`) + await $('#second-window').waitForDisplayed() + await browser.closeWindow() + await browser.switchToWindow(original) + await $('#second-window').waitForDisplayed({ reverse: true }) + await browser.waitUntil(async () => (await browser.getWindowHandles()).length === 1) }) }) @@ -539,28 +566,28 @@ describe('main suite 1', () => { }) it('can switch to a frame via url', async () => { - await browser.url('https://guinea-pig.webdriver.io/iframe.html') - await browser.switchFrame('https://guinea-pig.webdriver.io/iframeA2.html') + await browser.url(`${pages.url}/iframe.html`) + await browser.switchFrame(`${pages.url}/iframeA2.html`) expect(await browser.execute(() => [document.title, document.URL])) - .toEqual(['IFrame A2', 'https://guinea-pig.webdriver.io/iframeA2.html']) + .toEqual(['IFrame A2', `${pages.url}/iframeA2.html`]) }) it('can switch to a frame via element', async () => { - await browser.url('https://the-internet.herokuapp.com/nested_frames') + await browser.url(`${pages.url}/nested_frames`) await browser.switchFrame($('frame')) expect(await browser.execute(() => document.URL)) - .toBe('https://the-internet.herokuapp.com/frame_top') + .toBe(`${pages.url}/frame_top`) }) it('can switch to a frame via function', async () => { - await browser.url('https://the-internet.herokuapp.com/nested_frames') + await browser.url(`${pages.url}/nested_frames`) await browser.switchFrame(() => document.URL.includes('frame_right')) expect(await browser.execute(() => document.URL)) - .toBe('https://the-internet.herokuapp.com/frame_right') + .toBe(`${pages.url}/frame_right`) }) it('should reset the frame when the page is reloaded', async () => { - await browser.url('https://the-internet.herokuapp.com/iframe') + await browser.url(`${pages.url}/iframe`) await expect($('#tinymce')).not.toBePresent() await browser.switchFrame($('iframe')) await expect($('#tinymce')).toBePresent() @@ -571,24 +598,24 @@ describe('main suite 1', () => { }) it('allows expect after switching to non-children', async () => { - await browser.url('https://guinea-pig.webdriver.io/iframe.html') + await browser.url(`${pages.url}/iframe.html`) await expect($('h1,h2,h3')).toHaveText('Frame Demo') await browser.switchFrame($('#A')) // child await expect($('h1,h2,h3')).toHaveText('IFrame A') // child, we use this to proof switch frame with a function works - await browser.switchFrame(() => window.location.href === 'https://guinea-pig.webdriver.io/iframeA1.html') + await browser.switchFrame(() => window.location.pathname === '/iframeA1.html') await expect($('h1,h2,h3')).toHaveText('IFrame A1') // sibling - await browser.switchFrame(() => window.location.href === 'https://guinea-pig.webdriver.io/iframeA2.html') + await browser.switchFrame(() => window.location.pathname === '/iframeA2.html') await expect($('h1,h2,h3')).toHaveText('IFrame A2') // FAILS "no such element" }) describe('switchToParentFrame', () => { it('switches to parent (not top-level)', async () => { - await browser.url('https://guinea-pig.webdriver.io/iframe.html') + await browser.url(`${pages.url}/iframe.html`) await expect($('h1')).toHaveText('Frame Demo') await expect($('h2')).not.toExist() await expect($('h3')).not.toExist() @@ -614,7 +641,7 @@ describe('main suite 1', () => { describe('taking screenshots', () => { it('should take a screenshot of the iframe', async () => { - await browser.url('https://guinea-pig.webdriver.io/iframe.html') + await browser.url(`${pages.url}/iframe.html`) await browser.switchFrame($('#A')) await browser.switchFrame($('#A2')) @@ -634,7 +661,7 @@ describe('main suite 1', () => { describe('iframe navigations', () => { beforeEach(async () => { - await browser.url('https://guinea-pig.webdriver.io/iframeNavigation.html') + await browser.url(`${pages.url}/iframeNavigation.html`) }) describe('ability to catch navigation event within iframe', () => { @@ -655,13 +682,13 @@ describe('main suite 1', () => { describe('open resources with different protocols', () => { it('http', async () => { - browser.url('https://guinea-pig.webdriver.io/') - await expect(browser).toHaveUrl('https://guinea-pig.webdriver.io/') + await browser.url(`${pages.url}/`) + await expect(browser).toHaveUrl(`${pages.url}/`) }) it('https', async () => { - await browser.url('https://webdriver.io/') - await expect(browser).toHaveUrl('https://webdriver.io/') + await browser.url('https://example.com/') + await expect(browser).toHaveUrl('https://example.com/') }) it('data', async () => { @@ -677,7 +704,7 @@ describe('main suite 1', () => { it('chrome', async () => { await browser.url('chrome://about/') - await expect($('li=chrome://accessibility')).toExist() + await expect($('chrome-urls-app').shadow$('a[href="chrome://accessibility"]')).toExist() }) }) @@ -689,7 +716,7 @@ describe('main suite 1', () => { } for (const [name, [value, expected]] of Object.entries(scenarions)) { it(`reloads with ${name}`, async () => { - const url = `https://guinea-pig.webdriver.io/reloadCounter.html${value}` + const url = `${pages.url}/reloadCounter.html${value}` await browser.url(url) await $('#reset').click() await expect($('#counter')).toHaveValue('0') @@ -701,7 +728,7 @@ describe('main suite 1', () => { } }) - describe.only('selectBy*', () => { + describe('selectBy*', () => { const scenarios = [ ['selectByVisibleText', ['Option 2']], ['selectByIndex', [1]], @@ -711,7 +738,7 @@ describe('main suite 1', () => { for (const [command, args] of scenarios) { it(`${command}: should wait for the option to be present`, async () => { const newOption = 'Option 2' - await browser.url('https://guinea-pig.webdriver.io/two.html') + await browser.url(`${pages.url}/two.html`) await browser.execute((newOption) => { const select = document.createElement('select') diff --git a/e2e/wdio/wdio.conf.ts b/e2e/wdio/wdio.conf.ts index fc4c59c7f67..fa42cda732b 100644 --- a/e2e/wdio/wdio.conf.ts +++ b/e2e/wdio/wdio.conf.ts @@ -21,9 +21,9 @@ export const config: WebdriverIO.Config = { capabilities: [{ browserName: 'chrome', browserVersion: 'stable', - // 'goog:chromeOptions': { - // args: ['headless', 'disable-gpu'] - // } + 'goog:chromeOptions': { + args: ['headless', 'disable-gpu'] + } }], bail: 1, services: ['lighthouse'], diff --git a/e2e/wdio/wdio.local.conf.ts b/e2e/wdio/wdio.local.conf.ts index acbda4c024d..b34f7d07cc9 100644 --- a/e2e/wdio/wdio.local.conf.ts +++ b/e2e/wdio/wdio.local.conf.ts @@ -1,4 +1,3 @@ -import os from 'node:os' import url from 'node:url' import path from 'node:path' @@ -39,12 +38,6 @@ export const config: WebdriverIO.Config = { 'moz:firefoxOptions': { args: ['-headless'] } - }, { - browserName: 'edge', - webSocketUrl: true, - 'ms:edgeOptions': { - args: ['headless', 'disable-gpu'] - } }], /** @@ -62,14 +55,6 @@ export const config: WebdriverIO.Config = { } } -if (os.platform() === 'darwin') { - config.capabilities.push({ - // not yet supported - // webSocketUrl: true, - browserName: 'safari' - }) -} - /** * Disable these tests as they started failing, due to: * > WebDriver Bidi command "browsingContext.navigate" failed with error: diff --git a/eslint.config.mjs b/eslint.config.mjs index 57c4f56f019..b6ad95ef150 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -18,6 +18,18 @@ export default wdioEslint.config([ 'packages/wdio-protocols/src/commands' ], }, + { + // Flat config does not load e2e/browser-runner/.eslintrc. + files: ['e2e/browser-runner/**/*.test.{js,ts,tsx}'], + languageOptions: { + globals: { + ...globals.browser, + ...globals.mocha, + browser: true, + expect: true + } + } + }, { /** * Eslint rules for /example directory and unit tests diff --git a/package.json b/package.json index 2ee0113df3f..66c62aba074 100644 --- a/package.json +++ b/package.json @@ -114,6 +114,7 @@ "lerna-changelog": "^2.2.0", "mime-types": "^2.1.35", "npm-run-all2": "^6.2.2", + "pkg-pr-new": "0.0.88", "puppeteer-core": "^23.0.2", "recast": "^0.23.9", "recursive-readdir": "^2.2.3", diff --git a/packages/devtools/package.json b/packages/devtools/package.json index bf85d276671..dced15f0b97 100644 --- a/packages/devtools/package.json +++ b/packages/devtools/package.json @@ -6,7 +6,7 @@ "homepage": "https://github.com/webdriverio/webdriverio/tree/main/packages/devtools", "license": "MIT", "engines": { - "node": ">=18" + "node": ">=22.12.0" }, "repository": { "type": "git", @@ -26,6 +26,7 @@ ".": { "types": "./build/index.d.ts", "import": "./build/index.js", + "requireSource": "./src/index.cts", "require": "./build/index.cjs" }, "./package.json": "./package.json" @@ -41,15 +42,13 @@ "chrome-launcher": "^1.0.0", "edge-paths": "^3.0.5", "import-meta-resolve": "^4.0.0", - "puppeteer-core": "20.9.0", + "puppeteer-core": "^25.10.0", "query-selector-shadow-dom": "^1.0.0", "ua-parser-js": "^1.0.37", - "uuid": "^9.0.0", "which": "^4.0.0" }, "devDependencies": { "@types/ua-parser-js": "^0.7.33", - "@types/uuid": "^9.0.0", "@types/which": "^3.0.3", "@wdio/globals": "8.32.1" } diff --git a/packages/devtools/src/commands/closeWindow.ts b/packages/devtools/src/commands/closeWindow.ts index ed37a273fd6..e94745b763a 100644 --- a/packages/devtools/src/commands/closeWindow.ts +++ b/packages/devtools/src/commands/closeWindow.ts @@ -1,4 +1,4 @@ -import { v4 as uuidv4 } from 'uuid' +import { randomUUID } from 'node:crypto' import { sleep } from '../utils.js' import type DevToolsDriver from '../devtoolsdriver.js' @@ -22,7 +22,7 @@ export default async function closeWindow (this: DevToolsDriver) { if (!this.currentWindowHandle) { const page = await this.browser.newPage() - const newWindowHandle = uuidv4() + const newWindowHandle = randomUUID() this.windows.set(newWindowHandle, page) this.currentWindowHandle = newWindowHandle } diff --git a/packages/devtools/src/commands/newSession.ts b/packages/devtools/src/commands/newSession.ts index d4f4b9bde23..4d9e0b943b1 100644 --- a/packages/devtools/src/commands/newSession.ts +++ b/packages/devtools/src/commands/newSession.ts @@ -1,5 +1,5 @@ import os from 'node:os' -import { v4 as uuidv4 } from 'uuid' +import { randomUUID } from 'node:crypto' import launch from '../launcher.js' import { sessionMap } from '../index.js' @@ -19,7 +19,7 @@ export default async function newSession ( { capabilities }: { capabilities: WebdriverIO.Capabilities } ) { const browser = await launch(capabilities) - const sessionId = uuidv4() + const sessionId = randomUUID() const [browserName, browserVersion] = (await browser.version()).split('/') sessionMap.set(sessionId, browser) diff --git a/packages/devtools/src/commands/performActions.ts b/packages/devtools/src/commands/performActions.ts index d7986ecdb2f..5ba0db364bb 100644 --- a/packages/devtools/src/commands/performActions.ts +++ b/packages/devtools/src/commands/performActions.ts @@ -1,6 +1,5 @@ -import type { KeyInput } from 'puppeteer-core/lib/esm/puppeteer/common/USKeyboardLayout.js' -import { _keyDefinitions } from 'puppeteer-core/lib/esm/puppeteer/common/USKeyboardLayout.js' -import type { Keyboard, Mouse } from 'puppeteer-core/lib/esm/puppeteer/api/Input.js' +import type { KeyInput, Keyboard, Mouse } from 'puppeteer-core' +import { _keyDefinitions } from 'puppeteer-core/internal/common/USKeyboardLayout.js' import getElementRect from './getElementRect.js' import getWindowRect from './getWindowRect.js' @@ -130,7 +129,7 @@ export default async function performActions( y += location.y + (location.height / 2) } - await page.mouse.click(x, y, { clickCount: 2 }) + await page.mouse.click(x, y, { count: 2 }) continue } diff --git a/packages/devtools/src/commands/status.ts b/packages/devtools/src/commands/status.ts index c45393c345b..ad5874a3cd3 100644 --- a/packages/devtools/src/commands/status.ts +++ b/packages/devtools/src/commands/status.ts @@ -1,6 +1,5 @@ import fs from 'node:fs/promises' import url from 'node:url' -import path from 'node:path' import { resolve } from 'import-meta-resolve' let puppeteerVersion: string @@ -16,9 +15,8 @@ let puppeteerVersion: string */ export default async function status () { if (!puppeteerVersion) { - const puppeteerPath = await resolve('puppeteer-core', import.meta.url) try { - const pkgJsonPath = path.resolve(url.fileURLToPath(puppeteerPath), '..', '..', '..', '..', 'package.json') + const pkgJsonPath = url.fileURLToPath(resolve('puppeteer-core/package.json', import.meta.url)) const pkgJson = JSON.parse((await fs.readFile(pkgJsonPath, 'utf-8')).toString()) puppeteerVersion = pkgJson.version // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/packages/devtools/src/commands/switchToFrame.ts b/packages/devtools/src/commands/switchToFrame.ts index a259a5e24df..0d5f05cb557 100644 --- a/packages/devtools/src/commands/switchToFrame.ts +++ b/packages/devtools/src/commands/switchToFrame.ts @@ -1,5 +1,5 @@ -import type { Page } from 'puppeteer-core/lib/esm/puppeteer/api/Page.js' -import type { Frame } from 'puppeteer-core/lib/esm/puppeteer/api/Frame.js' +import type { Page } from 'puppeteer-core' +import type { Frame } from 'puppeteer-core/internal/api/Frame.js' import type { ElementReference } from '@testplane/wdio-protocols' import { ELEMENT_KEY } from '../constants.js' @@ -78,7 +78,7 @@ export default async function switchToFrame ( } this.currentFrame = childFrame as unknown as Page - return { id: childFrame._id } + return { id: (childFrame as unknown as Pick)._id } } throw new Error(`Could not switch frame, unknown id: ${id}`) diff --git a/packages/devtools/src/commands/switchToParentFrame.ts b/packages/devtools/src/commands/switchToParentFrame.ts index 7b3d5235cbe..0389bf964cd 100644 --- a/packages/devtools/src/commands/switchToParentFrame.ts +++ b/packages/devtools/src/commands/switchToParentFrame.ts @@ -1,5 +1,4 @@ -import type { Page } from 'puppeteer-core/lib/esm/puppeteer/api/Page.js' -import type { Frame } from 'puppeteer-core/lib/esm/puppeteer/api/Frame.js' +import type { Page, Frame } from 'puppeteer-core' import type DevToolsDriver from '../devtoolsdriver.js' /** diff --git a/packages/devtools/src/constants.ts b/packages/devtools/src/constants.ts index 93d686114a6..6e91ed6e7c0 100644 --- a/packages/devtools/src/constants.ts +++ b/packages/devtools/src/constants.ts @@ -55,7 +55,7 @@ export const DEFAULT_FLAGS = [ export const CHROME_NAMES = ['chrome', 'googlechrome', 'headlesschrome', 'google-chrome', 'chromium'] export const FIREFOX_NAMES = ['firefox', 'ff', 'mozilla', 'mozillafirefox', 'headless firefox', 'headlessfirefox'] export const EDGE_NAMES = ['edge', 'msedge', 'microsoft-edge', 'microsoftedge'] -export const SUPPORTED_BROWSER = [...CHROME_NAMES, ...FIREFOX_NAMES, ...EDGE_NAMES] +export const SUPPORTED_BROWSER = [...CHROME_NAMES, ...EDGE_NAMES] export const BROWSER_TYPE: { chrome: 'chrome', diff --git a/packages/devtools/src/devtoolsdriver.ts b/packages/devtools/src/devtoolsdriver.ts index 2cc57cd7f57..3b219be2f83 100644 --- a/packages/devtools/src/devtoolsdriver.ts +++ b/packages/devtools/src/devtoolsdriver.ts @@ -1,16 +1,12 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { EventEmitter } from 'node:events' -import { v4 as uuidv4 } from 'uuid' +import { randomUUID } from 'node:crypto' import logger from '@testplane/wdio-logger' import type { CommandEndpoint } from '@testplane/wdio-protocols' -import type { Browser } from 'puppeteer-core/lib/esm/puppeteer/api/Browser.js' -import type { Dialog } from 'puppeteer-core/lib/esm/puppeteer/common/Dialog.js' -import type { Page } from 'puppeteer-core/lib/esm/puppeteer/api/Page.js' -import type { Target } from 'puppeteer-core/lib/esm/puppeteer/common/Target.js' -import type { Frame } from 'puppeteer-core/lib/esm/puppeteer/common/Frame.js' +import type { Browser, Dialog, Page, Target, Frame } from 'puppeteer-core' import * as commands from './commands/index.js' import ElementStore from './elementstore.js' @@ -40,7 +36,7 @@ export default class DevToolsDriver { } private _createWindowHandle (page: Page) { - const pageId = uuidv4() + const pageId = randomUUID() this.windows.set(pageId, page) this.currentFrame = page this.currentWindowHandle = pageId diff --git a/packages/devtools/src/elementstore.ts b/packages/devtools/src/elementstore.ts index 33610bd74d9..cd78fba33c3 100644 --- a/packages/devtools/src/elementstore.ts +++ b/packages/devtools/src/elementstore.ts @@ -1,5 +1,4 @@ -import type { ElementHandle } from 'puppeteer-core/lib/esm/puppeteer/api/ElementHandle.js' -import type { Frame } from 'puppeteer-core/lib/esm/puppeteer/api/Frame.js' +import type { ElementHandle, Frame } from 'puppeteer-core' export default class ElementStore { private _index = 0 diff --git a/packages/devtools/src/index.cts b/packages/devtools/src/index.cts new file mode 100644 index 00000000000..b9a5b14b469 --- /dev/null +++ b/packages/devtools/src/index.cts @@ -0,0 +1,51 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +class Devtools { + // Bundled into this CommonJS entrypoint; does not load the ESM dependency graph. + // eslint-disable-next-line @typescript-eslint/no-require-imports + static SUPPORTED_BROWSER: string[] = require('./constants.js').SUPPORTED_BROWSER + + static get default () { + return Devtools + } + + static async newSession ( + options: any, + modifier?: (...args: any[]) => any, + userPrototype = {}, + customCommandWrapper?: (...args: any[]) => any + ): Promise { + const WebDriver = (await import('./index.js')).default + return WebDriver.newSession(options, modifier, userPrototype, customCommandWrapper) + } + + /** + * allows user to attach to existing sessions + */ + static async attachToSession ( + options: any, + modifier?: (...args: any[]) => any, + userPrototype = {}, + commandWrapper?: (...args: any[]) => any + ): Promise { + const Devtools = (await import('./index.js')).default + return Devtools.attachToSession(options, modifier, userPrototype, commandWrapper) + } + + /** + * Changes The instance session id and browser capabilities for the new session + * directly into the passed in browser object + * + * @param {Object} instance the object we get from a new browser session. + * @returns {string} the new session id of the browser + */ + static async reloadSession (instance: any, newCapabilities: any) { + const Devtools = (await import('./index.js')).default + return Devtools.reloadSession(instance, newCapabilities) + } + + static get Devtools () { + return Devtools + } +} + +module.exports = Devtools diff --git a/packages/devtools/src/index.ts b/packages/devtools/src/index.ts index 93f66d61bca..918dd7a7c93 100644 --- a/packages/devtools/src/index.ts +++ b/packages/devtools/src/index.ts @@ -1,14 +1,14 @@ import os from 'node:os' import path from 'node:path' import UAParser from 'ua-parser-js' -import { v4 as uuidv4 } from 'uuid' +import { randomUUID } from 'node:crypto' import logger from '@testplane/wdio-logger' import { webdriverMonad, devtoolsEnvironmentDetector } from '@testplane/wdio-utils' import { validateConfig } from '@testplane/wdio-config' import type { CommandEndpoint } from '@testplane/wdio-protocols' import type { Options, Capabilities } from '@testplane/wdio-types' -import type { Browser } from 'puppeteer-core/lib/esm/puppeteer/api/Browser.js' +import type { Browser } from 'puppeteer-core' import DevToolsDriver from './devtoolsdriver.js' import launch from './launcher.js' @@ -62,7 +62,7 @@ export default class DevTools { const browser = await launch(params.capabilities as WebdriverIO.Capabilities) const pages = await browser.pages() const driver = new DevToolsDriver(browser, pages) - const sessionId = uuidv4() + const sessionId = randomUUID() const uaParser = new UAParser(await browser.userAgent()) const userAgent = uaParser.getResult() @@ -159,7 +159,7 @@ export default class DevTools { const browser = await launch(options.capabilities) const pages = await browser.pages() const driver = new DevToolsDriver(browser, pages) - const sessionId = uuidv4() + const sessionId = randomUUID() const uaParser = new UAParser(await browser.userAgent()) const userAgent = uaParser.getResult() diff --git a/packages/devtools/src/launcher.ts b/packages/devtools/src/launcher.ts index 34c15d9db57..e8e312b40bd 100644 --- a/packages/devtools/src/launcher.ts +++ b/packages/devtools/src/launcher.ts @@ -1,10 +1,10 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { Options } from 'chrome-launcher' import { launch as launchChromeBrowser } from 'chrome-launcher' -import type { PuppeteerLaunchOptions, ConnectOptions } from 'puppeteer-core' +import type { LaunchOptions, ConnectOptions } from 'puppeteer-core' import puppeteer, { KnownDevices, Puppeteer } from 'puppeteer-core' import logger from '@testplane/wdio-logger' -import type { Browser } from 'puppeteer-core/lib/esm/puppeteer/api/Browser.js' +import type { Browser } from 'puppeteer-core' import type { Capabilities } from '@testplane/wdio-types' import { QueryHandler } from 'query-selector-shadow-dom/plugins/puppeteer/index.js' @@ -27,6 +27,17 @@ const log = logger('devtools') const DEVICE_NAMES = Object.keys(KnownDevices) +function getDevtoolsOptions(capabilities: WebdriverIO.Capabilities) { + const { ignoreHTTPSErrors, ...options } = capabilities['wdio:devtoolsOptions'] || {} + if (ignoreHTTPSErrors === undefined && capabilities.acceptInsecureCerts === undefined) { + return options + } + return { + ...options, + acceptInsecureCerts: options.acceptInsecureCerts ?? Boolean(ignoreHTTPSErrors || capabilities.acceptInsecureCerts) + } +} + /** * launches Chrome and returns a Puppeteer browser instance * @param {object} capabilities session capabilities @@ -35,7 +46,7 @@ const DEVICE_NAMES = Object.keys(KnownDevices) async function launchChrome (capabilities: WebdriverIO.Capabilities) { const chromeOptions: Capabilities.ChromeOptions = capabilities[VENDOR_PREFIX.chrome] || {} const mobileEmulation = chromeOptions.mobileEmulation || {} - const devtoolsOptions = capabilities['wdio:devtoolsOptions'] || {} + const devtoolsOptions = getDevtoolsOptions(capabilities) const chromeOptionsArgs = (chromeOptions.args || []).map((arg) => ( arg.startsWith('--') ? arg : `--${arg}` )) @@ -108,7 +119,7 @@ async function launchChrome (capabilities: WebdriverIO.Capabilities) { // Honor both ignoreHTTPSErrors and acceptInsecureCerts // Only for WebKit/Blink engines, Firefox uses a different option - if (capabilities.acceptInsecureCerts || devtoolsOptions.ignoreHTTPSErrors) { + if (devtoolsOptions.acceptInsecureCerts) { chromeFlags.push('--ignore-certificate-errors') } @@ -147,10 +158,9 @@ async function launchChrome (capabilities: WebdriverIO.Capabilities) { return browser } -function launchBrowser (capabilities: WebdriverIO.Capabilities, browserType: 'edge' | 'firefox') { - const product = browserType === BROWSER_TYPE.firefox ? BROWSER_TYPE.firefox : BROWSER_TYPE.chrome +function launchBrowser (capabilities: WebdriverIO.Capabilities, browserType: 'edge') { const vendorCapKey = VENDOR_PREFIX[browserType] - const devtoolsOptions = capabilities['wdio:devtoolsOptions'] || {} + const devtoolsOptions = getDevtoolsOptions(capabilities) /** * `ignoreDefaultArgs` and `headless` are currently expected to be part of the capabilities @@ -161,12 +171,6 @@ function launchBrowser (capabilities: WebdriverIO.Capabilities, browserType: 'ed const ignoreDefaultArgs = (capabilities as any).ignoreDefaultArgs || devtoolsOptions.ignoreDefaultArgs const headless = (capabilities as any).headless || devtoolsOptions.headless - // Set devtoolsOptions to honor both ignoreHTTPSErrors and acceptInsecureCerts - // Only necessary for Firefox, not for WebKit/Blink engines - devtoolsOptions.ignoreHTTPSErrors = browserType === 'firefox' - ? Boolean(devtoolsOptions.ignoreHTTPSErrors || capabilities.acceptInsecureCerts) - : devtoolsOptions.ignoreHTTPSErrors ?? false - if (!capabilities[vendorCapKey]) { capabilities[vendorCapKey] = {} } @@ -177,8 +181,8 @@ function launchBrowser (capabilities: WebdriverIO.Capabilities, browserType: 'ed browserFinderMethod()[0] ) - const puppeteerOptions: PuppeteerLaunchOptions = Object.assign({ - product, + const puppeteerOptions: LaunchOptions = Object.assign({ + browser: 'chrome', executablePath, ignoreDefaultArgs, headless: Boolean(headless), @@ -193,7 +197,7 @@ function launchBrowser (capabilities: WebdriverIO.Capabilities, browserType: 'ed ...['--ignore-certificate-errors'] .filter(() => browserType === 'edge' - && (devtoolsOptions.ignoreHTTPSErrors || capabilities.acceptInsecureCerts) ) + && devtoolsOptions.acceptInsecureCerts) ] }, capabilities[vendorCapKey] || {}, devtoolsOptions || {}) @@ -207,7 +211,7 @@ function launchBrowser (capabilities: WebdriverIO.Capabilities, browserType: 'ed function connectBrowser (connectionUrl: string, capabilities: WebdriverIO.Capabilities) { const connectionProp = connectionUrl.startsWith('http') ? 'browserURL' : 'browserWSEndpoint' - const devtoolsOptions = capabilities['wdio:devtoolsOptions'] + const devtoolsOptions = getDevtoolsOptions(capabilities) const options: ConnectOptions = { [connectionProp]: connectionUrl, ...devtoolsOptions @@ -216,6 +220,11 @@ function connectBrowser (connectionUrl: string, capabilities: WebdriverIO.Capabi } export default async function launch (capabilities: WebdriverIO.Capabilities) { + const browserName = capabilities.browserName?.toLowerCase() + if (browserName && FIREFOX_NAMES.includes(browserName)) { + throw new Error('Firefox no longer supports the CDP backend. Use WebDriver BiDi instead of the Devtools automation protocol.') + } + try { Puppeteer.unregisterCustomQueryHandler('shadow') } catch { @@ -224,7 +233,6 @@ export default async function launch (capabilities: WebdriverIO.Capabilities) { // ToDo(Christian): fix types (https://github.com/Georgegriff/query-selector-shadow-dom/issues/77) Puppeteer.registerCustomQueryHandler('shadow', QueryHandler as any) - const browserName = capabilities.browserName?.toLowerCase() /** * check if capabilities already contains connection details and connect @@ -257,10 +265,6 @@ export default async function launch (capabilities: WebdriverIO.Capabilities) { return launchChrome(capabilities) } - if (browserName && FIREFOX_NAMES.includes(browserName)) { - return launchBrowser(capabilities, BROWSER_TYPE.firefox) - } - /* istanbul ignore next */ if (browserName && EDGE_NAMES.includes(browserName)) { return launchBrowser(capabilities, BROWSER_TYPE.edge) diff --git a/packages/devtools/src/types.ts b/packages/devtools/src/types.ts index 4f9db8b9fd5..5e5c0c07804 100644 --- a/packages/devtools/src/types.ts +++ b/packages/devtools/src/types.ts @@ -3,9 +3,7 @@ import type { EventEmitter } from 'node:events' import type { Options, Capabilities } from '@testplane/wdio-types' import type { ProtocolCommands } from '@testplane/wdio-protocols' -import type { LaunchOptions, BrowserLaunchArgumentOptions, BrowserConnectOptions, ConnectOptions } from 'puppeteer-core' -import type { Browser } from 'puppeteer-core/lib/esm/puppeteer/api/Browser.js' -import type { EventEmitter as PuppeteerEventEmitter } from 'puppeteer-core/lib/esm/puppeteer/common/EventEmitter.js' +import type { LaunchOptions, Browser, EventEmitter as PuppeteerEventEmitter } from 'puppeteer-core' declare global { namespace WebdriverIO { @@ -15,7 +13,9 @@ declare global { } } -export interface DevToolsOptions extends LaunchOptions, BrowserLaunchArgumentOptions, BrowserConnectOptions, ConnectOptions { +export interface DevToolsOptions extends LaunchOptions { + /** @deprecated Use acceptInsecureCerts instead. */ + ignoreHTTPSErrors?: boolean /** * If you want to start Google Chrome on a custom port */ @@ -54,7 +54,7 @@ export interface Client extends BaseClient, ProtocolCommands { */ export interface ActiveListener { /** Event Emitter object emitting to the handler. */ - emitter: PuppeteerEventEmitter + emitter: PuppeteerEventEmitter> /** Name of the event the handler is attached to. */ eventName: string /** Event function handler, bound to the context of its class instance. */ diff --git a/packages/devtools/src/utils.ts b/packages/devtools/src/utils.ts index 2e6cba2fd8d..4892c4c7a90 100644 --- a/packages/devtools/src/utils.ts +++ b/packages/devtools/src/utils.ts @@ -11,10 +11,7 @@ import type { CommandParameters, CommandPathVariables, ElementReference } from ' import { WebDriverProtocol } from '@testplane/wdio-protocols' import { launch as launchChromeBrowser, type Options } from 'chrome-launcher' import type { Logger } from '@testplane/wdio-logger' -import type { ElementHandle } from 'puppeteer-core/lib/esm/puppeteer/api/ElementHandle.js' -import type { Browser } from 'puppeteer-core/lib/esm/puppeteer/api/Browser.js' -import type { Frame } from 'puppeteer-core/lib/esm/puppeteer/api/Frame.js' -import type { Page } from 'puppeteer-core/lib/esm/puppeteer/api/Page.js' +import type { ElementHandle, Browser, Frame, Page } from 'puppeteer-core' import cleanUp from './scripts/cleanUpSerializationSelector.js' import { ELEMENT_KEY, SERIALIZE_PROPERTY, SERIALIZE_FLAG, ERROR_MESSAGES } from './constants.js' @@ -113,10 +110,10 @@ export async function findElement ( * implicitly wait for the element if timeout is set */ const implicitTimeout = this.timeouts.get('implicit') - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - using === 'xpath' - ? await context.waitForSelector(`xpath/.${value}`, { timeout: implicitTimeout }) - : await context.waitForSelector(value, { timeout: implicitTimeout }) + if (implicitTimeout) { + const selector = using === 'xpath' ? `xpath/.${value}` : value + await context.waitForSelector(selector, { timeout: implicitTimeout }) + } let element: ElementHandle | null = null try { @@ -153,10 +150,10 @@ export async function findElements ( * implicitly wait for the element if timeout is set */ const implicitTimeout = this.timeouts.get('implicit') - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - using === 'xpath' - ? await context.waitForSelector(`xpath/.${value}`, { timeout: implicitTimeout }) - : await context.waitForSelector(value, { timeout: implicitTimeout }) + if (implicitTimeout) { + const selector = using === 'xpath' ? `xpath/.${value}` : value + await context.waitForSelector(selector, { timeout: implicitTimeout }) + } const elements = using === 'xpath' ? await context.$$(`xpath/.${value}`) as ElementHandle[] diff --git a/packages/devtools/tests/__snapshots__/launcher.test.ts.snap b/packages/devtools/tests/__snapshots__/launcher.test.ts.snap index a183694b069..34599fa9e10 100644 --- a/packages/devtools/tests/__snapshots__/launcher.test.ts.snap +++ b/packages/devtools/tests/__snapshots__/launcher.test.ts.snap @@ -7,6 +7,7 @@ exports[`launch Edge with custom arguments 1`] = ` "args": [ "foobar", ], + "browser": "chrome", "defaultViewport": { "height": 456, "width": 123, @@ -14,9 +15,7 @@ exports[`launch Edge with custom arguments 1`] = ` "executablePath": "/path/to/edge", "headless": true, "ignoreDefaultArgs": undefined, - "ignoreHTTPSErrors": false, "prefs": undefined, - "product": "chrome", }, ], ] @@ -27,6 +26,7 @@ exports[`launch Edge with default values 1`] = ` [ { "args": [], + "browser": "chrome", "defaultViewport": { "height": 900, "width": 1200, @@ -34,9 +34,7 @@ exports[`launch Edge with default values 1`] = ` "executablePath": "/path/to/edge", "headless": false, "ignoreDefaultArgs": undefined, - "ignoreHTTPSErrors": false, "prefs": undefined, - "product": "chrome", }, ], ] @@ -47,6 +45,7 @@ exports[`launch Edge without Puppeteer default args (backwards compat) 1`] = ` [ { "args": [], + "browser": "chrome", "defaultViewport": { "height": 900, "width": 1200, @@ -54,11 +53,9 @@ exports[`launch Edge without Puppeteer default args (backwards compat) 1`] = ` "executablePath": "/path/to/edge", "headless": true, "ignoreDefaultArgs": true, - "ignoreHTTPSErrors": false, "prefs": { "foo": "bar", }, - "product": "chrome", }, ], ] @@ -69,6 +66,7 @@ exports[`launch Edge without Puppeteer default args 1`] = ` [ { "args": [], + "browser": "chrome", "defaultViewport": { "height": 900, "width": 1200, @@ -76,73 +74,7 @@ exports[`launch Edge without Puppeteer default args 1`] = ` "executablePath": "/path/to/edge", "headless": true, "ignoreDefaultArgs": true, - "ignoreHTTPSErrors": false, "prefs": undefined, - "product": "chrome", - }, - ], -] -`; - -exports[`launch Firefox Nightly binary without Puppeteer default args 1`] = ` -[ - [ - { - "args": [], - "binary": "firefox", - "defaultViewport": { - "height": 900, - "width": 1200, - }, - "executablePath": "firefox", - "headless": true, - "ignoreDefaultArgs": true, - "ignoreHTTPSErrors": false, - "prefs": { - "foo": "bar", - }, - "product": "firefox", - }, - ], -] -`; - -exports[`launch Firefox Nightly without Puppeteer default args 1`] = ` -[ - [ - { - "args": [], - "binary": "/foo/firefox-nightly", - "defaultViewport": { - "height": 900, - "width": 1200, - }, - "executablePath": "/foo/firefox-nightly", - "headless": true, - "ignoreDefaultArgs": true, - "ignoreHTTPSErrors": false, - "prefs": undefined, - "product": "firefox", - }, - ], -] -`; - -exports[`launch Firefox with custom arguments 1`] = ` -[ - [ - { - "args": [], - "defaultViewport": { - "height": 456, - "width": 123, - }, - "executablePath": "/path/to/firefox", - "headless": true, - "ignoreDefaultArgs": undefined, - "ignoreHTTPSErrors": false, - "prefs": undefined, - "product": "firefox", }, ], ] diff --git a/packages/devtools/tests/devtools.test.ts b/packages/devtools/tests/devtools.test.ts index c0e1d8e7287..16f89cae94e 100644 --- a/packages/devtools/tests/devtools.test.ts +++ b/packages/devtools/tests/devtools.test.ts @@ -1,9 +1,16 @@ import path from 'node:path' +import { randomUUID } from 'node:crypto' +import type * as crypto from 'node:crypto' import { expect, test, vi, beforeEach } from 'vitest' import type { Capabilities } from '@testplane/wdio-types' import launch from '../src/launcher.js' import DevTools from '../src/index.js' +vi.mock('node:crypto', async (importOriginal) => ({ + ...await importOriginal(), + randomUUID: vi.fn().mockReturnValue('12345678-1234-4234-9234-123456789abc') +})) + vi.mock('@testplane/wdio-logger', () => import(path.join(process.cwd(), '__mocks__', '@testplane/wdio-logger'))) vi.mock('../src/launcher', () => ({ default: vi.fn().mockImplementation((capabilities) => { @@ -45,6 +52,8 @@ test('newSession', async () => { expect(client.isDevTools).toBe(true) expect(client.isChrome).toBe(true) expect(client.isW3C).toBe(true) + expect(randomUUID).toHaveBeenCalledWith() + expect(client.sessionId).toBe('12345678-1234-4234-9234-123456789abc') expect(launch).toBeCalledTimes(1) }) diff --git a/packages/devtools/tests/devtoolsdriver.test.ts b/packages/devtools/tests/devtoolsdriver.test.ts index 57020912fbb..04d4b5f366a 100644 --- a/packages/devtools/tests/devtoolsdriver.test.ts +++ b/packages/devtools/tests/devtoolsdriver.test.ts @@ -2,8 +2,8 @@ import path from 'node:path' import { expect, test, vi, beforeEach } from 'vitest' import DevToolsDriver from '../src/devtoolsdriver.js' -import type { Dialog } from 'puppeteer-core/lib/esm/puppeteer/common/Dialog.js' -import type { Frame } from 'puppeteer-core/lib/esm/puppeteer/api/Frame.js' +import type { Dialog } from 'puppeteer-core' +import type { Frame } from 'puppeteer-core' vi.mock('@testplane/wdio-logger', () => import(path.join(process.cwd(), '__mocks__', '@testplane/wdio-logger'))) vi.mock('puppeteer-core', () => import(path.join(process.cwd(), '__mocks__', 'puppeteer-core'))) @@ -36,6 +36,7 @@ const setNormalPageLoadBehavior = () => executionContext.evaluate.mockImplementa )) const frame = { + get evaluate () { return executionContext.evaluate }, executionContext: vi.fn().mockImplementation(() => Promise.resolve(executionContext)) } diff --git a/packages/devtools/tests/elementStore.test.ts b/packages/devtools/tests/elementStore.test.ts index f08c4544ee9..570392b9d0d 100644 --- a/packages/devtools/tests/elementStore.test.ts +++ b/packages/devtools/tests/elementStore.test.ts @@ -1,8 +1,8 @@ import path from 'node:path' import { expect, test, vi } from 'vitest' import ElementStore from '../src/elementstore.js' -import type { ElementHandle } from 'puppeteer-core/lib/esm/puppeteer/common/ElementHandle.js' -import type { Frame } from 'puppeteer-core/lib/esm/puppeteer/api/Frame.js' +import type { ElementHandle } from 'puppeteer-core' +import type { Frame } from 'puppeteer-core' vi.mock('@testplane/wdio-logger', () => import(path.join(process.cwd(), '__mocks__', '@testplane/wdio-logger'))) @@ -13,9 +13,7 @@ const elementHandleFactory = ( async evaluate(cb: any) { return cb({ isConnected }) }, - executionContext() { - return { _world: { frame: () => frame } } - } + frame }) test('should keep a map of elements', async () => { diff --git a/packages/devtools/tests/entrypoints.test.ts b/packages/devtools/tests/entrypoints.test.ts new file mode 100644 index 00000000000..45ddde6c308 --- /dev/null +++ b/packages/devtools/tests/entrypoints.test.ts @@ -0,0 +1,25 @@ +import { execFileSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { expect, test } from 'vitest' + +test('loads the CommonJS entrypoint and forwards async commands to ESM', () => { + const packageDirectory = fileURLToPath(new URL('..', import.meta.url)) + const output = execFileSync(process.execPath, ['-e', ` + const assert = require('node:assert/strict') + const Devtools = require('./') + assert.equal(typeof Devtools.newSession, 'function') + assert.equal(typeof Devtools.attachToSession, 'function') + assert.equal(typeof Devtools.reloadSession, 'function') + assert.equal(Devtools.default, Devtools) + assert(Devtools.SUPPORTED_BROWSER.includes('chrome')) + assert(!Devtools.SUPPORTED_BROWSER.includes('firefox')) + assert.rejects( + Devtools.newSession({ capabilities: { browserName: 'firefox' } }), + /Firefox.*CDP.*WebDriver BiDi/ + ).then(() => console.log('CJS passed'), error => { + console.error(error) + process.exitCode = 1 + }) + `], { cwd: packageDirectory, encoding: 'utf8', timeout: 10_000 }) + expect(output).toContain('CJS passed') +}) diff --git a/packages/devtools/tests/launcher.test.ts b/packages/devtools/tests/launcher.test.ts index 4658e09748b..8bb367c0578 100644 --- a/packages/devtools/tests/launcher.test.ts +++ b/packages/devtools/tests/launcher.test.ts @@ -4,6 +4,7 @@ import puppeteer, { Puppeteer } from 'puppeteer-core' import { launch as launchChromeBrowser } from 'chrome-launcher' import launch from '../src/launcher.js' +import { SUPPORTED_BROWSER } from '../src/constants.js' vi.mock('@testplane/wdio-logger', () => import(path.join(process.cwd(), '__mocks__', '@testplane/wdio-logger'))) vi.mock('puppeteer-core', () => import(path.join(process.cwd(), '__mocks__', 'puppeteer-core'))) @@ -31,6 +32,28 @@ beforeEach(() => { vi.mocked(launchChromeBrowser).mockClear() }) +test('rejects Firefox before launching the unsupported CDP backend', async () => { + expect(SUPPORTED_BROWSER).not.toContain('firefox') + await expect(launch({ browserName: 'firefox' })).rejects.toThrow(/Firefox.*CDP.*WebDriver BiDi/) + expect(puppeteer.launch).not.toHaveBeenCalled() + expect(puppeteer.connect).not.toHaveBeenCalled() +}) + +test('maps legacy HTTPS error handling to Puppeteer connect options', async () => { + await launch({ + browserName: 'chrome', + 'wdio:devtoolsOptions': { browserURL: 'http://localhost:9222', ignoreHTTPSErrors: true } + }) + expect(puppeteer.connect).toHaveBeenCalledWith(expect.objectContaining({ acceptInsecureCerts: true })) + expect(puppeteer.connect).not.toHaveBeenCalledWith(expect.objectContaining({ ignoreHTTPSErrors: true })) +}) + +test('uses the supported Puppeteer browser selector for Edge', async () => { + await launch({ browserName: 'edge' }) + expect(puppeteer.launch).toHaveBeenCalledWith(expect.objectContaining({ browser: 'chrome' })) + expect(puppeteer.launch).not.toHaveBeenCalledWith(expect.objectContaining({ product: 'chrome' })) +}) + test('launch chrome with default values', async () => { const browser = await launch({ browserName: 'chrome' @@ -182,8 +205,8 @@ test('throws an error if an unknown deviceName is picked', async () => { expect(err.message).toContain('Unknown device name "Cool Nexus 5"') }) -test('launch Firefox with custom arguments', async () => { - await launch({ +test('rejects Firefox with custom arguments', async () => { + await expect(launch({ browserName: 'firefox', 'wdio:devtoolsOptions': { headless: true, @@ -192,8 +215,9 @@ test('launch Firefox with custom arguments', async () => { height: 456 } } - }) - expect(vi.mocked(puppeteer.launch).mock.calls).toMatchSnapshot() + })).rejects.toThrow(/Firefox.*CDP.*WebDriver BiDi/) + expect(puppeteer.launch).not.toHaveBeenCalled() + expect(puppeteer.connect).not.toHaveBeenCalled() }) test('launch Edge with default values', async () => { @@ -230,8 +254,8 @@ test('throws if browser is unknown', async () => { } }) -test('launch Firefox Nightly without Puppeteer default args', async () => { - await launch({ +test('rejects Firefox Nightly without Puppeteer default args', async () => { + await expect(launch({ browserName: 'firefox', 'moz:firefoxOptions': { binary: '/foo/firefox-nightly' @@ -240,12 +264,13 @@ test('launch Firefox Nightly without Puppeteer default args', async () => { headless: true, ignoreDefaultArgs: true } - }) - expect(vi.mocked(puppeteer.launch).mock.calls).toMatchSnapshot() + })).rejects.toThrow(/Firefox.*CDP.*WebDriver BiDi/) + expect(puppeteer.launch).not.toHaveBeenCalled() + expect(puppeteer.connect).not.toHaveBeenCalled() }) -test('launch Firefox Nightly binary without Puppeteer default args', async () => { - await launch({ +test('rejects Firefox Nightly binary without Puppeteer default args', async () => { + await expect(launch({ browserName: 'firefox', 'moz:firefoxOptions': { binary: 'firefox', @@ -257,8 +282,9 @@ test('launch Firefox Nightly binary without Puppeteer default args', async () => ignoreDefaultArgs: true, headless: true } - }) - expect(vi.mocked(puppeteer.launch).mock.calls).toMatchSnapshot() + })).rejects.toThrow(/Firefox.*CDP.*WebDriver BiDi/) + expect(puppeteer.launch).not.toHaveBeenCalled() + expect(puppeteer.connect).not.toHaveBeenCalled() }) test('launch Edge without Puppeteer default args', async () => { @@ -329,7 +355,7 @@ test('connect to an existing devtools browser url', async () => { }) expect(puppeteer.launch).not.toBeCalled() expect(vi.mocked(puppeteer.connect)) - .toBeCalledWith(devtoolsOptions) + .toBeCalledWith({ browserURL: devtoolsOptions.browserURL, acceptInsecureCerts: true }) }) test('launch Chrome with default Args to ignore certificate errors (devtools option ignoreHTTPSErrors)', async () => { @@ -402,36 +428,30 @@ test('launch Edge while ignoring default args to ignore certificate errors (devt ) }) -test('launch Firefox with default Args to ignore certificate errors (devtools option ignoreHTTPSErrors)', async () => { - await launch({ +test('rejects Firefox with default Args to ignore certificate errors (devtools option ignoreHTTPSErrors)', async () => { + await expect(launch({ browserName: 'firefox', 'wdio:devtoolsOptions': { ignoreDefaultArgs: false, headless: true, ignoreHTTPSErrors: true } - }) - expect(puppeteer.launch).toBeCalledWith( - expect.objectContaining({ - 'ignoreHTTPSErrors': true - }) - ) + })).rejects.toThrow(/Firefox.*CDP.*WebDriver BiDi/) + expect(puppeteer.launch).not.toHaveBeenCalled() + expect(puppeteer.connect).not.toHaveBeenCalled() }) -test('launch Firefox while ignoring default args to ignore certificate errors (devtools option ignoreHTTPSErrors)', async () => { - await launch({ +test('rejects Firefox while ignoring default args to ignore certificate errors (devtools option ignoreHTTPSErrors)', async () => { + await expect(launch({ browserName: 'firefox', 'wdio:devtoolsOptions': { ignoreDefaultArgs: true, headless: true, ignoreHTTPSErrors: true } - }) - expect(puppeteer.launch).toBeCalledWith( - expect.objectContaining({ - 'ignoreHTTPSErrors': true - }) - ) + })).rejects.toThrow(/Firefox.*CDP.*WebDriver BiDi/) + expect(puppeteer.launch).not.toHaveBeenCalled() + expect(puppeteer.connect).not.toHaveBeenCalled() }) test('launch Chrome with default Args to ignore certificate errors (W3C capability acceptInsecureCerts)', async () => { @@ -504,37 +524,30 @@ test('launch Edge while ignoring default args to ignore certificate errors (W3C ) }) -test('launch Firefox with default Args to ignore certificate errors (W3C capability acceptInsecureCerts)', async () => { - await launch({ +test('rejects Firefox with default Args to ignore certificate errors (W3C capability acceptInsecureCerts)', async () => { + await expect(launch({ browserName: 'firefox', acceptInsecureCerts: true, 'wdio:devtoolsOptions': { ignoreDefaultArgs: false, headless: true, } - }) - expect(puppeteer.launch).toBeCalledWith( - expect.objectContaining({ - 'ignoreHTTPSErrors': true - }) - ) + })).rejects.toThrow(/Firefox.*CDP.*WebDriver BiDi/) + expect(puppeteer.launch).not.toHaveBeenCalled() + expect(puppeteer.connect).not.toHaveBeenCalled() }) -test('launch Firefox while ignoring default args to ignore certificate errors (W3C capability acceptInsecureCerts)', async () => { - await launch({ +test('rejects Firefox while ignoring default args to ignore certificate errors (W3C capability acceptInsecureCerts)', async () => { + await expect(launch({ browserName: 'firefox', acceptInsecureCerts: true, 'wdio:devtoolsOptions': { ignoreDefaultArgs: true, headless: true, } - }) - - expect(puppeteer.launch).toBeCalledWith( - expect.objectContaining({ - 'ignoreHTTPSErrors': true, - }) - ) + })).rejects.toThrow(/Firefox.*CDP.*WebDriver BiDi/) + expect(puppeteer.launch).not.toHaveBeenCalled() + expect(puppeteer.connect).not.toHaveBeenCalled() }) // qwe @@ -610,34 +623,28 @@ test('launch Edge while ignoring default args and not specifying devtools option ) }) -test('launch Firefox with default Args and not specifying devtools option ignoreHTTPSErrors', async () => { - await launch({ +test('rejects Firefox with default Args and not specifying devtools option ignoreHTTPSErrors', async () => { + await expect(launch({ browserName: 'firefox', 'wdio:devtoolsOptions': { ignoreDefaultArgs: false, headless: true, } - }) - expect(puppeteer.launch).toBeCalledWith( - expect.objectContaining({ - 'ignoreHTTPSErrors': false - }) - ) + })).rejects.toThrow(/Firefox.*CDP.*WebDriver BiDi/) + expect(puppeteer.launch).not.toHaveBeenCalled() + expect(puppeteer.connect).not.toHaveBeenCalled() }) -test('launch Firefox while ignoring default args and not specifying devtools option ignoreHTTPSErrors', async () => { - await launch({ +test('rejects Firefox while ignoring default args and not specifying devtools option ignoreHTTPSErrors', async () => { + await expect(launch({ browserName: 'firefox', 'wdio:devtoolsOptions': { ignoreDefaultArgs: true, headless: true, } - }) - expect(puppeteer.launch).toBeCalledWith( - expect.objectContaining({ - 'ignoreHTTPSErrors': false - }) - ) + })).rejects.toThrow(/Firefox.*CDP.*WebDriver BiDi/) + expect(puppeteer.launch).not.toHaveBeenCalled() + expect(puppeteer.connect).not.toHaveBeenCalled() }) test('launch Chrome with default Args and not specifying W3C capability acceptInsecureCerts', async () => { @@ -710,35 +717,28 @@ test('launch Edge while ignoring default args and not specifying W3C capability ) }) -test('launch Firefox with default args and not specifying W3C capability acceptInsecureCerts', async () => { - await launch({ +test('rejects Firefox with default args and not specifying W3C capability acceptInsecureCerts', async () => { + await expect(launch({ browserName: 'firefox', 'wdio:devtoolsOptions': { ignoreDefaultArgs: false, headless: true, } - }) - expect(puppeteer.launch).toBeCalledWith( - expect.objectContaining({ - 'ignoreHTTPSErrors': false - }) - ) + })).rejects.toThrow(/Firefox.*CDP.*WebDriver BiDi/) + expect(puppeteer.launch).not.toHaveBeenCalled() + expect(puppeteer.connect).not.toHaveBeenCalled() }) -test('launch Firefox while ignoring default args and not specifying W3C capability acceptInsecureCerts', async () => { - await launch({ +test('rejects Firefox while ignoring default args and not specifying W3C capability acceptInsecureCerts', async () => { + await expect(launch({ browserName: 'firefox', 'wdio:devtoolsOptions': { ignoreDefaultArgs: true, headless: true, } - }) - - expect(puppeteer.launch).toBeCalledWith( - expect.objectContaining({ - 'ignoreHTTPSErrors': false, - }) - ) + })).rejects.toThrow(/Firefox.*CDP.*WebDriver BiDi/) + expect(puppeteer.launch).not.toHaveBeenCalled() + expect(puppeteer.connect).not.toHaveBeenCalled() }) test('launch Chrome with defaultViewPort as null', async () => { diff --git a/packages/devtools/tests/performActions.test.ts b/packages/devtools/tests/performActions.test.ts new file mode 100644 index 00000000000..03ca071caf1 --- /dev/null +++ b/packages/devtools/tests/performActions.test.ts @@ -0,0 +1,25 @@ +import { expect, test, vi } from 'vitest' +import performActions from '../src/commands/performActions.js' +import type DevToolsDriver from '../src/devtoolsdriver.js' + +test('performs two clicks using the supported Puppeteer click count option', async () => { + const click = vi.fn() + const driver = { getPageHandle: () => ({ mouse: { click } }) } as unknown as DevToolsDriver + + await performActions.call(driver, { + actions: [{ + type: 'pointer', + actions: [ + { type: 'pointerMove', x: 20, y: 30 }, + { type: 'pointerDown', button: 0 }, + { type: 'pointerUp', button: 0 }, + { type: 'pause', duration: 10 }, + { type: 'pointerDown', button: 0 }, + { type: 'pointerUp', button: 0 } + ] + }] + }) + + expect(click).toHaveBeenCalledTimes(1) + expect(click).toHaveBeenCalledWith(20, 30, { count: 2 }) +}) diff --git a/packages/devtools/tests/status.test.ts b/packages/devtools/tests/status.test.ts new file mode 100644 index 00000000000..e23fbe79752 --- /dev/null +++ b/packages/devtools/tests/status.test.ts @@ -0,0 +1,6 @@ +import { expect, test } from 'vitest' +import status from '../src/commands/status.js' + +test('reports the installed Puppeteer version without depending on its entrypoint layout', async () => { + expect(await status()).toEqual({ message: '', ready: true, puppeteerVersion: '25.10.0' }) +}) diff --git a/packages/devtools/tests/utils.test.ts b/packages/devtools/tests/utils.test.ts index 448e2b8cc98..ee84e74efeb 100644 --- a/packages/devtools/tests/utils.test.ts +++ b/packages/devtools/tests/utils.test.ts @@ -162,14 +162,14 @@ describe('findElement utils', () => { timeouts: { get: vi.fn() }, elementStore: { set: vi.fn().mockReturnValue('foobar') } } - pageMock.$x.mockReturnValue(Promise.resolve([42])) + pageMock.$$.mockReturnValue(Promise.resolve([42])) expect(await findElement.call(scope as any, pageMock as any, 'xpath', '//img')) .toEqual({ 'element-6066-11e4-a52e-4f735466cecf': 'foobar' }) expect(pageMock.waitForSelector).toBeCalledTimes(0) expect(pageMock.$).toBeCalledTimes(0) - expect(pageMock.$x).toBeCalledWith('//img') + expect(pageMock.$$).toBeCalledWith('xpath/.//img') }) it('should fail if not found', async () => { @@ -212,10 +212,10 @@ describe('findElement utils', () => { timeouts: { get: vi.fn().mockReturnValue(1234) }, elementStore: { set: vi.fn() } } - pageMock.$.mockReturnValue(Promise.resolve('foobar')) + pageMock.$$.mockReturnValue(Promise.resolve(['foobar'])) await findElement.call(scope as any, pageMock as any, 'xpath', 'barfoo') - expect(pageMock.waitForSelector).toBeCalledTimes(0) - expect(pageMock.waitForXPath).toBeCalledTimes(1) + expect(pageMock.waitForSelector).toBeCalledWith('xpath/.barfoo', { timeout: 1234 }) + expect(pageMock.waitForXPath).not.toHaveBeenCalled() }) }) @@ -237,11 +237,10 @@ describe('findElement utils', () => { timeouts: { get: vi.fn() }, elementStore: { set: vi.fn().mockReturnValue('foobar') } } - pageMock.$x.mockReturnValue(Promise.resolve([42, 11])) + pageMock.$$.mockReturnValue(Promise.resolve([42, 11])) expect(await findElements.call(scope as any, pageMock as any, 'xpath', 'barfoo')).toMatchSnapshot() expect(pageMock.waitForSelector).toBeCalledTimes(0) - expect(pageMock.$$).toBeCalledTimes(0) - expect(pageMock.$x).toBeCalledWith('barfoo') + expect(pageMock.$$).toBeCalledWith('xpath/.barfoo') }) it('should return immediately if no elements were found', async () => { @@ -270,10 +269,10 @@ describe('findElement utils', () => { timeouts: { get: vi.fn().mockReturnValue(1234) }, elementStore: { set: vi.fn() } } - pageMock.$x.mockReturnValue(Promise.resolve(['foobar'])) + pageMock.$$.mockReturnValue(Promise.resolve(['foobar'])) await findElements.call(scope as any, pageMock as any, 'xpath', 'barfoo') - expect(pageMock.waitForSelector).toBeCalledTimes(0) - expect(pageMock.waitForXPath).toBeCalledTimes(1) + expect(pageMock.waitForSelector).toBeCalledWith('xpath/.barfoo', { timeout: 1234 }) + expect(pageMock.waitForXPath).not.toHaveBeenCalled() }) }) }) diff --git a/packages/wdio-allure-reporter/tests/suite.test.ts b/packages/wdio-allure-reporter/tests/suite.test.ts index 72069d33aad..c86087c2a8f 100644 --- a/packages/wdio-allure-reporter/tests/suite.test.ts +++ b/packages/wdio-allure-reporter/tests/suite.test.ts @@ -539,6 +539,7 @@ describe('Hook reporting', () => { reporter.onRunnerStart(runnerEvent) reporter.onSuiteStart(suiteStart()) reporter.onTestStart(testStart()) + reporter.onTestPass() reporter.onHookStart(eachHookStart()) reporter.onHookEnd(eachHookFailed()) reporter.onSuiteEnd(suiteEnd()) @@ -552,9 +553,10 @@ describe('Hook reporting', () => { expect(testCaseStep).toBeDefined() expect(testCaseStep.status).toEqual(Status.BROKEN) - const hookCase = results.find((tc => tc.name === '"before each" hook')) - expect(hookCase).toBeDefined() - expect(hookCase.status).toEqual(Status.BROKEN) + // Hooks are container fixtures, not duplicate test cases. + const previousTest = results.find(tc => tc.name === testStart().title) + expect(previousTest).toBeDefined() + expect(previousTest.status).toEqual(Status.PASSED) expect(containers[0].befores[0].steps[0]).toBeDefined() expect(containers[0].befores[0].steps[0].name).toEqual('"before each" hook') expect(containers[0].befores[0].steps[0].status).toEqual(Status.BROKEN) @@ -903,8 +905,10 @@ for (const protocol of ['webdriver', 'devtools']) { reporter.onSuiteEnd(suiteEnd()) reporter.onRunnerEnd(runnerEnd()) - const { results } = getResults(outputDir) - expect(results).toHaveLength(2) + const { results, containers } = getResults(outputDir) + expect(results).toHaveLength(1) + expect(containers[0].befores[0].status).toBe(Status.BROKEN) + expect(containers[0].befores[0].steps[0].name).toBe(hookFailed().title) const result = results.find( res => res.attachments.length === 1) expect(result).toBeDefined() diff --git a/packages/wdio-cli/src/launcher.ts b/packages/wdio-cli/src/launcher.ts index 04c600a8197..a99aa5efce2 100644 --- a/packages/wdio-cli/src/launcher.ts +++ b/packages/wdio-cli/src/launcher.ts @@ -136,8 +136,8 @@ class Launcher { * pre-configure necessary driver for worker threads */ await Promise.all([ - setupDriver({}, []), - setupBrowser({}, []) + setupDriver(config, caps), + setupBrowser(config, caps) ]) exitCode = await this._runMode(config, caps) diff --git a/packages/wdio-cli/tests/commands/repl.test.ts b/packages/wdio-cli/tests/commands/repl.test.ts index d40e5e9742d..9e2fa632c58 100644 --- a/packages/wdio-cli/tests/commands/repl.test.ts +++ b/packages/wdio-cli/tests/commands/repl.test.ts @@ -22,7 +22,7 @@ vi.mock('@testplane/wdio-utils', () => { vi.mock('repl') vi.mock('yargs') -vi.mock('webdriverio', () => import(path.join(process.cwd(), '__mocks__', 'webdriverio'))) +vi.mock('@testplane/webdriverio', () => import(path.join(process.cwd(), '__mocks__', 'webdriverio'))) describe('repl commandDir', () => { it('should call debug command', async () => { diff --git a/packages/wdio-cli/tests/launcher.test.ts b/packages/wdio-cli/tests/launcher.test.ts index 4a238597d3a..4e62bd7fede 100644 --- a/packages/wdio-cli/tests/launcher.test.ts +++ b/packages/wdio-cli/tests/launcher.test.ts @@ -2,6 +2,7 @@ import path from 'node:path' import { vi, describe, it, expect, afterEach, beforeEach } from 'vitest' import logger from '@testplane/wdio-logger' import { sleep, enableFileLogging } from '@testplane/wdio-utils' +import { setupBrowser, setupDriver } from '@testplane/wdio-utils/node' import Launcher from '../src/launcher.js' const caps: WebdriverIO.Capabilities = { @@ -16,6 +17,10 @@ vi.mock('node:fs/promises', () => ({ } })) vi.mock('@testplane/wdio-utils', () => import(path.join(process.cwd(), '__mocks__', '@testplane/wdio-utils'))) +vi.mock('@testplane/wdio-utils/node', () => ({ + setupBrowser: vi.fn(), + setupDriver: vi.fn() +})) vi.mock('@testplane/wdio-config', () => import(path.join(process.cwd(), '__mocks__', '@testplane/wdio-config'))) vi.mock('@testplane/wdio-config/node', () => import(path.join(process.cwd(), '__mocks__', '@testplane/wdio-config/node'))) vi.mock('@testplane/wdio-logger', () => import(path.join(process.cwd(), '__mocks__', '@testplane/wdio-logger'))) @@ -760,6 +765,8 @@ describe('launcher', () => { beforeEach(() => { global.console.error = vi.fn() + vi.mocked(setupBrowser).mockReset() + vi.mocked(setupDriver).mockReset() config = { // ConfigParser.addFileConfig() will return onPrepare and onComplete as arrays of functions @@ -774,6 +781,7 @@ describe('launcher', () => { launcher.configParser = { getCapabilities: vi.fn().mockReturnValue({}), getConfig: vi.fn().mockReturnValue(config), + getSpecs: vi.fn().mockReturnValue(['/test.e2e.ts']), initialize: vi.fn() } as any launcher.runner = { initialize: vi.fn(), shutdown: vi.fn() } as any @@ -803,6 +811,45 @@ describe('launcher', () => { expect(launcher.runner!.shutdown).not.toBeCalled() }) + it.each([ + [{ browserName: 'chrome', browserVersion: 'stable' }], + [{ + browserA: { capabilities: { browserName: 'chrome', browserVersion: 'stable' } }, + browserB: { capabilities: { browserName: 'chrome', browserVersion: 'stable' } } + }] + ])('prepares the configured browsers and drivers before starting workers: %j', async (cap) => { + const capabilities = [cap] + config.cacheDir = '/custom/browser-cache' + vi.mocked(launcher.configParser.getCapabilities).mockReturnValue(capabilities) + const ready: string[] = [] + vi.mocked(setupBrowser).mockImplementationOnce(async (options, caps) => { + expect(options).toBe(config) + expect(caps).toBe(capabilities) + await Promise.resolve() + ready.push('browser') + }) + vi.mocked(setupDriver).mockImplementationOnce(async (options, caps) => { + expect(options).toBe(config) + expect(caps).toBe(capabilities) + await Promise.resolve() + ready.push('driver') + }) + vi.mocked(launcher['_runMode']).mockImplementationOnce(async () => { + expect(ready.sort()).toEqual(['browser', 'driver']) + return 0 + }) + + expect(await launcher.run()).toBe(0) + }) + + it('does not start workers when browser preparation fails', async () => { + vi.mocked(setupBrowser).mockRejectedValueOnce(new Error('browser download failed')) + + await expect(launcher.run()).rejects.toThrow('browser download failed') + expect(launcher['_runMode']).not.toHaveBeenCalled() + expect(launcher.runner!.shutdown).toHaveBeenCalled() + }) + it('onComplete error', async () => { // ConfigParser.addFileConfig() will return onComplete as an array of functions config.onComplete = [() => { throw new Error() }] diff --git a/packages/wdio-config/package.json b/packages/wdio-config/package.json index efd43dacc23..d8ea1d7e1de 100644 --- a/packages/wdio-config/package.json +++ b/packages/wdio-config/package.json @@ -20,7 +20,7 @@ }, "typeScriptVersion": "3.8.3", "engines": { - "node": ">=18.0.0" + "node": ">=22.12.0" }, "repository": { "type": "git", @@ -39,7 +39,7 @@ "@testplane/wdio-logger": "workspace:*", "@testplane/wdio-types": "workspace:*", "@testplane/wdio-utils": "workspace:*", - "deepmerge-ts": "^7.0.3", + "deepmerge-ts": "^8.0.2", "glob": "^10.2.2", "import-meta-resolve": "^4.0.0" }, diff --git a/packages/wdio-config/src/node/ConfigParser.ts b/packages/wdio-config/src/node/ConfigParser.ts index 0e73d441050..8cb99f4be61 100644 --- a/packages/wdio-config/src/node/ConfigParser.ts +++ b/packages/wdio-config/src/node/ConfigParser.ts @@ -1,7 +1,7 @@ import path from 'node:path' import logger from '@testplane/wdio-logger' -import { deepmerge, deepmergeCustom } from 'deepmerge-ts' +import { deepmergeCustom } from 'deepmerge-ts' import type { Capabilities, Options, Reporters, Services } from '@testplane/wdio-types' import FileSystemPathService from './FileSystemPathService.js' @@ -11,6 +11,10 @@ import { SUPPORTED_HOOKS, SUPPORTED_FILE_EXTENSIONS, DEFAULT_CONFIGS, NO_NAMED_C import type { PathService } from '../types.js' +// Keep the shallow Map value merging used before deepmerge-ts 8. +const mergeMaps = (maps: ReadonlyArray>) => new Map(maps.flatMap((map) => [...map])) +const deepmerge = deepmergeCustom({ mergeMaps }) + const log = logger('@testplane/wdio-config:ConfigParser') const MERGE_DUPLICATION = ['services', 'reporters', 'capabilities'] as const @@ -182,6 +186,7 @@ export default class ConfigParser { * Add deepmergeCustom to remove array('services', 'reporters', 'capabilities') duplication in the config object */ const customDeepMerge = deepmergeCustom({ + mergeMaps, mergeArrays: ([oldValue, newValue], utils, meta) => { const key = meta?.key as KeyWithMergeDuplication if (meta && MERGE_DUPLICATION.includes(key)) { diff --git a/packages/wdio-config/tests/node/configparser.merge.test.ts b/packages/wdio-config/tests/node/configparser.merge.test.ts new file mode 100644 index 00000000000..94c4fce3670 --- /dev/null +++ b/packages/wdio-config/tests/node/configparser.merge.test.ts @@ -0,0 +1,52 @@ +import path from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import ConfigParserBuilder from '../lib/ConfigParserBuilder.js' +import { FileNamed } from '../lib/FileNamed.js' + +vi.mock('@testplane/wdio-logger', () => import(path.join(process.cwd(), '__mocks__', '@testplane/wdio-logger'))) + +describe('configuration merge compatibility', () => { + async function mergeConfig(initial: object, file: object) { + const filePath = path.resolve('/config/wdio.conf.js') + const parser = new ConfigParserBuilder(path.dirname(filePath), filePath, initial, [ + FileNamed(filePath).withContents({ config: { capabilities: [], ...file } }) + ]).build() + await parser.initialize() + return parser.getConfig() as ReturnType & { custom: any } + } + + it('merges cyclic configuration objects without exhausting the stack', async () => { + const first: Record = { first: true } + const second: Record = { second: true } + first.self = first + second.self = second + + const config = await mergeConfig({ custom: first }, { custom: second }) + expect(config.custom.first).toBe(true) + expect(config.custom.second).toBe(true) + expect(config.custom.self).toBe(config.custom) + }) + + it('preserves Map keys and replaces duplicate values without deep merging them', async () => { + const initialValue = { initial: true } + const fileValue = { file: true } + const config = await mergeConfig( + { custom: new Map([['same', initialValue], ['keep', initialValue]]) }, + { custom: new Map([['same', fileValue], ['added', fileValue]]) } + ) + expect([...config.custom.keys()]).toEqual(['same', 'keep', 'added']) + expect(config.custom.get('same')).toBe(fileValue) + expect(config.custom.get('keep')).toBe(initialValue) + }) + + it('preserves array concatenation and nested object merging', async () => { + const config = await mergeConfig( + { custom: { args: ['first'], nested: { first: true } } }, + { custom: { args: ['second'], nested: { second: true } } } + ) + expect(config.custom).toEqual({ + args: ['first', 'second'], + nested: { first: true, second: true } + }) + }) +}) diff --git a/packages/wdio-lighthouse-service/src/commands.ts b/packages/wdio-lighthouse-service/src/commands.ts index def470ea9a6..186c9b74bd4 100644 --- a/packages/wdio-lighthouse-service/src/commands.ts +++ b/packages/wdio-lighthouse-service/src/commands.ts @@ -116,7 +116,7 @@ export default class CommandHandler { if (!traceBuffer) { throw new Error('No tracebuffer captured') } - this._traceEvents = JSON.parse(traceBuffer.toString()) + this._traceEvents = JSON.parse(Buffer.from(traceBuffer).toString('utf8')) this._isTracing = false } catch (err) { throw new Error(`Couldn't parse trace events: ${(err as Error).message}`) diff --git a/packages/wdio-lighthouse-service/src/index.ts b/packages/wdio-lighthouse-service/src/index.ts index 6a24267db0d..9b89818082b 100644 --- a/packages/wdio-lighthouse-service/src/index.ts +++ b/packages/wdio-lighthouse-service/src/index.ts @@ -121,14 +121,10 @@ export default class DevToolsService implements Services.ServiceInstance { } const url = await (browser as WebdriverIO.Browser).getUrl() - const target = url !== 'data:,' ? - await puppeteer.waitForTarget( - /* istanbul ignore next */ - (t) => t.url().includes(url)) : - await puppeteer.waitForTarget( - /* istanbul ignore next */ - // @ts-expect-error - (t) => t.type() === 'page' || Boolean(t._getTargetInfo().browserContextId)) + const target = await puppeteer.waitForTarget( + // Modern Chromium also exposes non-page targets with the same + // URL. Their page() is null, even when a browserContextId exists. + (t) => t.type() === 'page' && (url === 'data:,' || t.url().includes(url))) /* istanbul ignore next */ if (!target) { diff --git a/packages/wdio-lighthouse-service/src/utils.ts b/packages/wdio-lighthouse-service/src/utils.ts index 5402bc6c166..c4a441f930f 100644 --- a/packages/wdio-lighthouse-service/src/utils.ts +++ b/packages/wdio-lighthouse-service/src/utils.ts @@ -48,7 +48,7 @@ export function isSupportedUrl (url: string) { * Either request the page list directly from the browser or if Selenium * or Selenoid is used connect to a target manually */ -export async function getLighthouseDriver (session: CDPSession, target: Target): Promise { +export async function getLighthouseDriver (session: CDPSession, _target: Target): Promise { const connection = session.connection() if (!connection) { @@ -57,6 +57,7 @@ export async function getLighthouseDriver (session: CDPSession, target: Target): const cUrl = new URL(connection.url()) const cdpConnection = new ChromeProtocol(cUrl.port, cUrl.hostname) + const { targetInfo } = await session.send('Target.getTargetInfo') /** * only create a new DevTools session if our WebSocket url doesn't already indicate @@ -65,18 +66,22 @@ export async function getLighthouseDriver (session: CDPSession, target: Target): if (!cUrl.pathname.startsWith('/devtools/browser')) { await cdpConnection._connectToSocket({ webSocketDebuggerUrl: connection.url(), - id: (await target.asPage()).mainFrame()._id + id: targetInfo.targetId }) const { sessionId } = await cdpConnection.sendCommand( 'Target.attachToTarget', undefined, - { targetId: (await target.asPage()).mainFrame()._id, flatten: true } + { targetId: targetInfo.targetId, flatten: true } ) cdpConnection.setSessionId(sessionId) return new Driver(cdpConnection) } const list = await cdpConnection._runJsonCommand('list') - await cdpConnection._connectToSocket(list[0]) + const pageTarget = list.find((entry: { id: string }) => entry.id === targetInfo.targetId) + if (!pageTarget) { + throw new Error(`Could not find Lighthouse page target ${targetInfo.targetId}`) + } + await cdpConnection._connectToSocket(pageTarget) return new Driver(cdpConnection) } diff --git a/packages/wdio-lighthouse-service/tests/commands.test.ts b/packages/wdio-lighthouse-service/tests/commands.test.ts index e2dddcb3b98..ff2caa5e544 100644 --- a/packages/wdio-lighthouse-service/tests/commands.test.ts +++ b/packages/wdio-lighthouse-service/tests/commands.test.ts @@ -136,8 +136,8 @@ test('startTracing', () => { expect(pageMock.tracing.start).toBeCalledTimes(1) }) -test('endTracing', async () => { - pageMock.tracing.stop.mockResolvedValue(Buffer.from('{ "traceEvents": "foobar" }')) +test.each([Buffer.from, (value: string) => new TextEncoder().encode(value)])('endTracing decodes trace bytes', async (encode) => { + pageMock.tracing.stop.mockResolvedValue(encode('{ "traceEvents": "foobar" }')) const handler = new CommandHandler( sessionMock as any, pageMock as any, diff --git a/packages/wdio-lighthouse-service/tests/service.test.ts b/packages/wdio-lighthouse-service/tests/service.test.ts index 27e7e706770..b1f0d81c336 100644 --- a/packages/wdio-lighthouse-service/tests/service.test.ts +++ b/packages/wdio-lighthouse-service/tests/service.test.ts @@ -258,3 +258,26 @@ test('onReload hook', async () => { service.onReload() expect(service._setupHandler).toBeCalledTimes(1) }) + +test.each(['about:blank', 'data:,', 'https://example.org/'])('selects a page target rather than another target with the same URL (%s)', async (url) => { + const page = {} + const otherTarget = { + type: () => 'other', url: () => url, + _getTargetInfo: () => ({ browserContextId: 'context' }), + page: vi.fn().mockResolvedValue(null) + } + const pageTarget = { + type: () => 'page', url: () => url, + page: vi.fn().mockResolvedValue(page), + createCDPSession: vi.fn().mockResolvedValue(sessionMock) + } + vi.mocked(browser.getUrl).mockResolvedValue(url) + vi.mocked(browser.getPuppeteer).mockResolvedValue({ + waitForTarget: vi.fn(async predicate => [otherTarget, pageTarget].find(predicate)) + } as any) + const service = new DevToolsService({}) + await service.before({}, [], browser) + expect(otherTarget.page).not.toHaveBeenCalled() + expect(pageTarget.page).toHaveBeenCalledOnce() + expect(browser.addCommand).toHaveBeenCalledWith('checkPWA', expect.any(Function)) +}) diff --git a/packages/wdio-lighthouse-service/tests/utils.test.ts b/packages/wdio-lighthouse-service/tests/utils.test.ts index 7f981c67fc9..ef109f0ac64 100644 --- a/packages/wdio-lighthouse-service/tests/utils.test.ts +++ b/packages/wdio-lighthouse-service/tests/utils.test.ts @@ -12,7 +12,7 @@ vi.mock('fs', () => ({ vi.mock('lighthouse/lighthouse-core/gather/connections/cri.js', () => ({ default: class ChromeProtocol { - public _runJsonCommand = vi.fn().mockReturnValue(['foobar']) + public _runJsonCommand = vi.fn().mockReturnValue([{ id: 'unrelated-target' }, { id: 'foobar321' }]) public _connectToSocket = vi.fn() public _sendCommandMock = vi.fn() public on = vi.fn() @@ -46,21 +46,25 @@ describe('getLighthouseDriver', () => { test('should return a driver w/o creating new session', async () => { const urlMock = vi.fn().mockReturnValue('ws://127.0.0.1:56513/devtools/browser/9aae0e34-86a9-4b0e-856b-d0d37009ddbb') const session = { - connection: vi.fn().mockReturnValue({ url: urlMock }) + connection: vi.fn().mockReturnValue({ url: urlMock }), + send: vi.fn().mockResolvedValue({ targetInfo: { targetId: 'foobar321' } }) } - const target = { _targetId: 'foobar321' } + const target = { _targetId: 'foobar321', asPage: vi.fn().mockResolvedValue({ mainFrame: () => ({ _id: 'foobar321' }) }) } const driver = await getLighthouseDriver(session as any, target as any) expect(session.connection).toBeCalledTimes(1) expect(urlMock).toBeCalledTimes(1) + expect(session.send).toHaveBeenCalledWith('Target.getTargetInfo') expect(driver.constructor.name).toBe('Driver') + expect(driver._connection._connectToSocket).toHaveBeenCalledWith({ id: 'foobar321' }) }) test('should create a new session', async () => { const urlMock = vi.fn().mockReturnValue('ws://127.0.0.1:56513/session/9aae0e34-86a9-4b0e-856b-d0d37009ddbb/se/cdp') const session = { - connection: vi.fn().mockReturnValue({ url: urlMock }) + connection: vi.fn().mockReturnValue({ url: urlMock }), + send: vi.fn().mockResolvedValue({ targetInfo: { targetId: 'foobar321' } }) } - const target = { _targetId: 'foobar321' } + const target = { _targetId: 'foobar321', asPage: vi.fn().mockResolvedValue({ mainFrame: () => ({ _id: 'foobar321' }) }) } const driver = await getLighthouseDriver(session as any, target as any) // @ts-expect-error driver._connection.sendCommand('foobar') diff --git a/packages/wdio-runner/src/browser.ts b/packages/wdio-runner/src/browser.ts index 8e73b78a0f2..4a1b85c4994 100644 --- a/packages/wdio-runner/src/browser.ts +++ b/packages/wdio-runner/src/browser.ts @@ -153,8 +153,15 @@ export default class BrowserFramework implements Omit { /** * run checks for errors here to avoid breakage in communication with the browser */ + const resolveTestState = this.#resolveTestStatePromise! const errorInterval = setInterval( - this.#checkForTestError.bind(this), + () => this.#checkForTestError(resolveTestState).catch((err: Error) => { + resolveTestState({ + events: [], + failures: 1, + errors: [{ message: `Failed to check browser test state: ${err.message}` }] + }) + }), ERROR_CHECK_INTERVAL) const state: TestState = await testStatePromise @@ -452,7 +459,7 @@ export default class BrowserFramework implements Omit { }) } - async #checkForTestError () { + async #checkForTestError (resolveTestState: (value: TestState) => void) { const testError = await browser.execute(function fetchExecutionState () { let viteError const viteErrorElem = document.querySelector('vite-error-overlay') @@ -475,7 +482,10 @@ export default class BrowserFramework implements Omit { /** * ignore error, see https://github.com/GoogleChromeLabs/chromium-bidi/issues/1102 */ - if (err.message.includes('Cannot find context with specified id')) { + if ( + err.message.includes('Cannot find context with specified id') || + err.message.includes('execution contexts cleared') + ) { return } @@ -487,7 +497,7 @@ export default class BrowserFramework implements Omit { } if ((testError.errors && testError.errors.length > 0) || testError.hasViteError) { - this.#resolveTestStatePromise?.({ + resolveTestState({ events: [], failures: 1, ...testError @@ -508,7 +518,7 @@ export default class BrowserFramework implements Omit { return browser.refresh() } - this.#resolveTestStatePromise?.({ + resolveTestState({ events: [], failures: 1, hasViteError: false, diff --git a/packages/wdio-runner/tests/browser.test.ts b/packages/wdio-runner/tests/browser.test.ts new file mode 100644 index 00000000000..f992097c935 --- /dev/null +++ b/packages/wdio-runner/tests/browser.test.ts @@ -0,0 +1,83 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { browser } from '@wdio/globals' +import { MESSAGE_TYPES } from '@testplane/wdio-types' + +import BrowserFramework from '../src/browser.js' + +vi.mock('@wdio/globals', () => ({ + browser: { url: vi.fn(), setCookies: vi.fn(), execute: vi.fn() } +})) +vi.mock('../src/utils.js', () => ({ transformExpectArgs: vi.fn() })) +vi.mock('expect-webdriverio', () => ({ matchers: new Map() })) + +describe('BrowserFramework error polling', () => { + let listeners: ReturnType + const originalSend = process.send + + beforeEach(() => { + vi.useFakeTimers() + vi.mocked(browser.execute).mockReset() + listeners = process.listeners('message') + process.send = vi.fn() + }) + + afterEach(() => { + for (const listener of process.listeners('message')) { + if (!listeners.includes(listener)) { + process.removeListener('message', listener) + } + } + process.send = originalSend + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('continues polling after navigation clears an execution context', async () => { + vi.mocked(browser.execute) + .mockRejectedValueOnce(new Error('execution contexts cleared')) + .mockResolvedValue({ errors: [] }) + const framework = new BrowserFramework('0-0', {}, [new URL('./spec.ts', import.meta.url).href], { emit: vi.fn() } as any) + const result = framework.run() + await vi.advanceTimersByTimeAsync(1000) + const onMessage = process.listeners('message').find((listener) => !listeners.includes(listener))! + onMessage({ + command: 'workerRequest', + args: { message: { type: MESSAGE_TYPES.browserTestResult, value: { failures: 0, events: [] } } } + }, undefined) + expect(await result).toBe(0) + expect(browser.execute).toHaveBeenCalledTimes(2) + }) + + it('does not apply an unfinished poll failure to the next spec', async () => { + let rejectPoll: (error: Error) => void + vi.mocked(browser.execute).mockReturnValue(new Promise((_, reject) => { rejectPoll = reject })) + const specs = ['first.ts', 'second.ts'].map((name) => new URL(name, import.meta.url).href) + const framework = new BrowserFramework('0-0', {}, specs, { emit: vi.fn() } as any) + const result = framework.run() + await vi.advanceTimersByTimeAsync(500) + const onMessage = process.listeners('message').find((listener) => !listeners.includes(listener))! + const finishMessage = { + command: 'workerRequest', + args: { message: { type: MESSAGE_TYPES.browserTestResult, value: { failures: 0, events: [] } } } + } + onMessage(finishMessage, undefined) + await vi.advanceTimersByTimeAsync(0) + rejectPoll!(new Error('session disconnected')) + await vi.advanceTimersByTimeAsync(0) + onMessage(finishMessage, undefined) + expect(await result).toBe(0) + expect(process.send).not.toHaveBeenCalled() + }) + + it('reports a polling command failure without an unhandled rejection', async () => { + vi.mocked(browser.execute).mockRejectedValue(new Error('session disconnected')) + const framework = new BrowserFramework('0-0', { mochaOpts: { timeout: 1000 } }, [new URL('./spec.ts', import.meta.url).href], { emit: vi.fn() } as any) + const result = framework.run() + await vi.advanceTimersByTimeAsync(1000) + expect(await result).toBe(1) + expect(process.send).toHaveBeenCalledWith(expect.objectContaining({ + name: 'error', + content: expect.objectContaining({ message: expect.stringContaining('session disconnected') }) + })) + }) +}) diff --git a/packages/wdio-runner/tests/index.test.ts b/packages/wdio-runner/tests/index.test.ts index 053cfb647b3..1662dce823b 100644 --- a/packages/wdio-runner/tests/index.test.ts +++ b/packages/wdio-runner/tests/index.test.ts @@ -24,7 +24,7 @@ vi.mock('expect-webdriverio', () => ({ }) } })) -vi.mock('webdriverio', () => import(path.join(process.cwd(), '__mocks__', 'webdriverio'))) +vi.mock('@testplane/webdriverio', () => import(path.join(process.cwd(), '__mocks__', 'webdriverio'))) vi.mock('@testplane/wdio-utils', () => import(path.join(process.cwd(), '__mocks__', '@testplane/wdio-utils'))) vi.mock('@testplane/wdio-logger', () => import(path.join(process.cwd(), '__mocks__', '@testplane/wdio-logger'))) vi.mock('@wdio/globals', () => ({ diff --git a/packages/wdio-runner/tests/utils.test.ts b/packages/wdio-runner/tests/utils.test.ts index 068f5aee7b9..1a6d4fd3b87 100644 --- a/packages/wdio-runner/tests/utils.test.ts +++ b/packages/wdio-runner/tests/utils.test.ts @@ -11,7 +11,7 @@ import { } from '../src/utils.js' vi.mock('@testplane/wdio-logger', () => import(path.join(process.cwd(), '__mocks__', '@testplane/wdio-logger'))) -vi.mock('webdriverio', () => import(path.join(process.cwd(), '__mocks__', 'webdriverio'))) +vi.mock('@testplane/webdriverio', () => import(path.join(process.cwd(), '__mocks__', 'webdriverio'))) vi.mock('@testplane/wdio-utils', () => import(path.join(process.cwd(), '__mocks__', '@testplane/wdio-utils'))) process.send = vi.fn() diff --git a/packages/wdio-utils/package.json b/packages/wdio-utils/package.json index 407d654e803..bdc2f3e9563 100644 --- a/packages/wdio-utils/package.json +++ b/packages/wdio-utils/package.json @@ -15,14 +15,14 @@ "./node": { "source": "./src/node/index.ts", "import": "./build/node.js", - "types": "./src/node/index.d.ts", + "types": "./build/node/index.d.ts", "require": "./build/node.cjs" } }, "types": "./build/index.d.ts", "typeScriptVersion": "3.8.3", "engines": { - "node": ">=18.0.0" + "node": ">=22.12.0" }, "repository": { "type": "git", @@ -39,13 +39,13 @@ "url": "https://github.com/webdriverio/webdriverio/issues" }, "dependencies": { - "@puppeteer/browsers": "^2.2.0", + "@puppeteer/browsers": "^3.2.2", "@testplane/edgedriver": "^6.1.4", "@testplane/geckodriver": "^5.0.2", "@testplane/wdio-logger": "workspace:*", "@testplane/wdio-types": "workspace:*", "decamelize": "^6.0.0", - "deepmerge-ts": "^7.0.3", + "deepmerge-ts": "^8.0.2", "get-port": "^7.0.0", "import-meta-resolve": "^4.0.0", "locate-app": "^2.2.24", diff --git a/packages/wdio-utils/src/monad.ts b/packages/wdio-utils/src/monad.ts index cb7fe4bc607..cb5534d2970 100644 --- a/packages/wdio-utils/src/monad.ts +++ b/packages/wdio-utils/src/monad.ts @@ -262,8 +262,10 @@ export default function WebDriver (options: object, modifier?: Function, propert eventHandler.emit('_dialogListenerRemoved') } - eventHandler[method]?.(...args as [never, unknown]) - return this + const result = eventHandler[method]?.(...args as [never, unknown]) + // Listener introspection must return its value, not the chainable + // browser instance. DialogManager uses it to detect unhandled prompts. + return method === 'listenerCount' ? result : this } } diff --git a/packages/wdio-utils/src/node/manager.ts b/packages/wdio-utils/src/node/manager.ts index 3789ce65381..aac481b44a4 100644 --- a/packages/wdio-utils/src/node/manager.ts +++ b/packages/wdio-utils/src/node/manager.ts @@ -34,7 +34,7 @@ function mapCapabilities ( if (typeof multiremoteCaps[multiremoteInstanceNames[0]] === 'object' && 'capabilities' in multiremoteCaps[multiremoteInstanceNames[0]]) { return Object.values(multiremoteCaps).map((c: Capabilities.WithRequestedCapabilities) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any - if ((c as any).automationProtocol === 'devtools') { + if ((c as any).automationProtocol === 'devtools' || definesRemoteDriver({ ...options, ...c })) { return } @@ -51,7 +51,7 @@ function mapCapabilities ( }).flat() : Object.values(caps as Capabilities.WithRequestedMultiremoteCapabilities['capabilities']).map((mrOpts) => { const w3cCaps = mrOpts.capabilities as Capabilities.W3CCapabilities - if (mrOpts.automationProtocol === 'devtools') { + if (mrOpts.automationProtocol === 'devtools' || definesRemoteDriver({ ...options, ...mrOpts })) { return } if (w3cCaps.alwaysMatch) { @@ -68,7 +68,7 @@ function mapCapabilities ( // - browserName is defined so we know it is a browser session cap.browserName && // - we are not about to run a cloud session - !definesRemoteDriver(options) && + !definesRemoteDriver({ ...options, ...cap }) && // - we are not running Safari (driver already installed on macOS) !isSafari(cap.browserName) && // - environment does not define a binary path diff --git a/packages/wdio-utils/src/node/startWebDriver.ts b/packages/wdio-utils/src/node/startWebDriver.ts index 9bda4577a00..3ee29f5824a 100644 --- a/packages/wdio-utils/src/node/startWebDriver.ts +++ b/packages/wdio-utils/src/node/startWebDriver.ts @@ -6,7 +6,7 @@ import getPort from 'get-port' import waitPort from 'wait-port' import logger from '../logger.js' import split2 from 'split2' -import { deepmerge } from 'deepmerge-ts' +import { deepmergeCustom } from 'deepmerge-ts' import { start as startSafaridriver, type SafaridriverOptions as SafaridriverParameters } from 'safaridriver' import { start as startGeckodriver, type GeckodriverParameters } from '@testplane/geckodriver' @@ -33,6 +33,11 @@ declare global { } } +// Keep the shallow Map value merging used before deepmerge-ts 8. +const deepmerge = deepmergeCustom({ + mergeMaps: (maps) => new Map(maps.flatMap((map) => [...map])) +}) + const log = logger('@testplane/wdio-utils') const DRIVER_WAIT_TIMEOUT = 10 * 1000 // 10s diff --git a/packages/wdio-utils/src/shim.ts b/packages/wdio-utils/src/shim.ts index 3959388bbe5..aa6b83a51c7 100644 --- a/packages/wdio-utils/src/shim.ts +++ b/packages/wdio-utils/src/shim.ts @@ -190,8 +190,20 @@ export function wrapCommand(commandName: string, fn: Function): (...args: unk /** * `this` is an array of WebdriverIO elements */ - function (this: WebdriverIOInstance, index: number) { - return this[index] + async function (this: WebdriverIOInstance, index: number) { + if (this[index] || !this.parent || !this.selector || Number(index) < 0) { + return this[index] + } + let elements = this + await this.parent.waitUntil(async () => { + // Replay the complete query, including React filters + // and custom selector arguments, not just its selector. + elements = await cmd.call(this.parent, ...args) + return Boolean(elements[index]) + }, { + timeoutMsg: `Index out of bounds! ${this.foundWith}(${this.selector}) did not return element ${index}.` + }) + return elements[index] }, [prop], { prop, args } diff --git a/packages/wdio-utils/src/test-framework/testFnWrapper.ts b/packages/wdio-utils/src/test-framework/testFnWrapper.ts index b5e478fa09d..ab7780c9b65 100644 --- a/packages/wdio-utils/src/test-framework/testFnWrapper.ts +++ b/packages/wdio-utils/src/test-framework/testFnWrapper.ts @@ -20,6 +20,9 @@ const STACKTRACE_FILTER = [ 'node_modules/webdriver/', 'node_modules/webdriverio/', 'node_modules/@wdio/', + 'node_modules/@testplane/webdriver/', + 'node_modules/@testplane/webdriverio/', + 'node_modules/@testplane/wdio-utils/', '(internal/process/task', '(node:internal/process/task' ] diff --git a/packages/wdio-utils/tests/envDetector.test.ts b/packages/wdio-utils/tests/envDetector.test.ts index 97cd0f70fb6..3dc6e717afe 100644 --- a/packages/wdio-utils/tests/envDetector.test.ts +++ b/packages/wdio-utils/tests/envDetector.test.ts @@ -72,8 +72,8 @@ describe('sessionEnvironmentDetector', () => { it('isW3C', () => { const requestedCapabilities = { browserName: '' } expect(sessionEnvironmentDetector({ capabilities: {}, requestedCapabilities: {} }).isW3C).toBe(false) - expect(sessionEnvironmentDetector({ capabilities: appiumCaps, requestedCapabilities }).isW3C).toBe(false) - expect(sessionEnvironmentDetector({ capabilities: experitestAppiumCaps, requestedCapabilities }).isW3C).toBe(false) + expect(sessionEnvironmentDetector({ capabilities: appiumCaps, requestedCapabilities }).isW3C).toBe(true) + expect(sessionEnvironmentDetector({ capabilities: experitestAppiumCaps, requestedCapabilities }).isW3C).toBe(true) expect(sessionEnvironmentDetector({ capabilities: chromeCaps, requestedCapabilities }).isW3C).toBe(true) expect(sessionEnvironmentDetector({ capabilities: geckoCaps, requestedCapabilities }).isW3C).toBe(true) expect(sessionEnvironmentDetector({ capabilities: safariCaps, requestedCapabilities }).isW3C).toBe(true) diff --git a/packages/wdio-utils/tests/monad.test.ts b/packages/wdio-utils/tests/monad.test.ts index f16962185c1..46e29b38d7e 100644 --- a/packages/wdio-utils/tests/monad.test.ts +++ b/packages/wdio-utils/tests/monad.test.ts @@ -24,6 +24,20 @@ function createElement (elementOverrides: PropertyDescriptor, command: Function) } describe('monad', () => { + it('returns the actual listener count while listener registration remains chainable', () => { + const browser = webdriverMonad({})(sessionId) + const handler = vi.fn() + expect(browser.listenerCount('dialog')).toBe(0) + expect(browser.on('dialog', handler)).toBe(browser) + expect(browser.listenerCount('dialog')).toBe(1) + expect(browser.once('dialog', handler)).toBe(browser) + expect(browser.listenerCount('dialog')).toBe(2) + browser.emit('dialog') + expect(browser.listenerCount('dialog')).toBe(1) + expect(browser.off('dialog', handler)).toBe(browser) + expect(browser.listenerCount('dialog')).toBe(0) + }) + it('should be able to initialize client with prototype with commands', () => { const modifier = vi.fn() const monad = webdriverMonad({ baseUrl: 'option' }, (client: any) => { diff --git a/packages/wdio-utils/tests/node/index.test.ts b/packages/wdio-utils/tests/node/index.test.ts index a812a819fe4..6296e68ae09 100644 --- a/packages/wdio-utils/tests/node/index.test.ts +++ b/packages/wdio-utils/tests/node/index.test.ts @@ -55,11 +55,11 @@ vi.mock('safaridriver', () => ({ stderr: { pipe: vi.fn().mockReturnValue({ on: vi.fn() }) } }) })) -vi.mock('edgedriver', () => ({ +vi.mock('@testplane/edgedriver', () => ({ start: vi.fn().mockResolvedValue('edgedriver'), findEdgePath: vi.fn().mockReturnValue('/foo/bar/executable') })) -vi.mock('geckodriver', () => ({ start: vi.fn().mockResolvedValue('geckodriver') })) +vi.mock('@testplane/geckodriver', () => ({ start: vi.fn().mockResolvedValue('geckodriver') })) vi.mock('wait-port', () => ({ default: vi.fn().mockResolvedValue(undefined) })) vi.mock('get-port', () => ({ default: vi.fn().mockResolvedValue(1234) })) @@ -92,12 +92,34 @@ describe('startWebDriver', () => { vi.mocked(cp.spawn).mockClear() vi.mocked(startGeckodriver).mockClear() vi.mocked(fs.createWriteStream).mockClear() + vi.mocked(split2).mockClear() }) afterEach(() => { process.env.WDIO_SKIP_DRIVER_SETUP = WDIO_SKIP_DRIVER_SETUP }) + it('preserves Chrome arguments and nested preferences when setting up the binary', async () => { + const options = { + capabilities: { + browserName: 'chrome', + 'wdio:chromedriverOptions': { binary: '/my/chromedriver' }, + 'goog:chromeOptions': { + args: ['--headless', '--disable-gpu'], + prefs: { download: { prompt_for_download: false } } + } + } as any + } + + await startWebDriver(options) + + expect(options.capabilities['goog:chromeOptions']).toEqual({ + binary: '/path/to/browser', + args: ['--headless', '--disable-gpu'], + prefs: { download: { prompt_for_download: false } } + }) + }) + it('should start safari driver', async () => { const params = { capabilities: { diff --git a/packages/wdio-utils/tests/node/manager.test.ts b/packages/wdio-utils/tests/node/manager.test.ts index b28e2b6f928..8d1505d13ee 100644 --- a/packages/wdio-utils/tests/node/manager.test.ts +++ b/packages/wdio-utils/tests/node/manager.test.ts @@ -18,6 +18,61 @@ vi.mock('../../src/node/utils.js', async (orig) => { }) vi.mock('@testplane/wdio-logger', () => import(path.join(process.cwd(), '__mocks__', '@testplane/wdio-logger'))) +describe('remote session preparation', () => { + beforeEach(() => { + vi.mocked(setupChromedriver).mockClear() + vi.mocked(setupPuppeteerBrowser).mockClear() + }) + + test('skips remote connection overrides in regular capabilities', async () => { + const caps = [{ + hostname: 'selenium.example.test', + port: 4444, + browserName: 'chrome', + browserVersion: '114' + }] + + await setupDriver({}, caps) + await setupBrowser({}, caps) + + expect(setupChromedriver).not.toHaveBeenCalled() + expect(setupPuppeteerBrowser).not.toHaveBeenCalled() + }) + + test.each([false, true])('skips per-instance remote connections (parallel: %s)', async (parallel) => { + const caps = { + grid: { + hostname: 'selenium.example.test', + port: 4444, + capabilities: { browserName: 'chrome', browserVersion: '114' } + }, + cloud: { + user: 'user', + key: 'key', + capabilities: { browserName: 'chrome', browserVersion: '113' } + } + } + const capabilities = parallel ? [caps] : caps + + await setupDriver({}, capabilities) + await setupBrowser({}, capabilities) + + expect(setupChromedriver).not.toHaveBeenCalled() + expect(setupPuppeteerBrowser).not.toHaveBeenCalled() + + const mixed = { ...caps, local: { capabilities: { browserName: 'chrome', browserVersion: 'stable' } } } + await setupDriver({}, parallel ? [mixed] : mixed) + await setupBrowser({}, parallel ? [mixed] : mixed) + + expect(setupChromedriver).toHaveBeenCalledTimes(1) + expect(setupChromedriver).toHaveBeenCalledWith('/foo/bar', 'stable') + expect(setupPuppeteerBrowser).toHaveBeenCalledTimes(1) + expect(setupPuppeteerBrowser).toHaveBeenCalledWith('/foo/bar', { + browserName: 'chrome', browserVersion: 'stable' + }) + }) +}) + describe('setupDriver', () => { beforeEach(() => { vi.mocked(setupChromedriver).mockClear() diff --git a/packages/wdio-utils/tests/shim.test.ts b/packages/wdio-utils/tests/shim.test.ts index 5bff9fdf8b3..21271e3c38a 100644 --- a/packages/wdio-utils/tests/shim.test.ts +++ b/packages/wdio-utils/tests/shim.test.ts @@ -77,3 +77,31 @@ describe('executeAsync', () => { expect(result).toEqual('Execution Error') }) }) + +describe('missing array index preserves query arguments', () => { + it.each([ + ['react$$', ['Button', { props: { color: 'orange' } }]], + ['custom$$', ['byColor', 'orange', { exact: true }]] + ] as [string, unknown[]][])('%s retries the original query without dropping filters', async (name, args) => { + let attempts = 0 + const query = vi.fn(async function (this: any, ...queryArgs: unknown[]) { + const sameArgs = JSON.stringify(queryArgs) === JSON.stringify(args) + const elements = ++attempts === 1 ? [] : [{ elementId: sameArgs ? 'orange' : 'blue' }] + return Object.assign(elements, { parent: this, selector: queryArgs[0], foundWith: name }) + }) + const scope: any = { + options: {}, + waitUntil: async (condition: () => Promise) => { + for (let attempt = 0; attempt < 3; attempt++) { + if (await condition()) {return} + } + throw new Error('Expected matching element to appear') + } + } + scope[name] = wrapCommand(name, query) + const element = await scope[name](...args)[0] + expect(element.elementId).toBe('orange') + expect(query).toHaveBeenCalledTimes(2) + expect(query.mock.calls[1]).toEqual(args) + }) +}) diff --git a/packages/wdio-webdriver-mock-service/src/WebDriverMock.ts b/packages/wdio-webdriver-mock-service/src/WebDriverMock.ts index 894bf5428c6..60c700c6c24 100644 --- a/packages/wdio-webdriver-mock-service/src/WebDriverMock.ts +++ b/packages/wdio-webdriver-mock-service/src/WebDriverMock.ts @@ -24,7 +24,10 @@ export interface CommandMock { for (const protocol of protocols) { for (const [endpoint, methods] of Object.entries(protocol)) { for (const [method, commandData] of Object.entries(methods)) { - protocolFlattened.set(commandData.command, { method, endpoint, commandData }) + // W3C definitions take precedence over legacy aliases. + if (!protocolFlattened.has(commandData.command)) { + protocolFlattened.set(commandData.command, { method, endpoint, commandData }) + } } } } @@ -82,14 +85,7 @@ export default class WebDriverMock { return this.scope[reqMethod](WebDriverMock.pathMatcher(urlPath), (body: Record) => { for (const param of commandData.parameters) { /** - * check if parameter was set - */ - if (!body[param.name]) { - return false - } - - /** - * check if parameter has correct type + * Only required parameters must be present; zero and false are valid values. */ if (param.required && typeof body[param.name] === 'undefined') { return false diff --git a/packages/wdio-webdriver-mock-service/tests/WebDriverMock.test.ts b/packages/wdio-webdriver-mock-service/tests/WebDriverMock.test.ts new file mode 100644 index 00000000000..e5191418ee2 --- /dev/null +++ b/packages/wdio-webdriver-mock-service/tests/WebDriverMock.test.ts @@ -0,0 +1,41 @@ +import http from 'node:http' +import nock from 'nock' +import { afterEach, describe, expect, it } from 'vitest' +import WebDriverMock from '../src/WebDriverMock.js' + +function post (path: string, body: Record) { + return new Promise((resolve, reject) => { + const req = http.request(`http://webdriver-mock.test${path}`, { + method: 'POST', headers: { 'Content-Type': 'application/json' } + }, res => { + res.resume() + res.on('end', () => resolve(res.statusCode)) + }) + req.on('error', reject) + req.end(JSON.stringify(body)) + }) +} + +afterEach(() => nock.cleanAll()) + +describe('WebDriver protocol mock matching', () => { + it('accepts a new session without optional requiredCapabilities', async () => { + new WebDriverMock('webdriver-mock.test', 80).command.newSession().reply(200, { value: {} }) + await expect(post('/session', { capabilities: { alwaysMatch: { browserName: 'chrome' }, firstMatch: [{}] }, desiredCapabilities: { browserName: 'chrome' } })).resolves.toBe(200) + }) + + it('accepts required parameters whose value is zero', async () => { + new WebDriverMock('webdriver-mock.test', 80).command.positionClick().reply(200, { value: null }) + await expect(post('/session/11111111-1111-1111-1111-111111111111/click', { button: 0 })).resolves.toBe(200) + }) + + it('uses W3C endpoints when the same command also exists in JSONWire', async () => { + new WebDriverMock('webdriver-mock.test', 80).command.executeScript().reply(200, { value: true }) + await expect(post('/session/11111111-1111-1111-1111-111111111111/execute/sync', { script: 'return true', args: [] })).resolves.toBe(200) + }) + + it('still rejects missing required parameters', async () => { + new WebDriverMock('webdriver-mock.test', 80).command.newSession().reply(200, { value: {} }) + await expect(post('/session', {})).rejects.toThrow('Nock: No match') + }) +}) diff --git a/packages/webdriver/package.json b/packages/webdriver/package.json index 8292bf21980..a23ac05a470 100644 --- a/packages/webdriver/package.json +++ b/packages/webdriver/package.json @@ -22,7 +22,7 @@ "types": "./build/index.d.ts", "typeScriptVersion": "3.8.3", "engines": { - "node": ">=18.0.0" + "node": ">=22.12.0" }, "repository": { "type": "git", @@ -43,10 +43,9 @@ "@testplane/wdio-utils": "workspace:*", "@types/node": "^20.1.0", "@types/ws": "^8.5.3", - "deepmerge-ts": "^7.0.3", + "deepmerge-ts": "^8.0.2", "got": "12.6.1", - "ky": "0.33.0", - "undici": "6.12.0", - "ws": "^8.8.0" + "ky": "0.33.3", + "ws": "^8.21.3" } } diff --git a/packages/webdriver/src/node.ts b/packages/webdriver/src/node.ts index 609b9249eb2..15c279e7a70 100644 --- a/packages/webdriver/src/node.ts +++ b/packages/webdriver/src/node.ts @@ -15,10 +15,7 @@ import { environment } from './environment.js' environment.value = { Request: ( /** - * Currently Nock doesn't support the mocking of undici requests, therefore for all - * Smoke test we use the native fetch implementation. - * - * @see https://github.com/nock/nock/issues/2183#issuecomment-2252525890 + * Smoke tests can explicitly select the native fetch transport. */ process.env.WDIO_USE_NATIVE_FETCH || /** diff --git a/packages/webdriver/src/request/web.ts b/packages/webdriver/src/request/web.ts index b8e38cfb4eb..8127168965b 100644 --- a/packages/webdriver/src/request/web.ts +++ b/packages/webdriver/src/request/web.ts @@ -1,5 +1,5 @@ import type { Options as KyOptions } from 'ky' -import ky from 'ky' +import ky, { TimeoutError } from 'ky' import logger from '@testplane/wdio-logger' import WebDriverRequest from './index.js' import type { RequestOptions, RequestLibOptions, RequestLibResponse } from './types.js' @@ -45,7 +45,16 @@ export class WebRequest extends WebDriverRequest { } } - const res = await ky(url, kyOptions) + // The shared request layer uses got's timeout shape and owns retries. + // Passing that object to ky coerces the timeout to 1 ms in setTimeout. + kyOptions.timeout = options.timeout?.response + kyOptions.retry = 0 + const res = await ky(url.href, kyOptions).catch((error: unknown) => { + if (error instanceof TimeoutError) { + Object.assign(error, { code: 'ETIMEDOUT', event: 'response' }) + } + throw error + }) return { statusCode: res.status, body: await res.json(), diff --git a/packages/webdriver/src/utils.ts b/packages/webdriver/src/utils.ts index 2494ed2b32a..cdaf0522e7a 100644 --- a/packages/webdriver/src/utils.ts +++ b/packages/webdriver/src/utils.ts @@ -20,7 +20,10 @@ import type { WebDriverResponse } from './request/types.js' import type { Client, JSONWPCommandError, SessionFlags, RemoteConfig } from './types.js' const log = logger('webdriver') -const deepmerge = deepmergeCustom({ mergeArrays: false }) +const deepmerge = deepmergeCustom({ + mergeArrays: false, + mergeMaps: (maps) => new Map(maps.flatMap((map) => [...map])) +}) const BROWSER_DRIVER_ERRORS = [ 'unknown command: wd/hub/session', // chromedriver diff --git a/packages/webdriver/tests/command.test.ts b/packages/webdriver/tests/command.test.ts index 0d8ec1a8940..000a6c8017e 100644 --- a/packages/webdriver/tests/command.test.ts +++ b/packages/webdriver/tests/command.test.ts @@ -8,7 +8,7 @@ import type { Options } from '@testplane/wdio-types' import '../src/browser.js' // @ts-expect-error mock feature -import { WebDriverRequest as RequestMock, thenMock, catchMock } from '../src/request/request.js' +import { WebRequest as RequestMock, thenMock, catchMock } from '../src/request/web.js' import commandWrapper from '../src/command.js' import type { BaseClient } from '../src/types.js' @@ -49,14 +49,7 @@ const commandEndpoint: CommandEndpoint = { }] } -const requestHandler = { - onPerformance: expect.any(Function), - onRequest: expect.any(Function), - onResponse: expect.any(Function), - onRetry: expect.any(Function) -} - -vi.mock('../src/request/request', () => { +vi.mock('../src/request/web.js', () => { const thenMock = vi.fn() const finallyMock = vi.fn() const catchMock = vi.fn().mockReturnValue({ finally: finallyMock }) @@ -72,7 +65,7 @@ vi.mock('../src/request/request', () => { thenMock, catchMock, finallyMock, - WebDriverRequest, + WebRequest: WebDriverRequest, } }) @@ -155,9 +148,7 @@ describe('command wrapper', () => { using: 'css selector', value: '#body' }, - expect.any(AbortSignal), - false, - requestHandler + false ) }) @@ -172,9 +163,7 @@ describe('command wrapper', () => { value: '#body', customParam: 123 }, - expect.any(AbortSignal), - false, - requestHandler + false ) }) @@ -186,9 +175,7 @@ describe('command wrapper', () => { 'POST', '/session/:sessionId/element/%2Fpath/element', expect.anything(), - expect.any(AbortSignal), - false, - requestHandler + false ) }) @@ -200,9 +187,7 @@ describe('command wrapper', () => { 'POST', '/session/:sessionId/element/%252Fpath/element', expect.anything(), - expect.any(AbortSignal), - false, - requestHandler + false ) expect(log.warn).toHaveBeenCalledTimes(0) }) @@ -258,7 +243,7 @@ describe('command wrapper result log', () => { const commandFn = commandWrapper(method, path, endpoint) await commandFn.call(scope) expect(RequestMock).toHaveBeenCalledTimes(1) - expect(RequestMock).toHaveBeenCalledWith(method, path, expect.any(Object), expect.any(AbortSignal), false, requestHandler) + expect(RequestMock).toHaveBeenCalledWith(method, path, expect.any(Object), false) const callback = thenMock.mock.calls[0][0] diff --git a/packages/webdriver/tests/fetchEnvironment.test.ts b/packages/webdriver/tests/fetchEnvironment.test.ts new file mode 100644 index 00000000000..22a522857e6 --- /dev/null +++ b/packages/webdriver/tests/fetchEnvironment.test.ts @@ -0,0 +1,18 @@ +/** + * @vitest-environment jsdom + */ +import { describe, expect, it } from 'vitest' + +describe('fetch test environment', () => { + it('uses compatible Request and AbortSignal constructors and propagates cancellation', () => { + const controller = new AbortController() + const request = new Request('http://localhost/session', { signal: controller.signal }) + const cloned = request.clone() + + expect(request.signal).toBeInstanceOf(AbortSignal) + expect(cloned.signal.aborted).toBe(false) + controller.abort() + expect(request.signal.aborted).toBe(true) + expect(cloned.signal.aborted).toBe(true) + }) +}) diff --git a/packages/webdriver/tests/index.test.ts b/packages/webdriver/tests/index.test.ts index eff0962217d..a39adfa0007 100644 --- a/packages/webdriver/tests/index.test.ts +++ b/packages/webdriver/tests/index.test.ts @@ -100,6 +100,11 @@ describe('WebDriver', () => { unhandledPromptBehavior: 'ignore' }, firstMatch: [{}] + }, + desiredCapabilities: { + browserName: 'firefox', + webSocketUrl: true, + unhandledPromptBehavior: 'ignore' } }) }) ) @@ -124,6 +129,11 @@ describe('WebDriver', () => { unhandledPromptBehavior: 'ignore' }, firstMatch: [{}] + }, + desiredCapabilities: { + browserName: 'firefox', + webSocketUrl: true, + unhandledPromptBehavior: 'ignore' } }) }) ) diff --git a/packages/webdriver/tests/protocolMerge.test.ts b/packages/webdriver/tests/protocolMerge.test.ts new file mode 100644 index 00000000000..c03a7b36c0c --- /dev/null +++ b/packages/webdriver/tests/protocolMerge.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it, vi } from 'vitest' +import { getPrototype } from '../src/utils.js' +import command from '../src/command.js' + +vi.mock('../src/command.js', () => ({ default: vi.fn(() => () => {}) })) +vi.mock('@testplane/wdio-protocols', async (original) => ({ + ...await original(), + JsonWProtocol: { + '/session/:sessionId/example': { + POST: { command: 'example', parameters: [{ name: 'old' }], nested: { old: true } } + } + }, + WebDriverProtocol: { + '/session/:sessionId/example': { + POST: { command: 'example', parameters: [{ name: 'new' }], nested: { current: true } } + } + } +})) + +describe('protocol merge compatibility', () => { + it('replaces parameter arrays and retains nested metadata in mobile protocol overrides', () => { + const prototype = getPrototype({ isMobile: true, isW3C: true }) + + expect(prototype.example.value).toBeTypeOf('function') + expect(command).toHaveBeenCalledWith('POST', '/session/:sessionId/example', { + command: 'example', + parameters: [{ name: 'new' }], + nested: { old: true, current: true } + }, undefined) + }) +}) diff --git a/packages/webdriver/tests/request.test.ts b/packages/webdriver/tests/request.test.ts index 18beb206e30..8bd3cdf333b 100644 --- a/packages/webdriver/tests/request.test.ts +++ b/packages/webdriver/tests/request.test.ts @@ -1,458 +1,190 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { WebRequest } from '../src/request/web.js' +import type { RequestLibOptions } from '../src/request/types.js' -import path from 'node:path' - -import logger from '@testplane/wdio-logger' -import type { Options } from '@testplane/wdio-types' - -import '../src/browser.js' -import { FetchRequest } from '../src/request/web.js' +const options = { + protocol: 'http', hostname: 'localhost', port: 4444, + connectionRetryCount: 0, connectionRetryTimeout: 1000 +} +const originalFetch = globalThis.fetch -vi.mock('@testplane/wdio-logger', () => import(path.join(process.cwd(), '__mocks__', '@testplane/wdio-logger'))) -vi.mock('fetch') -const { warn, error } = logger('test') +afterEach(() => { + vi.useRealTimers() + vi.stubGlobal('fetch', originalFetch) +}) -const webdriverPath = '/session' -const defaultOptions = { - protocol: 'http', - hostname: 'localhost', - port: 4444 +async function capture (method: string, body?: Record, extra = {}, endpoint = '/session/:sessionId/element') { + let wire: Request | undefined + vi.stubGlobal('fetch', vi.fn(async (request: Request) => { + wire = request + return Response.json({ value: 'ok' }) + })) + const request = new WebRequest(method, endpoint, body) + await request.makeRequest({ ...options, ...extra }, 'abc') + return wire! } -const baseUrl = `${defaultOptions.protocol}://${defaultOptions.hostname}:${defaultOptions.port}` -describe('webdriver request', () => { - beforeEach(() => { - vi.mocked(fetch).mockClear() +describe('webdriver request public API', () => { + it('preserves method and endpoint and sends a request for the supplied session', async () => { + const request = new WebRequest('POST', '/session/:sessionId/element', { using: 'css selector', value: '#foo' }) + const agent = { request: vi.fn().mockResolvedValue({ statusCode: 200, body: { value: 42 } }) } + expect(request.method).toBe('POST') + expect(request.endpoint).toBe('/session/:sessionId/element') + await expect(request.makeRequest({ ...options, customWdRequestAgent: agent }, 'abc')).resolves.toEqual({ value: 42 }) + expect(agent.request).toHaveBeenCalledWith(new URL('http://localhost:4444/session/abc/element'), expect.objectContaining({ + method: 'POST', json: { using: 'css selector', value: '#foo' } + })) }) - it('should have some default options', () => { - const req = new FetchRequest('POST', '/foo/bar', { foo: 'bar' }) - expect(req.method).toBe('POST') - expect(req.endpoint).toBe('/foo/bar') + it('rejects a missing session ID before sending the request', async () => { + const send = vi.fn() + vi.stubGlobal('fetch', send) + await expect(new WebRequest('POST', '/session/:sessionId/element', {}).makeRequest(options)).rejects.toThrow('A sessionId is required') + expect(send).not.toHaveBeenCalled() }) - it('should be able to make request', async () => { - const req = new FetchRequest('POST', '/foo/bar', { foo: 'bar' }) - const url = new URL('/foo/bar', baseUrl) - req.createOptions = vi.fn().mockImplementation((opts, sessionId) => ({ - url, - requestOptions:{ - foo: 'bar', - sessionId - } - })) - req['_request'] = vi.fn() - - await req.makeRequest({ connectionRetryCount: 43, logLevel: 'warn' }, 'some_id') - expect(req['_request']).toHaveBeenCalledWith( - url, - expect.objectContaining({ foo: 'bar', sessionId: 'some_id' }), - undefined, - 43, - 0 - ) + it('creates the URL and default/custom headers', async () => { + const request = await capture('POST', {}, { protocol: 'https', port: 4445, path: '/wd/hub', headers: { foo: 'bar' } }) + expect(request.url).toBe('https://localhost:4445/wd/hub/session/abc/element') + expect([...request.headers.keys()]).toEqual(['accept', 'connection', 'content-length', 'content-type', 'foo', 'user-agent']) + expect(request.headers.get('foo')).toBe('bar') + expect(request.signal.aborted).toBe(false) }) - it('should pick up the fullRequestOptions returned by transformRequest', async () => { - const req = new FetchRequest('POST', '/foo/bar', { foo: 'bar' }) - const transformRequest = vi.fn().mockImplementation((requestOptions) => ({ - ...requestOptions, - body: { foo: 'baz' } - })) - - await req.makeRequest({ - transformRequest, - protocol: 'https', - hostname: 'localhost', - port: 4445, - path: '/wd/hub/', - logLevel: 'warn' - }, 'some_id') - expect(vi.mocked(fetch)).toHaveBeenCalledWith( - expect.any(Object), - expect.objectContaining({ body: JSON.stringify( { foo: 'baz' }) }) - ) + it('adds Basic authorization for user and key', async () => { + const request = await capture('POST', { some: 'body' }, { user: 'foo', key: 'bar' }, '/session') + expect(request.headers.get('authorization')).toBe('Basic ' + btoa('foo:bar')) + expect(await request.json()).toEqual({ some: 'body' }) }) - it('should resolve with the body returned by transformResponse', async () => { - const req = new FetchRequest('POST', 'session/:sessionId/element', { foo: 'requestBody' }) - - const transformResponse = vi.fn().mockImplementation((response) => ({ - ...response, - body: { value: { foo: 'transformedResponse' } }, - })) - - vi.mocked(fetch).mockClear() - const responseBody = await req.makeRequest({ - transformResponse, - protocol: 'https', - hostname: 'localhost', - port: 4445, - path: '/wd/hub/', - logLevel: 'warn' - }, 'foobar-123') - - expect(transformResponse.mock.calls[0][0]).toHaveProperty('body') - expect(transformResponse.mock.calls[0][1].body).toEqual({ foo: 'requestBody' }) - await expect(responseBody).toEqual({ value: { foo: 'transformedResponse' } }) - vi.mocked(fetch).mockClear() + it.each(['GET', 'DELETE'])('does not send an empty object for %s', async method => { + const request = await capture(method, {}) + expect(request.body).toBeNull() + expect(request.headers.has('content-length')).toBe(false) }) - describe('createOptions', () => { - it('fails if command requires sessionId but none given', async () => { - const req = new FetchRequest('POST', `${webdriverPath}/:sessionId/element`, {}) - await expect(() => req.createOptions({ logLevel: 'warn' })).rejects.toThrow('A sessionId is required') - }) - - it('creates proper options set', async () => { - const req = new FetchRequest('POST', `${webdriverPath}/:sessionId/element`, {}) - const { url, requestOptions } = await req.createOptions({ - protocol: 'https', - hostname: 'localhost', - port: 4445, - path: '/', - headers: { foo: 'bar' }, - connectionRetryTimeout: 10 * 1000, - logLevel: 'warn' - }, 'foobar12345') - - expect((url! as URL).href) - .toBe('https://localhost:4445/session/foobar12345/element') - expect([...(requestOptions.headers as unknown as Map).keys()]) - .toEqual(['accept', 'connection', 'content-length', 'content-type', 'foo', 'user-agent']) - expect(requestOptions.signal?.aborted).toBeFalsy() - }) - - it('ignors path when command is a hub command', async () => { - const req = new FetchRequest('POST', '/grid/api/hub', {}, undefined, true) - const options = await req.createOptions({ - protocol: 'https', - hostname: 'localhost', - port: 4445, - path: '/', - logLevel: 'warn' - }, 'foobar12345') - expect((options.url as URL).href).toBe('https://localhost:4445/grid/api/hub') - }) - - it('should add authorization header if user and key is given', async () => { - const req = new FetchRequest('POST', webdriverPath, { some: 'body' }) - const user = 'foo' - const key = 'bar' - const { requestOptions } = await req.createOptions({ - ...defaultOptions, - user, - key, - path: '/', - logLevel: 'warn' - }) - expect((requestOptions.headers as unknown as Map).get('Authorization')).toEqual('Basic ' + btoa(user + ':' + key)) - expect(requestOptions.body).toEqual({ some: 'body' }) - }) - - it('sets request body to "undefined" when request object is empty and DELETE is used', async () => { - const req = new FetchRequest('DELETE', webdriverPath, {}) - const { requestOptions } = await req.createOptions({ - ...defaultOptions, - path: '/', - logLevel: 'warn' - }) - expect(Boolean(requestOptions.body)).toEqual(false) - }) - - it('sets request body to "undefined" when request object is empty and GET is used', async () => { - const req = new FetchRequest('GET', `${webdriverPath}/title`, {}) - const { requestOptions } = await req.createOptions({ - ...defaultOptions, - path: '/', - logLevel: 'warn' - }) - expect(Boolean(requestOptions.body)).toEqual(false) - }) - - it('should attach an empty object body when POST is used', async () => { - const req = new FetchRequest('POST', '/status', {}) - const { requestOptions } = await req.createOptions({ - ...defaultOptions, - path: '/', - logLevel: 'warn' - }) - expect(requestOptions.body).toEqual({}) - }) - - it('should add the Content-Length header when a request object has a body', async () => { - const req = new FetchRequest('POST', webdriverPath, { foo: 'bar' }) - const { requestOptions } = await req.createOptions({ - ...defaultOptions, - path: '/', - logLevel: 'warn' - }) - expect([...(requestOptions.headers as unknown as Map).keys()]) - .toEqual(['accept', 'connection', 'content-length', 'content-type', 'user-agent']) - expect((requestOptions.headers as unknown as Map).get('Content-Length')).toBe('13') - }) - - it('should add Content-Length as well any other header provided in the request options if there is body in the request object', async () => { - const req = new FetchRequest('POST', webdriverPath, { foo: 'bar' }) - const { requestOptions } = await req.createOptions({ - ...defaultOptions, path: '/', - headers: { foo: 'bar' }, - logLevel: 'warn' - }) - expect((requestOptions.headers as unknown as Map).get('foo')).toContain('bar') - expect((requestOptions.headers as unknown as Map).get('Content-Length')).toBe('13') - }) - - it('should add only the headers provided if the request body is empty', async () => { - const req = new FetchRequest('POST', webdriverPath) - const { requestOptions } = await req.createOptions({ - ...defaultOptions, - path: '/', - headers: { foo: 'bar' }, - logLevel: 'warn' - }) - expect([...(requestOptions.headers as unknown as Map).keys()]).not.toContain('content-length') - expect((requestOptions.headers as unknown as Map).get('foo')).toContain('bar') - }) + it('sends an empty JSON object for POST', async () => { + const request = await capture('POST', {}) + expect(await request.json()).toEqual({}) + expect(request.headers.get('content-length')).toBe('2') }) - describe('_request', () => { - it('should make a request', async () => { - const expectedResponse = { value: { 'element-6066-11e4-a52e-4f735466cecf': 'some-elem-123' } } - const onResponse = vi.fn() - const onPerformance = vi.fn() - const req = new FetchRequest('POST', webdriverPath, {}, undefined, false, { - onResponse, onPerformance - }) - - const url = new URL('/session/foobar-123/element', baseUrl) - const opts = {} - const res = await req['_request'](url, opts) - - expect(res).toEqual(expectedResponse) - expect(onResponse).toHaveBeenNthCalledWith(1, { result: expectedResponse }) - expect(onPerformance).toHaveBeenNthCalledWith(1, expect.objectContaining({ - request: opts, - durationMillisecond: expect.any(Number), - retryCount: 0, - success: true, - })) - }) - - it('should short circuit if request throws a stale element exception', async () => { - const onResponse = vi.fn() - const onPerformance = vi.fn() - const req = new FetchRequest('POST', 'session/:sessionId/element', {}, undefined, false, { - onResponse, onPerformance - }) - - const url = new URL('/session/foobar-123/element/some-sub-sub-elem-231/click', baseUrl) - const opts = Object.assign({ - body: JSON.stringify({ foo: 'bar' }) - }) - - const error = await req['_request'](url, opts).catch(err => err) - expect(error.message).toContain('element is not attached to the page document') - expect(onResponse).toHaveBeenNthCalledWith(1, expect.anything()) - expect(onPerformance).toHaveBeenNthCalledWith(1, expect.objectContaining({ success: false })) - expect(vi.mocked(warn).mock.calls).toHaveLength(1) - expect(vi.mocked(warn).mock.calls).toEqual([['Request encountered a stale element - terminating request']]) - }) - - it('should not fail code due to an empty server response', async () => { - const onResponse = vi.fn() - const onPerformance = vi.fn() - const req = new FetchRequest('POST', webdriverPath, {}, undefined, false, { - onResponse, onPerformance - }) - - const url = new URL('/empty', baseUrl) - const opts = {} - await expect(req['_request'](url, opts)).rejects.toEqual(expect.objectContaining({ - message: expect.stringContaining('Response has empty body') - })) - expect(onResponse).toHaveBeenNthCalledWith(1, expect.anything()) - expect(onPerformance).toHaveBeenNthCalledWith(1, expect.objectContaining({ success: false })) - expect(vi.mocked(warn).mock.calls).toHaveLength(0) - expect(vi.mocked(error).mock.calls).toHaveLength(1) - }) - - it('should retry requests but still fail', async () => { - const onRetry = vi.fn() - const onResponse = vi.fn() - const onPerformance = vi.fn() - const req = new FetchRequest('POST', webdriverPath, {}, undefined, false, { - onResponse, onPerformance, onRetry - }) - - const url = new URL('/failing', baseUrl) - const opts = {} - await expect(req['_request'](url, opts, undefined, 2)).rejects.toEqual(expect.objectContaining({ - message: expect.stringContaining('unknown error') - })) - expect(onRetry).toHaveBeenNthCalledWith(1, expect.anything()) - expect(onPerformance).toHaveBeenNthCalledWith(1, expect.objectContaining({ success: false })) - expect(onRetry).toHaveBeenNthCalledWith(2, expect.anything()) - expect(onPerformance).toHaveBeenNthCalledWith(2, expect.objectContaining({ success: false })) - expect(onResponse).toHaveBeenNthCalledWith(1, expect.anything()) - expect(onPerformance).toHaveBeenNthCalledWith(3, expect.objectContaining({ success: false })) - expect(vi.mocked(warn).mock.calls).toHaveLength(2) - expect(vi.mocked(error).mock.calls).toHaveLength(1) - }) - - it('should retry and eventually respond', async () => { - const onRetry = vi.fn() - const onResponse = vi.fn() - const onPerformance = vi.fn() - const req = new FetchRequest('POST', webdriverPath, {}, undefined, false, { - onResponse, onPerformance, onRetry - }) + it('calculates Content-Length in bytes and preserves custom headers', async () => { + const body = { foo: 'тест' } + const request = await capture('POST', body, { headers: { foo: 'bar' } }) + expect(request.headers.get('content-length')).toBe(String(Buffer.byteLength(JSON.stringify(body)))) + expect(request.headers.get('foo')).toBe('bar') + }) - const url = new URL('/failing', baseUrl) - const opts = Object.assign({ body: { foo: 'bar' } }) - expect(await req['_request'](url, opts, undefined, 3)).toEqual({ value: 'caught' }) - expect(onRetry).toHaveBeenNthCalledWith(1, expect.anything()) - expect(onPerformance).toHaveBeenNthCalledWith(1, expect.objectContaining({ success: false })) - expect(onRetry).toHaveBeenNthCalledWith(2, expect.anything()) - expect(onPerformance).toHaveBeenNthCalledWith(2, expect.objectContaining({ success: false })) - expect(onRetry).toHaveBeenNthCalledWith(3, expect.anything()) - expect(onPerformance).toHaveBeenNthCalledWith(3, expect.objectContaining({ success: false })) - expect(onResponse).toHaveBeenNthCalledWith(1, expect.anything()) - expect(onPerformance).toHaveBeenNthCalledWith(4, expect.objectContaining({ success: true })) - expect(vi.mocked(warn).mock.calls).toHaveLength(3) - expect(vi.mocked(error).mock.calls).toHaveLength(0) - }) + it('does not add Content-Length when no body was supplied', async () => { + const request = await capture('POST', undefined, { headers: { foo: 'bar' } }) + expect(request.headers.has('content-length')).toBe(false) + expect(request.headers.get('foo')).toBe('bar') + }) - it('should manage hub commands', async () => { - const req = new FetchRequest('POST', '/grid/api/hub', {}, undefined, true) - expect(await req.makeRequest({ - protocol: 'https', - hostname: 'localhost', - port: 4445, - path: '/', - logLevel: 'warn' - }, 'foobar')).toEqual({ value: { some: 'config' } }) - }) + it('uses the options returned by transformRequest', async () => { + const transformRequest = vi.fn((request: RequestLibOptions) => ({ ...request, json: { foo: 'baz' } })) + expect(await (await capture('POST', { foo: 'bar' }, { transformRequest })).json()).toEqual({ foo: 'baz' }) + expect(transformRequest).toHaveBeenCalledTimes(1) + }) - it('should fail if hub command is called on node', async () => { - const req = new FetchRequest('POST', '/grid/api/testsession', {}, undefined, true) - const result = await req.makeRequest({ - protocol: 'https', - hostname: 'localhost', - port: 4445, - path: '/', - logLevel: 'warn' - }, 'foobar').then( - (res) => res, - (e) => e - ) - expect(result.message).toBe('Command can only be called to a Selenium Hub') - }) + it('passes response and request options to transformResponse', async () => { + const transformResponse = vi.fn((response, request) => ({ ...response, body: { value: request.json } })) + const request = new WebRequest('POST', '/status', { foo: 'requestBody' }) + const agent = { request: vi.fn().mockResolvedValue({ statusCode: 200, body: { value: 'original' } }) } + await expect(request.makeRequest({ ...options, transformResponse, customWdRequestAgent: agent })).resolves.toEqual({ value: { foo: 'requestBody' } }) + expect(transformResponse).toHaveBeenCalledWith({ statusCode: 200, body: { value: 'original' } }, expect.objectContaining({ json: { foo: 'requestBody' } })) + }) - describe('"ETIMEDOUT" error', () => { - it('should throw if timeout happens too often', async () => { - const retryCnt = 3 - const onRetry = vi.fn() - const req = new FetchRequest('POST', '/timeout', {}, undefined, true, { onRetry }) - const result = await req.makeRequest({ - protocol: 'https', - hostname: 'localhost', - port: 4445, - path: '/', - connectionRetryCount: retryCnt, - logLevel: 'warn' - }, 'foobar').then( - (res) => res, - (e) => e - ) - expect(result.code).toBe('ETIMEDOUT') - expect(onRetry).toBeCalledTimes(retryCnt) - }) + it.each([ + [200, { value: 'ok' }, true], + [404, { value: { error: 'stale element reference', message: 'element is not attached to the page document' } }, false], + [500, '', false] + ])('emits request, response and performance events (status %s, success %s)', async (statusCode, body, success) => { + const request = new WebRequest('POST', '/status', {}) + const onRequest = vi.fn(), onResponse = vi.fn(), onPerformance = vi.fn() + request.on('request', onRequest).on('response', onResponse).on('performance', onPerformance) + const agent = { request: vi.fn().mockResolvedValue({ statusCode, body }) } + const promise = request.makeRequest({ ...options, customWdRequestAgent: agent }) + if (success) { + await expect(promise).resolves.toEqual(body) + expect(onResponse).toHaveBeenCalledWith({ result: body }) + } else { + await expect(promise).rejects.toThrow(body ? 'element is not attached' : 'Response has empty body') + expect(onResponse).toHaveBeenCalledWith({ error: expect.any(Error) }) + } + expect(onRequest).toHaveBeenCalledWith(expect.objectContaining({ method: 'POST' })) + expect(onPerformance).toHaveBeenCalledWith(expect.objectContaining({ success, retryCount: 0, durationMillisecond: expect.any(Number) })) + expect(agent.request).toHaveBeenCalledTimes(1) + }) - it('should use error from "getRequestError" helper', async () => { - const onRetry = vi.fn() - const onRequest = vi.fn() - const onResponse = vi.fn() - const onPerformance = vi.fn() - const req = new FetchRequest('GET', '/timeout', {}, undefined, true, { onRetry, onRequest, onResponse, onPerformance }) - const reqOpts = { - protocol: 'https', - hostname: 'localhost', - port: 4445, - path: '/', - } as Options.WebDriver - await req.makeRequest(reqOpts, 'foobar') - // ignore error - .catch((e) => e) + it.each([false, true])('retries failures and reports each attempt (eventual success: %s)', async succeeds => { + const request = new WebRequest('POST', '/status', {}) + const onRetry = vi.fn(), onPerformance = vi.fn(), onResponse = vi.fn() + request.on('retry', onRetry).on('performance', onPerformance).on('response', onResponse) + const send = vi.fn().mockResolvedValue({ statusCode: 500, body: { value: { error: 'unknown error', message: 'failure' } } }) + if (succeeds) { + send.mockResolvedValueOnce({ statusCode: 500, body: {} }).mockResolvedValueOnce({ statusCode: 200, body: { value: 'caught' } }) + } + const promise = request.makeRequest({ ...options, connectionRetryCount: 2, customWdRequestAgent: { request: send } }) + await (succeeds ? expect(promise).resolves.toEqual({ value: 'caught' }) : expect(promise).rejects.toThrow('failure')) + expect(send).toHaveBeenCalledTimes(succeeds ? 2 : 3) + expect(onRetry).toHaveBeenCalledTimes(succeeds ? 1 : 2) + expect(onResponse).toHaveBeenCalledTimes(1) + expect(onPerformance).toHaveBeenCalledTimes(succeeds ? 2 : 3) + expect(onPerformance).toHaveBeenLastCalledWith(expect.objectContaining({ success: succeeds })) + }) - expect(vi.mocked(onRetry).mock.calls).toHaveLength(0) - expect(onRequest).toHaveBeenNthCalledWith(1, expect.anything()) - expect(onResponse).toHaveBeenNthCalledWith(1, { error: expect.objectContaining({ code: 'ETIMEDOUT' }) }) - expect(onPerformance).toHaveBeenNthCalledWith(1, expect.objectContaining({ success: false })) - }) - }) + it.each([ + [{ some: 'config' }, { value: { some: 'config' } }], + ['', { value: null }] + ])('handles non-WebDriver hub responses', async (body, expected) => { + const request = new WebRequest('POST', '/grid/api/hub', {}, true) + const send = vi.fn().mockResolvedValue({ statusCode: 200, body }) + await expect(request.makeRequest({ ...options, path: '/ignored', customWdRequestAgent: { request: send } })).resolves.toEqual(expected) + expect(send.mock.calls[0][0].href).toBe('http://localhost:4444/grid/api/hub') + }) - it('should return proper response if retry passes', async () => { - const retryCnt = 7 - const onRetry = vi.fn() - const req = new FetchRequest('POST', '/timeout', {}, undefined, true, { onRetry }) - const result = await req.makeRequest({ - protocol: 'https', - hostname: 'localhost', - port: 4445, - path: '/timeout', - connectionRetryCount: retryCnt, - logLevel: 'warn' - }, 'foobar').then( - (res) => res, - (e) => e - ) - expect(result).toEqual({ value: {} }) - expect(onRetry).toBeCalledTimes(5) - }, 20_000) + it('rejects a hub command sent directly to a node', async () => { + const request = new WebRequest('POST', '/grid/api/hub', {}, true) + const send = vi.fn().mockResolvedValue({ statusCode: 200, body: 'not a hub' }) + await expect(request.makeRequest({ ...options, customWdRequestAgent: { request: send } })).rejects.toThrow('Command can only be called to a Selenium Hub') + }) - it('should retry on connection refused error', async () => { - const retryCnt = 7 - const onRetry = vi.fn() - const req = new FetchRequest('POST', '/connectionRefused', {}, undefined, false, { onRetry }) - const result = await req.makeRequest({ - protocol: 'https', - hostname: 'localhost', - port: 4445, - connectionRetryCount: retryCnt, - logLevel: 'warn' - }, 'foobar').then( - (res) => res, - (e) => e - ) - expect(result).toEqual({ value: { foo: 'bar' } }) - expect(onRetry).toBeCalledTimes(5) - }, 20_000) + it.each(['connect', 'response'])('handles %s timeouts without duplicating response-timeout commands', async event => { + const error = Object.assign(new Error('timeout'), { code: 'ETIMEDOUT', event }) + const send = vi.fn().mockRejectedValue(error) + const request = new WebRequest('POST', '/status', {}) + const onRetry = vi.fn(), onResponse = vi.fn() + request.on('retry', onRetry).on('response', onResponse) + await expect(request.makeRequest({ ...options, connectionRetryCount: 2, customWdRequestAgent: { request: send } })).rejects.toMatchObject({ code: 'ETIMEDOUT' }) + expect(send).toHaveBeenCalledTimes(event === 'response' ? 1 : 3) + expect(onRetry).toHaveBeenCalledTimes(event === 'response' ? 0 : 2) + expect(onResponse).toHaveBeenCalledWith({ error: expect.objectContaining({ code: 'ETIMEDOUT' }) }) + }) - it('should throw if request error is unknown', async () => { - console.log('TESTING', AbortSignal) - const req = new FetchRequest('POST', '/sumoerror', {}, undefined, true) - const result = await req.makeRequest({ - protocol: 'https', - hostname: 'localhost', - port: 4445, - path: '/sumoerror', - connectionRetryCount: 0, - logLevel: 'warn' - }, 'foobar').then( - (res) => res, - (e) => e - ) - expect(result.message).toEqual(expect.stringContaining('ups')) - }) + it.each(['timeout', 'connection refused'])('can succeed after a transient %s error', async reason => { + const send = vi.fn().mockResolvedValue({ statusCode: 200, body: { value: 'recovered' } }) + if (reason === 'timeout') {send.mockRejectedValueOnce(Object.assign(new Error('timeout'), { code: 'ETIMEDOUT' }))} else {send.mockResolvedValueOnce({ statusCode: 500, body: { value: { message: 'java.net.ConnectException: Connection refused: connect' } } })} + await expect(new WebRequest('POST', '/status', {}).makeRequest({ ...options, connectionRetryCount: 2, customWdRequestAgent: { request: send } })).resolves.toEqual({ value: 'recovered' }) + expect(send).toHaveBeenCalledTimes(2) }) - afterEach(() => { - // @ts-ignore - vi.mocked(fetch).retryCnt = 0 + it('does not swallow or retry an unknown transport error', async () => { + const error = new Error('ups') + const send = vi.fn().mockRejectedValue(error) + await expect(new WebRequest('POST', '/status', {}).makeRequest({ ...options, connectionRetryCount: 2, customWdRequestAgent: { request: send } })).rejects.toBe(error) + expect(send).toHaveBeenCalledTimes(1) + }) - vi.mocked(fetch).mockClear() - vi.mocked(warn).mockClear() - vi.mocked(error).mockClear() + it('retries a rate-limited request only after the 429 backoff', async () => { + vi.useFakeTimers() + const send = vi.fn().mockResolvedValueOnce({ statusCode: 429, body: {} }).mockResolvedValue({ statusCode: 200, body: { value: 'ok' } }) + const promise = new WebRequest('POST', '/status', {}).makeRequest({ ...options, connectionRetryCount: 1, customWdRequestAgent: { request: send } }) + await vi.advanceTimersByTimeAsync(4999) + expect(send).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1001) + await expect(promise).resolves.toEqual({ value: 'ok' }) + expect(send).toHaveBeenCalledTimes(2) }) }) diff --git a/packages/webdriver/tests/request/error.test.ts b/packages/webdriver/tests/request/error.test.ts index 4842e88b8fd..bb7e8577a1a 100644 --- a/packages/webdriver/tests/request/error.test.ts +++ b/packages/webdriver/tests/request/error.test.ts @@ -1,210 +1,52 @@ -/* eslint-disable quotes */ -import { URL } from 'node:url' -import type { MockedFunction } from 'vitest' -import { transformCommandLogResult } from '@testplane/wdio-utils' -import { describe, it, expect, vi } from 'vitest' - -import '../../src/browser.js' -import { WebDriverResponseError, WebDriverRequestError } from '../../src/request/error.js' - -const cmdURL = new URL('http://localhost:1234/session/abc123/url') -const init = { method: 'POST' } - -vi.mock('@testplane/wdio-utils') - -describe('WebDriverResponseError', () => { - it('captures errors without body', () => { - const error = new WebDriverResponseError({} as any, cmdURL, init) - expect(error.message).toBe('WebDriverError: Response has empty body when running "url" with method "POST"') - expect(error.stack).toMatch('Response has empty body') - }) - - it('captures errors with unknown body', () => { - const error = new WebDriverResponseError({ body: {} } as any, cmdURL, init) - expect(error.message).toBe('WebDriverError: unknown error when running "url" with method "POST"') - expect(error.stack).toMatch('unknown error') - }) - - it('captures Firefox errors', () => { - //Firefox - const error = new WebDriverResponseError({ - body: { - value: { - error: 'foo', - message: 'bar' - } - } - } as any, cmdURL, init) - expect(error.name).toBe('foo') - expect(error.message).toBe('WebDriverError: bar when running "url" with method "POST"') - }) - - it('captures Chrome errors', () => { - const error = new WebDriverResponseError({ - body: { value: { message: 'stale element reference' } } - } as any, cmdURL, init) - expect(error.name).toBe('stale element reference') - expect(error.message).toBe('WebDriverError: stale element reference when running "url" with method "POST"') - expect(error.stack).toMatch('stale element reference') - expect(error.stack).toMatch('stale element reference') - }) - - it('captures errors with message', () => { - const error = new WebDriverResponseError({ - body: { value: { message: 'message error' } } - } as any, cmdURL, init) - expect(error.name).toBe('WebDriver Error') - expect(error.message).toBe('WebDriverError: message error when running "url" with method "POST"') - expect(error.stack).toMatch('WebDriver Error') - expect(error.stack).toMatch('message') - }) - - it('captures errors with class', () => { - const error = new WebDriverResponseError({ - body: { value: { class: 'class error' } } - } as any, cmdURL, init) - expect(error.name).toBe('WebDriver Error') - expect(error.message).toBe('WebDriverError: class error when running "url" with method "POST"') - expect(error.stack).toMatch('WebDriver Error') - expect(error.stack).toMatch('class') - }) - - it('captures errors with name', () => { - const error = new WebDriverResponseError({ - body: { value: { name: 'Protocol Error' } } - } as any, cmdURL, init) - expect(error.name).toBe('Protocol Error') - expect(error.message).toBe('WebDriverError: unknown error when running "url" with method "POST"') - expect(error.stack).toMatch('Protocol Error') - expect(error.stack).toMatch('unknown error') - }) - - it('captures errors without value', () => { - const error = new WebDriverResponseError({ - body: { value: { } } - } as any, cmdURL, init) - expect(error.name).toBe('WebDriver Error') - expect(error.message).toBe('WebDriverError: unknown error when running "url" with method "POST"') - expect(error.stack).toMatch('WebDriver Error') - expect(error.stack).toMatch('unknown error') - }) - - it('captures invalid selector errors', () => { - const error = new WebDriverResponseError( - { body: { value: { message: 'invalid locator' } } } as any, - cmdURL, - { body: { using: 'css selector', value: '!!' }, method: 'POST' } as any - ) - expect(error.message).toMatchInlineSnapshot(`"WebDriverError: The selector "!!" used with strategy "css selector" is invalid! when running "url" with method "POST" and args "{"using":"css selector","value":"!!"}""`) - }) - - it('captures unknown command errors', () => { - const error = new WebDriverResponseError( - { - body: { - message: 'Command not found: POST /some/command', - error: 'unknown method', - name: 'Protocol Error' - } - } as any, - cmdURL, - init - ) - expect(error.message).toMatchInlineSnapshot(`"WebDriverError: Command not found: POST /some/command when running "url" with method "POST""`) - expect(error.name).toBe('unknown method') - }) - - it('command name as full endpoint', async () => { - const timeoutErr = new WebDriverResponseError( - { body: { message: 'Timeout' } } as any, - new URL('https://localhost:4445/wd/hub/session'), - { method: 'GET' } - ) - - expect(timeoutErr.message).toEqual(expect.stringMatching('when running "https://localhost:4445/wd/hub/session"')) - }) - - it('command args as stringified object', async () => { - const timeoutErr = new WebDriverResponseError( - { body: { message: 'Timeout' } } as any, - new URL('https://localhost:4445/default/method'), - { method: 'GET', body: { foo: 'bar' } as any } - ) - - expect(timeoutErr.message).toEqual( - expect.stringMatching(new RegExp(`when running .+ with method .+ and args "${JSON.stringify({ foo: 'bar' })}"`)) - ) - }) - - it('command args with base64 script', async () => { - (transformCommandLogResult as MockedFunction).mockReturnValueOnce('""') - - const cmdArgs = { script: Buffer.from('script').toString('base64') } - const reqOpts = { body: cmdArgs, method: 'POST' } - - const timeoutErr = new WebDriverResponseError( - { body: { message: 'Timeout' } } as any, - new URL('https://localhost:4445/default/method'), - reqOpts as any - ) - - expect(timeoutErr.message).toEqual( - expect.stringMatching(/when running .+ with method .+ and args ""/) - ) - }) - - it('command args with function script without extra wrapper', async () => { - const cmdArgs = { script: 'return (function() {\nconsole.log("hi")\n}).apply(null, arguments)' } - const reqOpts = { body: cmdArgs, method: 'POST' } - - const timeoutErr = new WebDriverResponseError( - { body: { message: 'Timeout' } } as any, - new URL('https://localhost:4445/default/method'), - reqOpts as any - ) - expect(timeoutErr.message).toEqual( - expect.stringMatching(/when running .+ with method .+ and args "function\(\) {\nconsole\.log\("hi"\)\n}/) - ) - }) - - it('command args with base64 screenshot', async () => { - (transformCommandLogResult as MockedFunction).mockReturnValueOnce('""') - - const cmdArgs = { file: Buffer.from('screen').toString('base64') } - const reqOpts = { body: cmdArgs, method: 'POST' } - - const timeoutErr = new WebDriverResponseError( - { body: { message: 'Timeout' } } as any, - new URL('https://localhost:4445/default/method'), - reqOpts as any - ) - - expect(timeoutErr.message).toEqual( - expect.stringMatching(/when running .+ with method .+ and args ""/) - ) +import { describe, expect, it } from 'vitest' +import { getErrorFromResponseBody, getTimeoutError } from '../../src/utils.js' + +// The got-based request layer replaced WebDriverResponseError and +// WebDriverRequestError. Exercise its public error helpers, not deleted classes. +describe('WebDriver response errors', () => { + it.each([ + [undefined, 'Response has empty body', 'Error'], + [{}, 'unknown error', 'WebDriver Error'], + [{ value: { error: 'foo', message: 'bar' } }, 'bar', 'foo'], + [{ value: { message: 'stale element reference' } }, 'stale element reference', 'stale element reference'], + [{ value: { message: 'message error' } }, 'message error', 'WebDriver Error'], + [{ value: { class: 'class error' } }, 'class error', 'WebDriver Error'], + [{ value: { name: 'Protocol Error' } }, 'unknown error', 'Protocol Error'], + [{ value: {} }, 'unknown error', 'WebDriver Error'], + [{ message: 'Command not found: POST /some/command', error: 'unknown method' }, 'Command not found: POST /some/command', 'unknown method'], + ['plain text error', 'plain text error', 'Error'], + [42, 'Unknown error', 'Error'] + ])('preserves the server message and error name for %j', (body, message, name) => { + const error = getErrorFromResponseBody(body, {}) + expect(error).toBeInstanceOf(Error) + expect(error.message).toBe(message) + expect(error.name).toBe(name) + expect(error.stack).toContain(message) + }) + + it('explains an invalid selector with its value and strategy', () => { + const error = getErrorFromResponseBody({ value: { message: 'invalid locator' } }, { using: 'css selector', value: '!!' }) + expect(error.message).toBe('The selector "!!" used with strategy "css selector" is invalid!') + }) + + it.each([ + { script: Buffer.from('script').toString('base64') }, + { script: 'return (function() {\nconsole.log("hi")\n}).apply(null, arguments)' }, + { file: Buffer.from('screen').toString('base64') }, + { foo: 'bar' } + ])('does not append potentially large command arguments to the server error', args => { + const error = getErrorFromResponseBody({ value: { message: 'Timeout' } }, args) + expect(error.message).toBe('Timeout') + expect(error.message).not.toContain(JSON.stringify(args)) }) }) -describe('WebDriverRequestError', () => { - const url = new URL('http://localhost:1234') - const init: any = { method: 'POST' } - - it('captures basic error', () => { - const error = new WebDriverRequestError(new Error('ups'), url, init) - expect(error.message).toBe('WebDriverError: ups when running "http://localhost:1234/" with method "POST"') - }) - - it('captures fetch errors', () => { - const err = new Error('fetch failed') - err.cause = { code: 'ECONNREFUSED' } - const error = new WebDriverRequestError(err, url, init) - expect(error.message).toBe('WebDriverError: Request failed with error code ECONNREFUSED when running "http://localhost:1234/" with method "POST"') - }) - - it('captures timeout errors', () => { - const err = new Error('fetch failed') - err.cause = { code: 'UND_ERR_CONNECT_TIMEOUT' } - const error = new WebDriverRequestError(err, url, init) - expect(error.message).toBe('WebDriverError: Request timed out! Consider increasing the "connectionRetryTimeout" option. when running "http://localhost:1234/" with method "POST"') +describe('WebDriver timeout errors', () => { + it('preserves the transport error code', () => { + const error = getTimeoutError(Object.assign(new Error('timeout'), { code: 'ETIMEDOUT' }), { + method: 'POST', url: new URL('http://localhost:4444/session'), timeout: { response: 1000 } + }) + expect(error).toMatchObject({ code: 'ETIMEDOUT' }) + expect(error.message).toContain('http://localhost:4444/session') }) }) diff --git a/packages/webdriver/tests/webRequest.test.ts b/packages/webdriver/tests/webRequest.test.ts new file mode 100644 index 00000000000..fc5cc9843fe --- /dev/null +++ b/packages/webdriver/tests/webRequest.test.ts @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { WebRequest } from '../src/request/web.js' + +describe('browser HTTP transport', () => { + const fetch = globalThis.fetch + afterEach(() => vi.stubGlobal('fetch', fetch)) + + it('sends JSON using a native Request with read-only duplex', async () => { + let received: Request | undefined + const send = vi.fn(async (request: Request) => { + received = request + return Response.json({ value: { sessionId: 'native-request' } }) + }) + vi.stubGlobal('fetch', send) + + const result = await new WebRequest('POST', '/session', { capabilities: {} }).makeRequest({ + protocol: 'http', hostname: 'localhost', port: 4444, + connectionRetryCount: 0, connectionRetryTimeout: 1000 + }) + + expect(result).toEqual({ value: { sessionId: 'native-request' } }) + expect(received).toBeInstanceOf(Request) + expect(await received!.json()).toEqual({ capabilities: {} }) + }) + it('uses the configured response timeout instead of coercing its options object to 1 ms', async () => { + vi.stubGlobal('fetch', vi.fn(async () => { + await new Promise(resolve => setTimeout(resolve, 20)) + return Response.json({ value: 'delayed response' }) + })) + + await expect(new WebRequest('GET', '/status').makeRequest({ + protocol: 'http', hostname: 'localhost', port: 4444, + connectionRetryCount: 0, connectionRetryTimeout: 1000 + })).resolves.toEqual({ value: 'delayed response' }) + }) + +}) diff --git a/packages/webdriverio/package.json b/packages/webdriverio/package.json index 82421ae0e40..7aedd456bd2 100644 --- a/packages/webdriverio/package.json +++ b/packages/webdriverio/package.json @@ -14,7 +14,7 @@ "url": "https://github.com/webdriverio/webdriverio/issues" }, "engines": { - "node": ">=18.0.0" + "node": ">=22.12.0" }, "tags": [ "webdriver", @@ -95,8 +95,8 @@ "jszip": "^3.10.1", "lodash.clonedeep": "^4.5.0", "lodash.zip": "^4.2.0", - "minimatch": "9.0.0", - "puppeteer-core": "^20.9.0", + "minimatch": "^9.0.9", + "puppeteer-core": "^25.10.0", "query-selector-shadow-dom": "^1.0.1", "resq": "^1.11.0", "rgb2hex": "0.2.5", diff --git a/packages/webdriverio/src/commands/browser/deleteCookies.ts b/packages/webdriverio/src/commands/browser/deleteCookies.ts index 46d8b81b76c..49cc7e7bd61 100644 --- a/packages/webdriverio/src/commands/browser/deleteCookies.ts +++ b/packages/webdriverio/src/commands/browser/deleteCookies.ts @@ -1,5 +1,7 @@ import type { remote } from '@testplane/webdriver' +import { getContextManager } from '../../session/context.js' + /** * Delete cookies visible to the current page. By providing a cookie name it * just removes the single cookie or more when multiple names are passed. @@ -36,8 +38,11 @@ export async function deleteCookies( return } + const contextManager = getContextManager(this) + const context = await contextManager.getCurrentContext() + if (!filterArray) { - await this.storageDeleteCookies({}) + await this.storageDeleteCookies({ partition: { type: 'context', context } }) return } @@ -53,7 +58,7 @@ export async function deleteCookies( }) await Promise.all(bidiFilter.map((filter) => ( - this.storageDeleteCookies({ filter }) + this.storageDeleteCookies({ filter, partition: { type: 'context', context } }) ))) return diff --git a/packages/webdriverio/src/commands/browser/getCookies.ts b/packages/webdriverio/src/commands/browser/getCookies.ts index e7089b82ace..0fd45d969c7 100644 --- a/packages/webdriverio/src/commands/browser/getCookies.ts +++ b/packages/webdriverio/src/commands/browser/getCookies.ts @@ -2,6 +2,8 @@ import logger from '@testplane/wdio-logger' import type { Cookie } from '@testplane/wdio-protocols' import type { remote } from '@testplane/webdriver' +import { getContextManager } from '../../session/context.js' + const log = logger('webdriverio') /** @@ -54,7 +56,12 @@ export async function getCookies( } const cookieFilter = getCookieFilter(filter) - const { cookies } = await this.storageGetCookies({ filter: cookieFilter }) + const contextManager = getContextManager(this) + const context = await contextManager.getCurrentContext() + const { cookies } = await this.storageGetCookies({ + filter: cookieFilter, + partition: { type: 'context', context } + }) return cookies.map((cookie) => ({ ...cookie, value: cookie.value.type === 'base64' ? atob(cookie.value.value) : cookie.value.value diff --git a/packages/webdriverio/src/commands/browser/getPuppeteer.ts b/packages/webdriverio/src/commands/browser/getPuppeteer.ts index 770b12c4eb1..83287b42774 100644 --- a/packages/webdriverio/src/commands/browser/getPuppeteer.ts +++ b/packages/webdriverio/src/commands/browser/getPuppeteer.ts @@ -3,6 +3,7 @@ import puppeteer, { type Browser as PuppeteerBrowser } from 'puppeteer-core' import type { Capabilities } from '@testplane/wdio-types' import { FF_REMOTE_DEBUG_ARG } from '../../constants.js' +import { addPuppeteerCompatibility, type CompatiblePuppeteerBrowser } from '../../utils/puppeteerCompatibility.js' const log = logger('webdriverio') const DEBUG_PIPE_FLAG = 'remote-debugging-pipe' @@ -49,7 +50,7 @@ const DEBUG_PIPE_FLAG = 'remote-debugging-pipe' * * @return {PuppeteerBrowser} initiated puppeteer instance connected to the browser */ -export async function getPuppeteer (this: WebdriverIO.Browser): Promise { +export async function getPuppeteer (this: WebdriverIO.Browser): Promise { /** * Tell user that Puppeteer is not supported in browser runner */ @@ -61,9 +62,9 @@ export async function getPuppeteer (this: WebdriverIO.Browser): Promise> = {} export const SESSION_BIDI_MOCKS: Record> = {} diff --git a/packages/webdriverio/src/commands/browser/reloadSession.ts b/packages/webdriverio/src/commands/browser/reloadSession.ts index 6bca458fd90..b7373ef6c6f 100644 --- a/packages/webdriverio/src/commands/browser/reloadSession.ts +++ b/packages/webdriverio/src/commands/browser/reloadSession.ts @@ -77,12 +77,16 @@ export async function reloadSession (this: WebdriverIO.Browser, newCapabilities? log.warn(`Suppressing error closing the session: ${(err as Error).stack}`) } - if (this.puppeteer?.isConnected()) { - this.puppeteer.disconnect() + if (this.puppeteer?.connected) { + await this.puppeteer.disconnect() log.debug('Disconnected puppeteer session') } - const ProtocolDriver = (await import(/* @vite-ignore */this.options.automationProtocol!)).default + const automationProtocol = this.options.automationProtocol || 'webdriver' + const packageName = automationProtocol === 'webdriver' || automationProtocol === 'devtools' + ? `@testplane/${automationProtocol}` + : automationProtocol + const ProtocolDriver = (await import(/* @vite-ignore */packageName)).default await ProtocolDriver.reloadSession(this, newCapabilities) await registerSessionManager(this) diff --git a/packages/webdriverio/src/commands/browser/setCookies.ts b/packages/webdriverio/src/commands/browser/setCookies.ts index cce0dca90b1..88440444c85 100644 --- a/packages/webdriverio/src/commands/browser/setCookies.ts +++ b/packages/webdriverio/src/commands/browser/setCookies.ts @@ -1,5 +1,7 @@ import type { Cookie } from '@testplane/wdio-protocols' +import { getContextManager } from '../../session/context.js' + /** * * Sets one or more [cookies](https://w3c.github.io/webdriver/#cookies) for the current page. Make sure you are @@ -80,8 +82,11 @@ export async function setCookies( url = new URL(await this.getUrl()) } + const contextManager = getContextManager(this) + const context = await contextManager.getCurrentContext() await Promise.all(cookieObjsList.map((cookie) => ( this.storageSetCookie({ + partition: { type: 'context', context }, cookie: { ...cookie, domain: cookie.domain || url.hostname, diff --git a/packages/webdriverio/src/commands/browser/switchWindow.ts b/packages/webdriverio/src/commands/browser/switchWindow.ts index 19fda72ff42..b5ea0d7a856 100644 --- a/packages/webdriverio/src/commands/browser/switchWindow.ts +++ b/packages/webdriverio/src/commands/browser/switchWindow.ts @@ -107,5 +107,10 @@ export async function switchWindow ( } } + if (currentWindow && tabs.includes(currentWindow)) { + await this.switchToWindow(currentWindow) + contextManager.setCurrentContext(currentWindow) + } + throw new Error(`No window found with title, url, name or window handle matching "${matcher}"`) } diff --git a/packages/webdriverio/src/commands/element/click.ts b/packages/webdriverio/src/commands/element/click.ts index 1a4bbfc8f0a..bb296eccdd1 100644 --- a/packages/webdriverio/src/commands/element/click.ts +++ b/packages/webdriverio/src/commands/element/click.ts @@ -83,6 +83,7 @@ const log = logger('webdriver') * @param {string= | number=} options.button can be one of [0, "left", 1, "middle", 2, "right"] (optional) * @param {number=} options.x Number (optional) * @param {number=} options.y Number (optional) + * @param {number=} options.duration Milliseconds to hold the pointer down (default: 0) * @param {boolean=} options.skipRelease Boolean (optional) */ export async function click( @@ -101,7 +102,8 @@ export async function click( const { x: xOffset = 0, y: yOffset = 0, - skipRelease = false + skipRelease = false, + duration = 0 } = options || {} if ( @@ -137,8 +139,8 @@ export async function click( } } const clickNested = async () => { - await browser.action('pointer', { - parameters: { pointerType: 'mouse' } + const action = browser.action('pointer', { + parameters: { pointerType: browser.isMobile && duration > 0 ? 'touch' : 'mouse' } }) .move({ origin: this, @@ -146,8 +148,10 @@ export async function click( y: yOffset }) .down({ button }) - .up({ button }) - .perform(skipRelease) + if (duration > 0) { + action.pause(duration) + } + await action.up({ button }).perform(skipRelease) } try { await clickNested() diff --git a/packages/webdriverio/src/commands/element/getHTML.ts b/packages/webdriverio/src/commands/element/getHTML.ts index b96c888efbb..3c2b850fd4e 100644 --- a/packages/webdriverio/src/commands/element/getHTML.ts +++ b/packages/webdriverio/src/commands/element/getHTML.ts @@ -27,11 +27,7 @@ import getHTMLScript from '../../scripts/getHTML.js' * * * @alias element.getHTML - * @param {GetHTMLOptions} options command options - * @param {Boolean=} options.includeSelectorTag if true it includes the selector element tag (default: `true`) - * @param {Boolean=} options.pierceShadowRoot if true it includes content of the shadow roots of all web components in the DOM (default: `true`) - * @param {Boolean=} options.removeCommentNodes if true it removes all comment nodes from the HTML, e.g. `` (default: `true`) - * @param {Boolean=} options.prettify if true, the html output will be prettified (default: `true`) + * @param {Boolean=} includeSelectorTag if true returns outerHTML, otherwise innerHTML (default: `true`) * @return {String} the HTML of the specified element * @uses action/selectorExecute * @type property diff --git a/packages/webdriverio/src/commands/mock/respond.ts b/packages/webdriverio/src/commands/mock/respond.ts index 6a97a52d52b..eadc4553ff2 100644 --- a/packages/webdriverio/src/commands/mock/respond.ts +++ b/packages/webdriverio/src/commands/mock/respond.ts @@ -1,6 +1,20 @@ /** * Always respond with same overwrite. * + * With WebDriver BiDi, Chromium fetches the real response by default, then + * replaces its body. Set `fetchResponse: false` to provide the response before + * sending the request instead. + * + * Firefox can only provide a response body before the request is sent, so its + * BiDi default is `fetchResponse: false`. The request does not reach the server + * and the mock cannot inherit the server's status or response headers; specify + * `statusCode` and `headers` explicitly when needed. Firefox rejects an explicit + * `fetchResponse: true`. Request-stage response mocking also rejects response + * filters (`statusCode` and `responseHeaders`) and a `statusCode` function, + * because the real response is unavailable. Combining request overwrites with + * request-stage responses is not supported either. Request method/header + * filters and aborts remain supported. + * * :respond.js it('should demonstrate response overwrite with static data', async () => { diff --git a/packages/webdriverio/src/scripts/customElement.ts b/packages/webdriverio/src/scripts/customElement.ts index 0f5fa863a6f..dfa9331fdfc 100644 --- a/packages/webdriverio/src/scripts/customElement.ts +++ b/packages/webdriverio/src/scripts/customElement.ts @@ -8,21 +8,58 @@ interface CustomElementConstructor { } export default function customElementWrapper () { + const shadowHosts = new WeakSet() + const registeredHosts = new WeakSet() + const pendingHosts = new Set>() + + function registerShadowRoot (host: HTMLElement, allowUnknown = false) { + if (!host.isConnected || (!allowUnknown && !shadowHosts.has(host)) || registeredHosts.has(host)) { + return + } + let parentNode: ParentNode = host + while (parentNode.parentNode) { + parentNode = parentNode.parentNode + } + // Register the containing shadow root before its descendants, even if they were + // constructed in the opposite order while detached. + if (parentNode instanceof ShadowRoot) { + registerShadowRoot(parentNode.host as HTMLElement, true) + } + console.debug('[WDIO]', 'newShadowRoot', host, parentNode, parentNode === document, document.documentElement) + registeredHosts.add(host) + } + + // Regular elements have no connectedCallback. Observe document and shadow-tree + // insertions so attachShadow() before append() is reported with its real scope. + const observer = new MutationObserver(() => { + for (const reference of pendingHosts) { + const host = reference.deref() + if (host) { + registerShadowRoot(host) + } + if (!host || host.isConnected) { + pendingHosts.delete(reference) + } + } + }) + observer.observe(document, { childList: true, subtree: true }) + const origFn = customElements.define.bind(customElements) customElements.define = function(name: string, Constructor: CustomElementConstructor, options?: ElementDefinitionOptions) { const origConnectedCallback = Constructor.prototype.connectedCallback Constructor.prototype.connectedCallback = function(this: HTMLElement) { - let parentNode: ParentNode | null = this - while (parentNode.parentNode) { - parentNode = parentNode.parentNode - } - console.debug('[WDIO]', 'newShadowRoot', this, parentNode, parentNode === document, document.documentElement) - return origConnectedCallback?.call(this) + const result = origConnectedCallback?.call(this) + // Native/declarative roots can predate the attachShadow wrapper. In + // particular, a closed root cannot be detected through host.shadowRoot. + registerShadowRoot(this, true) + return result } const origDisconnectedCallback = Constructor.prototype.disconnectedCallback Constructor.prototype.disconnectedCallback = function(this: HTMLElement) { - console.debug('[WDIO]', 'removeShadowRoot', this) + if (registeredHosts.delete(this)) { + console.debug('[WDIO]', 'removeShadowRoot', this) + } return origDisconnectedCallback?.call(this) } return origFn(name, Constructor, options) @@ -31,11 +68,14 @@ export default function customElementWrapper () { const origAttachShadow = Element.prototype.attachShadow Element.prototype.attachShadow = function (this: HTMLElement, init: ShadowRootInit) { const shadowRoot = origAttachShadow.call(this, init) - let parentNode: ParentNode | null = this - while (parentNode.parentNode) { - parentNode = parentNode.parentNode + shadowHosts.add(this) + // A connected, previously rootless custom host may already have been reported. + registeredHosts.delete(this) + observer.observe(shadowRoot, { childList: true, subtree: true }) + if (!this.isConnected) { + pendingHosts.add(new WeakRef(this)) } - console.debug('[WDIO]', 'newShadowRoot', this, parentNode, parentNode === document, document.documentElement) + registerShadowRoot(this) return shadowRoot } } diff --git a/packages/webdriverio/src/session/context.ts b/packages/webdriverio/src/session/context.ts index f0e65b03ff0..73b22e74097 100644 --- a/packages/webdriverio/src/session/context.ts +++ b/packages/webdriverio/src/session/context.ts @@ -24,9 +24,6 @@ export class ContextManager extends SessionManager { #mobileContext?: string #isNativeContext: boolean #getContextSupport = true - #onCommandListener: (event: { command: string, body: unknown }) => void - #onCommandResultMobileListener: (event: { command: string, result: unknown }) => void - #navigationStartedListener: (nav: local.BrowsingContextNavigationInfo) => void constructor(browser: WebdriverIO.Browser) { super(browser, ContextManager.name) @@ -39,16 +36,6 @@ export class ContextManager extends SessionManager { isNativeContext: this.#isNativeContext }) - this.#onCommandListener = this.#onCommand.bind(this) - this.#onCommandResultMobileListener = this.#onCommandResultMobile.bind(this) - this.#navigationStartedListener = this.#navigationStarted.bind(this) - - /** - * Listens for the 'closeWindow' browser command to handle context changes. - * (classic + bidi) - */ - // this.#browser.on('result', this.#onCommandResultBidiAndClassic.bind(this)) - // only listen to command events if we are in a bidi session or a mobile session // Adding the check for mobile in the `this.isEnabled()` method breaking the method and throws // `Test execution failed: TypeError: __privateGet(...).sessionSubscribe is not a function` @@ -60,37 +47,29 @@ export class ContextManager extends SessionManager { * Listens for the 'switchToWindow' browser command to handle context changes. * Updates the browsingContext with the context passed in 'switchToWindow'. */ - this.#browser.on('command', this.#onCommandListener) + this.#browser.on('command', this.#onCommand) + this.#browser.on('result', this.#onCommandResult) - /** - * Listens for the 'closeWindow' browser command to handle context changes. - */ - if (this.#browser.isMobile) { - this.#browser.on('result', this.#onCommandResultMobileListener) - } else { + if (!this.#browser.isMobile) { /** * Listen to the 'browsingContext.navigationStarted' event to handle context changes * through navigation within e.g. frames. */ this.#browser.sessionSubscribe({ events: ['browsingContext.navigationStarted'] - }) - this.#browser.on('browsingContext.navigationStarted', this.#navigationStartedListener) + }).catch((err) => log.warn('Failed to subscribe to navigation events', err)) + this.#browser.on('browsingContext.navigationStarted', this.#onNavigationStarted) } } removeListeners(): void { super.removeListeners() - // this.#browser.off('result', this.#onCommandResultBidiAndClassic.bind(this)) - this.#browser.off('command', this.#onCommandListener) - if (this.#browser.isMobile) { - this.#browser.off('result', this.#onCommandResultMobileListener) - } else { - this.#browser.off('browsingContext.navigationStarted', this.#navigationStartedListener) - } + this.#browser.off('command', this.#onCommand) + this.#browser.off('result', this.#onCommandResult) + this.#browser.off('browsingContext.navigationStarted', this.#onNavigationStarted) } - async #navigationStarted(nav: local.BrowsingContextNavigationInfo) { + #onNavigationStarted = async (nav: local.BrowsingContextNavigationInfo) => { /** * no need to do anything as we navigate within the same context */ @@ -103,34 +82,40 @@ export class ContextManager extends SessionManager { * current tree and see if our context is still there, if not, we need to reset * the context to the first context in the tree. */ - const { contexts } = await this.#browser.browsingContextGetTree({}) - /** - * check if the context is still in the tree, if not, switch to... - */ - const hasContext = this.findContext(this.#currentContext, contexts, 'byContextId') - /** - * ...the context we are navigating to - */ - const newContext = contexts.find((context) => context.context === nav.context) - if (!hasContext && newContext) { - this.setCurrentContext(newContext.context) - await this.#browser.switchToWindow(this.#currentContext) + try { + const { contexts } = await this.#browser.browsingContextGetTree({}) + /** + * check if the context is still in the tree, if not, switch to... + */ + const hasContext = this.findContext(this.#currentContext, contexts, 'byContextId') + /** + * ...the context we are navigating to + */ + const newContext = contexts.find((context) => context.context === nav.context) + if (!hasContext && newContext) { + await this.#browser.switchToWindow(newContext.context) + this.setCurrentContext(newContext.context) + return + } + } catch (err) { + // Navigation can race with a destroyed context or a reloaded session. + log.warn('Failed to update context after navigation', err) + } + } + + #onCommandResult = (event: { command: string, body: unknown, result: unknown }) => { + if ((event.result as { error?: unknown }).error) { return } + if (event.command === 'switchToWindow') { + this.setCurrentContext((event.body as { handle: string }).handle) + } + if (this.#browser.isMobile) { + this.#onCommandResultMobile(event) + } } - // #onCommandResultBidiAndClassic(event: { command: string, result: unknown }) { - // /** - // * the `closeWindow` command returns: - // * > the result of running the remote end steps for the Get Window Handles command, with session, URL variables and parameters. - // */ - // if (event.command === 'closeWindow') { - // this.#currentContext = (event.result as { value: string[] }).value[0] - // return this.#browser.switchToWindow(this.#currentContext) - // } - // } - - #onCommand(event: { command: string, body: unknown }) { + #onCommand = (event: { command: string, body: unknown }) => { /** * update frame context if user switches using 'switchToParentFrame' */ @@ -148,14 +133,6 @@ export class ContextManager extends SessionManager { }) } - /** - * update frame context if user switches using 'switchToWindow' - * which is WebDriver Classic only - */ - if (event.command === 'switchToWindow') { - this.setCurrentContext((event.body as { handle: string }).handle) - } - /** * reset current context if: * - user uses 'switchToParentFrame' which only impacts WebDriver Classic commands diff --git a/packages/webdriverio/src/session/dialog.ts b/packages/webdriverio/src/session/dialog.ts index 597f13c54bd..a20d6e75253 100644 --- a/packages/webdriverio/src/session/dialog.ts +++ b/packages/webdriverio/src/session/dialog.ts @@ -1,21 +1,19 @@ import { type local } from '@testplane/webdriver' +import logger from '@testplane/wdio-logger' import { SessionManager } from './session.js' +const log = logger('webdriverio:DialogManager') + export function getDialogManager(browser: WebdriverIO.Browser) { return SessionManager.getSessionManager(browser, DialogManager) } /** - * This class is responsible for managing shadow roots and their elements. - * It allows to do deep element lookups and pierce into shadow DOMs across - * all components of a page. + * Dispatch dialog events to user listeners, or dismiss unhandled dialogs. */ export class DialogManager extends SessionManager { #browser: WebdriverIO.Browser #initialize: Promise - #autoHandleDialog = true - - #handleUserPromptListener = this.#handleUserPrompt.bind(this) constructor(browser: WebdriverIO.Browser) { super(browser, DialogManager.name) @@ -35,46 +33,33 @@ export class DialogManager extends SessionManager { this.#initialize = this.#browser.sessionSubscribe({ events: ['browsingContext.userPromptOpened'] }).then(() => true, () => false) - // @ts-ignore this is a private event - this.#browser.on('_dialogListenerRegistered', () => this.#switchListenerFlag(false)) - // @ts-ignore this is a private event - this.#browser.on('_dialogListenerRemoved', () => this.#switchListenerFlag(true)) - this.#browser.on('browsingContext.userPromptOpened', this.#handleUserPromptListener) + this.#browser.on('browsingContext.userPromptOpened', this.#handleUserPrompt) } removeListeners(): void { super.removeListeners() - this.#browser.off('browsingContext.userPromptOpened', this.#handleUserPromptListener) - this.#browser.removeAllListeners('_dialogListenerRegistered') - this.#browser.removeAllListeners('_dialogListenerRemoved') + this.#browser.off('browsingContext.userPromptOpened', this.#handleUserPrompt) } async initialize () { return this.#initialize } - /** - * capture shadow root elements propagated through console.debug - */ - async #handleUserPrompt(log: local.BrowsingContextUserPromptOpenedParameters) { - if (this.#autoHandleDialog) { + #handleUserPrompt = async (event: local.BrowsingContextUserPromptOpenedParameters) => { + // Query the live listener count: once(), addListener(), removeAllListeners() + // and removing one of several listeners cannot be represented by a boolean. + if (this.#browser.listenerCount('dialog') === 0) { return this.#browser.browsingContextHandleUserPrompt({ accept: false, - context: log.context + context: event.context + }).catch((error: Error) => { + // EventEmitter does not await async listeners. Report the failure + // instead of leaking an unhandled rejection from automatic handling. + log.warn(`Unable to auto-dismiss dialog in context ${event.context}: ${error.message}`) }) } - const dialog = new Dialog(log, this.#browser) - this.#browser.emit('dialog', dialog) - } - - /** - * Is called when a new dialog listener is registered with the `dialog` name. - * In these cases we set a flag to the `#listener` map to indicate that we - * are listening to dialog events for this page in this context. - */ - #switchListenerFlag(value: boolean) { - this.#autoHandleDialog = value + this.#browser.emit('dialog', new Dialog(event, this.#browser)) } } diff --git a/packages/webdriverio/src/session/networkManager.ts b/packages/webdriverio/src/session/networkManager.ts index d80ce0333a8..f8d335c8424 100644 --- a/packages/webdriverio/src/session/networkManager.ts +++ b/packages/webdriverio/src/session/networkManager.ts @@ -20,11 +20,6 @@ export class NetworkManager extends SessionManager { #requests = new Map() #lastNetworkId?: string - #navigationStartedListener = this.#navigationStarted.bind(this) - #responseCompletedListener = this.#responseCompleted.bind(this) - #beforeRequestSentListener = this.#beforeRequestSent.bind(this) - #fetchErrorListener = this.#fetchError.bind(this) - constructor(browser: WebdriverIO.Browser) { super(browser, NetworkManager.name) this.#browser = browser @@ -49,27 +44,27 @@ export class NetworkManager extends SessionManager { 'network.fetchError' ] }).then(() => true, () => false) - this.#browser.on('browsingContext.navigationStarted', this.#navigationStartedListener) - this.#browser.on('browsingContext.fragmentNavigated', this.#navigationStartedListener) - this.#browser.on('network.responseCompleted', this.#responseCompletedListener) - this.#browser.on('network.beforeRequestSent', this.#beforeRequestSentListener) - this.#browser.on('network.fetchError', this.#fetchErrorListener) + this.#browser.on('browsingContext.navigationStarted', this.#navigationStarted) + this.#browser.on('browsingContext.fragmentNavigated', this.#navigationStarted) + this.#browser.on('network.responseCompleted', this.#responseCompleted) + this.#browser.on('network.beforeRequestSent', this.#beforeRequestSent) + this.#browser.on('network.fetchError', this.#fetchError) } removeListeners(): void { super.removeListeners() - this.#browser.off('browsingContext.navigationStarted', this.#navigationStartedListener) - this.#browser.off('browsingContext.fragmentNavigated', this.#navigationStartedListener) - this.#browser.off('network.responseCompleted', this.#responseCompletedListener) - this.#browser.off('network.beforeRequestSent', this.#beforeRequestSentListener) - this.#browser.off('network.fetchError', this.#fetchErrorListener) + this.#browser.off('browsingContext.navigationStarted', this.#navigationStarted) + this.#browser.off('browsingContext.fragmentNavigated', this.#navigationStarted) + this.#browser.off('network.responseCompleted', this.#responseCompleted) + this.#browser.off('network.beforeRequestSent', this.#beforeRequestSent) + this.#browser.off('network.fetchError', this.#fetchError) } async initialize () { return this.#initialize } - #beforeRequestSent(log: local.NetworkBeforeRequestSentParameters) { + #beforeRequestSent = (log: local.NetworkBeforeRequestSentParameters) => { /** * for events with navigation id we skip this step as we pull information * from the `navigationStarted` event @@ -104,7 +99,7 @@ export class NetworkManager extends SessionManager { } - #navigationStarted(log: local.BrowsingContextNavigationInfo) { + #navigationStarted = (log: local.BrowsingContextNavigationInfo) => { if ( /** * we need a navigation id to identify the request @@ -144,7 +139,7 @@ export class NetworkManager extends SessionManager { }) } - #fetchError (log: local.NetworkFetchErrorParameters) { + #fetchError = (log: local.NetworkFetchErrorParameters) => { const response = this.#findRootRequest(log.navigation) if (!response) { return @@ -176,7 +171,7 @@ export class NetworkManager extends SessionManager { : firstRequest } - #responseCompleted (log: local.NetworkResponseCompletedParameters) { + #responseCompleted = (log: local.NetworkResponseCompletedParameters) => { const response = this.#findRootRequest(log.navigation) if (!response) { return diff --git a/packages/webdriverio/src/session/polyfill.ts b/packages/webdriverio/src/session/polyfill.ts index feb82cac5a2..d9f0a68a899 100644 --- a/packages/webdriverio/src/session/polyfill.ts +++ b/packages/webdriverio/src/session/polyfill.ts @@ -38,8 +38,11 @@ export class PolyfillManager extends SessionManager { #initialize: Promise #browser: WebdriverIO.Browser #scriptsRegisteredInContexts: Set = new Set() - - #registerScriptsListener = this.#registerScripts.bind(this) + #onContextCreated = (context: Pick) => ( + this.#registerScripts(context)?.catch((error: Error) => { + log.warn(`Unable to register polyfill in context ${context.context}: ${error.message}`) + }) + ) constructor(browser: WebdriverIO.Browser) { super(browser, PolyfillManager.name) @@ -53,8 +56,7 @@ export class PolyfillManager extends SessionManager { return } - // start listening for browsingContext.contextCreated - this.#browser.on('browsingContext.contextCreated', this.#registerScriptsListener) + this.#browser.on('browsingContext.contextCreated', this.#onContextCreated) /** * apply polyfill script for upcoming as well as current execution context @@ -71,11 +73,10 @@ export class PolyfillManager extends SessionManager { removeListeners() { super.removeListeners() - // stop listening for browsingContext.contextCreated - this.#browser.off('browsingContext.contextCreated', this.#registerScriptsListener) + this.#browser.off('browsingContext.contextCreated', this.#onContextCreated) } - #registerScripts (context: Pick) { + #registerScripts = (context: Pick) => { if (this.#scriptsRegisteredInContexts.has(context.context)) { return } @@ -94,12 +95,17 @@ export class PolyfillManager extends SessionManager { functionDeclaration, target: context, awaitPromise: false - }).catch(() => { - /** - * this may fail if the context is already destroyed - */ }) - ]) + ]).catch((error: Error) => { + this.#scriptsRegisteredInContexts.delete(context.context) + if (/no such frame|no such browsing context/.test(error.message)) { + // A contextCreated event can race with closing that tab. Both + // preload registration and immediate injection can then fail. + log.debug(`Context ${context.context} was destroyed before polyfill registration`) + return + } + throw error + }) } async initialize () { diff --git a/packages/webdriverio/src/session/session.ts b/packages/webdriverio/src/session/session.ts index 956ecd12e02..0b5210caf35 100644 --- a/packages/webdriverio/src/session/session.ts +++ b/packages/webdriverio/src/session/session.ts @@ -1,6 +1,6 @@ const sessionManager = new Map>() -const listenerRegisteredSession = new Set() +const listenerRegisteredSession = new WeakMap>() export class SessionManager { #browser: WebdriverIO.Browser @@ -16,28 +16,33 @@ export class SessionManager { constructor(browser: WebdriverIO.Browser, scope: string) { this.#browser = browser this.#scope = scope - const registrationId = `${this.#browser.sessionId}-${this.#scope}` - if (!listenerRegisteredSession.has(registrationId)) { - this.#browser.on('command', this.#onCommandListener) - listenerRegisteredSession.add(registrationId) + let registeredManagers = listenerRegisteredSession.get(browser) + if (!registeredManagers) { + registeredManagers = new Map() + listenerRegisteredSession.set(browser, registeredManagers) + } + if (!registeredManagers.has(scope)) { + this.#browser.on('command', this.#onCommand) + registeredManagers.set(scope, this) } } - #onCommandListener = this.#onCommand.bind(this) - - #onCommand(ev: { command: string }) { + #onCommand = (ev: { command: string }) => { if (ev.command === 'deleteSession') { + this.removeListeners() const sessionManagerInstances = sessionManager.get(this.#scope) - const sessionManagerInstance = sessionManagerInstances?.get(this.#browser) - if (sessionManagerInstance && sessionManagerInstances) { - sessionManagerInstance.removeListeners() + if (sessionManagerInstances?.get(this.#browser) === this) { sessionManagerInstances.delete(this.#browser) } } } removeListeners() { - this.#browser.off('command', this.#onCommandListener) + this.#browser.off('command', this.#onCommand) + const registeredManagers = listenerRegisteredSession.get(this.#browser) + if (registeredManagers?.get(this.#scope) === this) { + registeredManagers.delete(this.#scope) + } } initialize(): unknown { diff --git a/packages/webdriverio/src/session/shadowRoot.ts b/packages/webdriverio/src/session/shadowRoot.ts index 46ddf4e57e1..448286fc542 100644 --- a/packages/webdriverio/src/session/shadowRoot.ts +++ b/packages/webdriverio/src/session/shadowRoot.ts @@ -23,10 +23,9 @@ export class ShadowRootManager extends SessionManager { #shadowRoots = new Map() #documentElement?: remote.ScriptNodeRemoteValue #frameDepth = 0 - - #handleLogEntryListener = this.handleLogEntry.bind(this) - #commandResultHandlerListener = this.#commandResultHandler.bind(this) - #handleBidiCommandListener = this.#handleBidiCommand.bind(this) + #onLogEntry = this.handleLogEntry.bind(this) + #onCommandResult = this.#commandResultHandler.bind(this) + #onBidiCommand = this.#handleBidiCommand.bind(this) constructor(browser: WebdriverIO.Browser) { super(browser, ShadowRootManager.name) @@ -46,9 +45,9 @@ export class ShadowRootManager extends SessionManager { this.#initialize = this.#browser.sessionSubscribe({ events: ['log.entryAdded', 'browsingContext.navigationStarted'] }).then(() => true, () => false) - this.#browser.on('log.entryAdded', this.#handleLogEntryListener) - this.#browser.on('result', this.#commandResultHandlerListener) - this.#browser.on('bidiCommand', this.#handleBidiCommandListener) + this.#browser.on('log.entryAdded', this.#onLogEntry) + this.#browser.on('result', this.#onCommandResult) + this.#browser.on('bidiCommand', this.#onBidiCommand) this.#browser.scriptAddPreloadScript({ functionDeclaration: customElementWrapper.toString() }) @@ -56,9 +55,9 @@ export class ShadowRootManager extends SessionManager { removeListeners(): void { super.removeListeners() - this.#browser.off('log.entryAdded', this.#handleLogEntryListener) - this.#browser.off('result', this.#commandResultHandlerListener) - this.#browser.off('bidiCommand', this.#handleBidiCommandListener) + this.#browser.off('log.entryAdded', this.#onLogEntry) + this.#browser.off('result', this.#onCommandResult) + this.#browser.off('bidiCommand', this.#onBidiCommand) } async initialize () { diff --git a/packages/webdriverio/src/utils/index.ts b/packages/webdriverio/src/utils/index.ts index 24d22b2417c..f71200cce82 100644 --- a/packages/webdriverio/src/utils/index.ts +++ b/packages/webdriverio/src/utils/index.ts @@ -21,7 +21,7 @@ import type { CustomStrategyReference, ExtendedElementReference } from '../types const log = logger('webdriverio') const INVALID_SELECTOR_ERROR = 'selector needs to be typeof `string` or `function`' -const IGNORED_COMMAND_FILE_EXPORTS = ['SESSION_MOCKS', 'SESSION_DIBI_MOCKS', 'CDP_SESSIONS'] +const IGNORED_COMMAND_FILE_EXPORTS = ['SESSION_MOCKS', 'SESSION_BIDI_MOCKS', 'CDP_SESSIONS'] declare global { interface Window { __wdio_element: Record } diff --git a/packages/webdriverio/src/utils/interception/bidi.ts b/packages/webdriverio/src/utils/interception/bidi.ts index 0fac7c4be4c..9ce373e77e8 100644 --- a/packages/webdriverio/src/utils/interception/bidi.ts +++ b/packages/webdriverio/src/utils/interception/bidi.ts @@ -11,7 +11,11 @@ import type { WaitForOptions } from '../../types.js' const log = logger('BidiInterception') -let hasSubscribedToEvents = false +const sessions = new WeakMap + mocks: Set +}>() type RespondBody = string | JsonCompatible | Buffer interface Overwrite { @@ -36,7 +40,19 @@ export default class BidiInterception { #restored = false #requestOverwrites: Overwrite[] = [] #respondOverwrites: Overwrite[] = [] + #providedRequests = new Set() + #failedRequests = new Set() #calls: local.NetworkResponseCompletedParameters[] = [] + #pendingCalls = new Map() + #pendingRequests = new Map() + #pendingOperations = new Set>() + #networkError?: Error + #beforeRequestSent = (request: local.NetworkBeforeRequestSentParameters) => { + void this.#handleNetworkEvent(() => this.#handleBeforeRequestSent(request), request.request.request) + } + #responseStarted = (request: local.NetworkResponseCompletedParameters) => { + void this.#handleNetworkEvent(() => this.#handleResponseStarted(request), request.request.request) + } constructor ( pattern: URLPattern, @@ -52,8 +68,8 @@ export default class BidiInterception { /** * attach network listener to this mock */ - browser.on('network.beforeRequestSent', this.#handleBeforeRequestSent.bind(this)) - browser.on('network.responseStarted', this.#handleResponseStarted.bind(this)) + browser.on('network.beforeRequestSent', this.#beforeRequestSent) + browser.on('network.responseStarted', this.#responseStarted) } static async initiate( @@ -62,22 +78,37 @@ export default class BidiInterception { browser: WebdriverIO.Browser ) { const pattern = parseUrlPattern(url) - if (!hasSubscribedToEvents) { - await browser.sessionSubscribe({ - events: [ - 'network.beforeRequestSent', - 'network.responseStarted' - ] - }) - log.info('subscribed to network events') - hasSubscribedToEvents = true + let session = sessions.get(browser) + if (!session || session.sessionId !== browser.sessionId) { + for (const mock of session?.mocks || []) { + mock.#detach() + } + session = { + sessionId: browser.sessionId, + subscribed: browser.sessionSubscribe({ + events: ['network.beforeRequestSent', 'network.responseStarted'] + }), + mocks: new Set() + } + sessions.set(browser, session) + } + try { + await session.subscribed + } catch (error) { + if (sessions.get(browser) === session) { + sessions.delete(browser) + } + throw error } /** * register network intercept */ const interception = await browser.networkAddIntercept({ - phases: ['beforeRequestSent', 'responseStarted'], + // Firefox can only replace bodies beforeRequestSent. Intercepting + // its synthetic responseStarted event also races the first phase's + // continuation, so observe those responses without blocking them. + phases: browser.isFirefox ? ['beforeRequestSent'] : ['beforeRequestSent', 'responseStarted'], urlPatterns: [{ type: 'pattern', protocol: getPatternParam(pattern, 'protocol'), @@ -88,7 +119,9 @@ export default class BidiInterception { }] }) - return new BidiInterception(pattern, interception.intercept, filterOptions, browser) + const mock = new BidiInterception(pattern, interception.intercept, filterOptions, browser) + session.mocks.add(mock) + return mock } #emit (event: string, args: unknown) { @@ -111,6 +144,54 @@ export default class BidiInterception { handlers?.push(handler) } + #handleNetworkEvent(handler: () => Promise | undefined, requestId: string) { + this.#pendingRequests.set(requestId, (this.#pendingRequests.get(requestId) || 0) + 1) + const operation = this.#runNetworkEvent(handler, requestId) + this.#pendingOperations.add(operation) + void operation.then(() => { + this.#pendingOperations.delete(operation) + const remaining = (this.#pendingRequests.get(requestId) || 1) - 1 + if (remaining > 0) { + this.#pendingRequests.set(requestId, remaining) + return + } + this.#pendingRequests.delete(requestId) + this.#calls.push(...(this.#pendingCalls.get(requestId) || [])) + this.#pendingCalls.delete(requestId) + }) + } + + async #runNetworkEvent(handler: () => Promise | undefined, requestId: string) { + try { + await handler() + } catch (error) { + // EventEmitter does not await listener promises. Keep the failure + // observable through calls/waitForResponse instead of crashing the + // process with an unhandled rejection, and release the blocked request. + this.#networkError ||= error instanceof Error ? error : new Error(String(error)) + this.#failedRequests.add(requestId) + this.#providedRequests.delete(requestId) + this.#pendingCalls.delete(requestId) + log.error(`Failed to mock request ${requestId}: ${this.#networkError.message}`) + try { + await this.#browser.networkFailRequest({ request: requestId }) + } catch (abortError) { + log.error(`Failed to abort mocked request ${requestId}: ${abortError}`) + } + } + } + + #recordResponse(request: local.NetworkResponseCompletedParameters) { + // A late browser event cannot turn a failed interception into a + // successful call, even if clear/reset acknowledged the stored error. + if (this.#failedRequests.has(request.request.request)) { + return + } + const calls = this.#pendingCalls.get(request.request.request) || [] + calls.push(request) + this.#pendingCalls.set(request.request.request, calls) + } + #handleBeforeRequestSent(request: local.NetworkBeforeRequestSentParameters) { /** * don't do anything if: @@ -124,13 +205,31 @@ export default class BidiInterception { /** * check if request matches filter option and do nothing if not */ - if (!this.#matchesFilterOptions(request)) { + if (!this.#pattern.test(request.request.url) || !this.#matchesFilterOptions(request)) { return this.#browser.networkContinueRequest({ request: request.request.request }) } this.#emit('request', request) + const responseOverwrite = this.#respondOverwrites[0] + if ( + responseOverwrite?.overwrite && + 'fetchResponse' in responseOverwrite.overwrite && + responseOverwrite.overwrite.fetchResponse === false && + this.#requestOverwrites.length === 0 + ) { + if (responseOverwrite.once) { + this.#respondOverwrites.shift() + } + this.#providedRequests.add(request.request.request) + this.#emit('overwrite', request) + return this.#browser.networkProvideResponse({ + request: request.request.request, + ...parseOverwrite(responseOverwrite.overwrite, request) + }) + } + const hasRequestOverwrites = this.#requestOverwrites.length > 0 if (hasRequestOverwrites) { const { overwrite, abort } = this.#requestOverwrites[0].once @@ -156,6 +255,29 @@ export default class BidiInterception { } #handleResponseStarted(request: local.NetworkResponseCompletedParameters) { + // Providing a complete response before the request is sent still emits + // response events. Record that response, but never consume another + // respondOnce overwrite or provide its body a second time. + if (!this.#restored && this.#providedRequests.has(request.request.request)) { + this.#recordResponse(request) + // Chromium can report isBlocked from the configured intercepts, + // even though provideResponse already fulfilled this request. + // Keep ownership until the other listeners saw this event. + queueMicrotask(() => { + this.#providedRequests.delete(request.request.request) + }) + return + } + + // Firefox only intercepts the request phase, but spies still need real + // response data and response filters without resuming unblocked events. + if (this.#browser.isFirefox && !request.isBlocked) { + if (!this.#restored && this.#pattern.test(request.request.url) && this.#matchesFilterOptions(request)) { + this.#recordResponse(request) + } + return + } + /** * don't do anything if: * - request is not blocked @@ -168,7 +290,7 @@ export default class BidiInterception { /** * continue mock if not matching filter */ - if (!this.#matchesFilterOptions(request)) { + if (!this.#pattern.test(request.request.url) || !this.#matchesFilterOptions(request)) { this.#emit('continue', request.request.request) return this.#browser.networkProvideResponse({ request: request.request.request @@ -176,16 +298,18 @@ export default class BidiInterception { } /** - * mark mock to be "called" + * Publish the call only after its protocol operations have settled. */ - this.#calls.push(request) + this.#recordResponse(request) /** * continue response as mock has no respond overwrites */ if ( this.#respondOverwrites.length === 0 || - !this.#respondOverwrites[0].overwrite + !this.#respondOverwrites[0].overwrite || + ('fetchResponse' in this.#respondOverwrites[0].overwrite && + this.#respondOverwrites[0].overwrite.fetchResponse === false) ) { this.#emit('continue', request.request.request) return this.#browser.networkProvideResponse({ @@ -231,7 +355,20 @@ export default class BidiInterception { } #isRequestMatching (request: T) { - return request.isBlocked && this.#pattern && this.#pattern.test(request.request.url) + if (!request.isBlocked || this.#restored) { + return false + } + + // BiDi URL components are exact matches, so wildcard components are + // filtered locally. Only one mock may resume a blocked request, even + // when several broad protocol intercepts captured it. + const mocks = [...(sessions.get(this.#browser)?.mocks || [])].filter(mock => ( + !mock.#restored && (!request.intercepts || request.intercepts.includes(mock.#mockId)) + )) + const owner = mocks.find(mock => mock.#providedRequests.has(request.request.request)) || mocks.find(mock => ( + mock.#pattern.test(request.request.url) && mock.#matchesFilterOptions(request) + )) || mocks[0] + return owner === this } #matchesFilterOptions (request: T) { @@ -301,6 +438,9 @@ export default class BidiInterception { * allows access to all requests made with given pattern */ get calls(): local.NetworkResponseCompletedParameters[] { + if (this.#networkError) { + throw this.#networkError + } return this.#calls } @@ -309,6 +449,8 @@ export default class BidiInterception { */ clear() { this.#calls = [] + this.#pendingCalls.clear() + this.#networkError = undefined return this } @@ -323,24 +465,41 @@ export default class BidiInterception { return this } + #detach() { + this.#browser.off('network.beforeRequestSent', this.#beforeRequestSent) + this.#browser.off('network.responseStarted', this.#responseStarted) + this.#restored = true + this.#providedRequests.clear() + } + /** * Does everything that `mock.reset()` does, and also * removes any mocked return values or implementations. * Restored mock does not emit events and could not mock responses */ async restore() { - this.reset() + // Stop applying overrides, but preserve in-flight failures until after + // cleanup. Clearing the error here could turn a failed mock into a pass. this.#respondOverwrites = [] - this.#restored = true + this.#requestOverwrites = [] + // Keep listeners alive while the remote end removes the intercept: + // requests already blocked by it still need to be continued. + if (this.#mockId) { + await this.#browser.networkRemoveIntercept({ intercept: this.#mockId }) + } + this.#detach() + sessions.get(this.#browser)?.mocks.delete(this) + // Includes requests received while networkRemoveIntercept was pending. + await Promise.all(this.#pendingOperations) const handle = await this.#browser.getWindowHandle() log.trace(`Restoring mock for ${handle}`) - SESSION_BIDI_MOCKS[handle].delete(this as BidiInterception) + SESSION_BIDI_MOCKS[handle]?.delete(this) - if (this.#mockId) { - await this.#browser.networkRemoveIntercept({ intercept: this.#mockId }) + if (this.#networkError) { + throw this.#networkError } - + this.reset() return this } @@ -352,6 +511,9 @@ export default class BidiInterception { */ request(overwrite: RequestWithOptions, once?: boolean) { this.#ensureNotRestored() + if (this.#respondOverwrites.some(({ overwrite }) => overwrite && 'fetchResponse' in overwrite && overwrite.fetchResponse === false)) { + throw new Error('Request overwrites cannot be combined with mock.respond when it does not fetch the real response') + } this.#requestOverwrites = this.#setOverwrite(this.#requestOverwrites, { overwrite, once }) return this } @@ -372,12 +534,25 @@ export default class BidiInterception { */ respond(payload: RespondBody, params: Omit = {}, once?: boolean) { this.#ensureNotRestored() + if (this.#browser.isFirefox && params.fetchResponse === true) { + throw new Error('Firefox does not support mock.respond with fetchResponse: true; response bodies can only be provided before the request is sent') + } + const fetchResponse = params.fetchResponse ?? !this.#browser.isFirefox + if (!fetchResponse && (this.#filterOptions.responseHeaders || this.#filterOptions.statusCode !== undefined)) { + throw new Error('Response filters (responseHeaders and statusCode) cannot be used when mock.respond does not fetch the real response') + } + if (!fetchResponse && typeof params.statusCode === 'function') { + throw new Error('A statusCode function requires the real response; use a numeric statusCode when mock.respond does not fetch the real response') + } + if (!fetchResponse && this.#requestOverwrites.some(({ overwrite }) => overwrite)) { + throw new Error('Request overwrites cannot be combined with mock.respond when it does not fetch the real response') + } const body = typeof payload === 'string' ? payload : globalThis.Buffer && globalThis.Buffer.isBuffer(payload) ? payload.toString('base64') : JSON.stringify(payload) - const overwrite: RespondWithOptions = { body, ...params } + const overwrite: RespondWithOptions = { body, ...params, fetchResponse } this.#respondOverwrites = this.#setOverwrite(this.#respondOverwrites, { overwrite, once }) return this } diff --git a/packages/webdriverio/src/utils/interception/devtools.ts b/packages/webdriverio/src/utils/interception/devtools.ts index 6fd1dba27b1..03cc7cb094e 100644 --- a/packages/webdriverio/src/utils/interception/devtools.ts +++ b/packages/webdriverio/src/utils/interception/devtools.ts @@ -1,7 +1,7 @@ import fs from 'node:fs/promises' import path from 'node:path' import logger from '@testplane/wdio-logger' -import type { CDPSession } from 'puppeteer-core/lib/esm/puppeteer/common/Connection.js' +import type { CDPSession } from 'puppeteer-core' import type { Protocol } from 'devtools-protocol' import Interception from './index.js' @@ -22,20 +22,12 @@ interface HeaderEntry { value: string; } -type Event = { - requestId: string - request: Matches & { mockedResponse: string | Buffer } - responseStatusCode?: number - responseHeaders: HeaderEntry[] - responseErrorReason?: string -} - type ExpectParameter = ((param: T) => boolean) | T; export default class DevtoolsInterception extends Interception { private restored = false - static handleRequestInterception (client: CDPSession, mocks: Set): (event: Event) => Promise { + static handleRequestInterception (client: CDPSession, mocks: Set): (event: Protocol.Fetch.RequestPausedEvent) => Promise { return async (event) => { // Race condition: if mock was already restored on client side // But browser managed to send "Fetch.requestPaused" event before it received "Fetch.disable" @@ -83,8 +75,11 @@ export default class DevtoolsInterception extends Interception { /** * Add statusCode and responseHeaders to request to be used in expect-webdriverio */ - event.request.statusCode = event.responseStatusCode - event.request.responseHeaders = { ...responseHeaders } + // Enrich the original CDP request in place, preserving its identity for listeners. + // The body is populated below before exposing the request in mock.matches. + const request = event.request as Matches & { mockedResponse?: string | Buffer } + request.statusCode = event.responseStatusCode + request.responseHeaders = { ...responseHeaders } /** * match filter options @@ -101,7 +96,7 @@ export default class DevtoolsInterception extends Interception { mock.emit('request', event) - const { requestId, request, responseStatusCode } = event + const { requestId, responseStatusCode } = event const { body, base64Encoded = undefined } = isRequest ? { body: '' } : await client.send( 'Fetch.getResponseBody', { requestId } diff --git a/packages/webdriverio/src/utils/interception/types.ts b/packages/webdriverio/src/utils/interception/types.ts index bdce08bd01b..ef28215b552 100644 --- a/packages/webdriverio/src/utils/interception/types.ts +++ b/packages/webdriverio/src/utils/interception/types.ts @@ -1,7 +1,7 @@ import type { local } from '@testplane/webdriver' import type { Cookie } from '@testplane/wdio-protocols' -import type { CDPSession } from 'puppeteer-core/lib/esm/puppeteer/common/Connection.js' +import type { CDPSession } from 'puppeteer-core' import type { JsonCompatible } from '@testplane/wdio-types' // export type MockFilterOptions = { @@ -25,6 +25,12 @@ export interface RequestWithOptions { export interface RespondWithOptions extends Omit { statusCode?: Overwrite + /** + * Fetch the real response before replacing its body. Defaults to true on + * Chromium and false on Firefox, which only supports bodies before the + * request is sent. Firefox does not support an explicit true value. + */ + fetchResponse?: boolean } export interface MockRequestOptions { diff --git a/packages/webdriverio/src/utils/interception/utils.ts b/packages/webdriverio/src/utils/interception/utils.ts index 876b85181fb..645a94854fc 100644 --- a/packages/webdriverio/src/utils/interception/utils.ts +++ b/packages/webdriverio/src/utils/interception/utils.ts @@ -91,7 +91,9 @@ export function parseOverwrite< } export function getPatternParam (pattern: URLPattern, key: keyof Omit) { - if (key !== 'pathname' && pattern[key] === '*') { + // BiDi compares URL components for equality; it does not evaluate URLPattern syntax. + // Omit dynamic components and let BidiInterception apply the complete pattern. + if (/[*():{}+?]/.test(pattern[key])) { return } diff --git a/packages/webdriverio/src/utils/puppeteerCompatibility.ts b/packages/webdriverio/src/utils/puppeteerCompatibility.ts new file mode 100644 index 00000000000..48996f9b5f5 --- /dev/null +++ b/packages/webdriverio/src/utils/puppeteerCompatibility.ts @@ -0,0 +1,51 @@ +import type { Browser, BrowserContext, BrowserContextOptions } from 'puppeteer-core' + +export interface CompatibleBrowserContext extends BrowserContext { + /** @deprecated Use context.id to distinguish the default context. */ + isIncognito(): boolean +} + +export interface CompatiblePuppeteerBrowser extends Browser { + /** @deprecated Use browser.connected. */ + isConnected(): boolean + /** @deprecated Use browser.createBrowserContext(). */ + createIncognitoBrowserContext(options?: BrowserContextOptions): Promise + createBrowserContext(options?: BrowserContextOptions): Promise + browserContexts(): CompatibleBrowserContext[] + defaultBrowserContext(): CompatibleBrowserContext +} + +const adaptedBrowsers = new WeakSet() + +/** Preserve the Puppeteer 20 context APIs used by Testplane's session isolation. */ +export function addPuppeteerCompatibility(browser: Browser): CompatiblePuppeteerBrowser { + if (adaptedBrowsers.has(browser)) { + return browser as CompatiblePuppeteerBrowser + } + + const decorateContext = (context: BrowserContext): CompatibleBrowserContext => { + if (!('isIncognito' in context)) { + Object.defineProperty(context, 'isIncognito', { + configurable: true, + value: () => Boolean(context.id) + }) + } + return context as CompatibleBrowserContext + } + const createBrowserContext = browser.createBrowserContext + const browserContexts = browser.browserContexts + const defaultBrowserContext = browser.defaultBrowserContext + const createContext = async (options?: BrowserContextOptions) => ( + decorateContext(await createBrowserContext.call(browser, options)) + ) + + Object.assign(browser, { + isConnected: () => browser.connected, + createIncognitoBrowserContext: createContext, + createBrowserContext: createContext, + browserContexts: () => browserContexts.call(browser).map(decorateContext), + defaultBrowserContext: () => decorateContext(defaultBrowserContext.call(browser)) + }) + adaptedBrowsers.add(browser) + return browser as CompatiblePuppeteerBrowser +} diff --git a/packages/webdriverio/tests/commands/browser/deleteCookies.test.ts b/packages/webdriverio/tests/commands/browser/deleteCookies.test.ts index 29826259d9d..64225e14584 100644 --- a/packages/webdriverio/tests/commands/browser/deleteCookies.test.ts +++ b/packages/webdriverio/tests/commands/browser/deleteCookies.test.ts @@ -98,33 +98,48 @@ describe('deleteCookies', () => { storageDeleteCookies.mockImplementation((() => {}) as any) }) - beforeEach(() => { + beforeEach(async () => { + await browser.switchWindow('window-handle-2') storageDeleteCookies.mockClear() }) it('should delete all cookies', async () => { await browser.deleteCookies() expect(storageDeleteCookies).toBeCalledTimes(1) - expect(storageDeleteCookies).toBeCalledWith({}) + expect(storageDeleteCookies).toBeCalledWith({ + partition: { type: 'context', context: 'window-handle-2' } + }) }) it('should support passing a string', async () => { await browser.deleteCookies('cookie1') expect(storageDeleteCookies).toBeCalledTimes(1) - expect(storageDeleteCookies).toBeCalledWith({ filter: { name: 'cookie1' } }) + expect(storageDeleteCookies).toBeCalledWith({ + filter: { name: 'cookie1' }, + partition: { type: 'context', context: 'window-handle-2' } + }) }) it('should support passing an object', async () => { await browser.deleteCookies({ domain: 'foobar.com' }) expect(storageDeleteCookies).toBeCalledTimes(1) - expect(storageDeleteCookies).toBeCalledWith({ filter: { domain: 'foobar.com' } }) + expect(storageDeleteCookies).toBeCalledWith({ + filter: { domain: 'foobar.com' }, + partition: { type: 'context', context: 'window-handle-2' } + }) }) it('should support passing a array with a string', async () => { await browser.deleteCookies(['cookie1', 'cookie2']) expect(storageDeleteCookies).toBeCalledTimes(2) - expect(storageDeleteCookies).toBeCalledWith({ filter: { name: 'cookie1' } }) - expect(storageDeleteCookies).toBeCalledWith({ filter: { name: 'cookie2' } }) + expect(storageDeleteCookies).toBeCalledWith({ + filter: { name: 'cookie1' }, + partition: { type: 'context', context: 'window-handle-2' } + }) + expect(storageDeleteCookies).toBeCalledWith({ + filter: { name: 'cookie2' }, + partition: { type: 'context', context: 'window-handle-2' } + }) }) it('should support passing an array of objects', async () => { @@ -133,8 +148,27 @@ describe('deleteCookies', () => { { domain: 'foobar2.com' } ]) expect(storageDeleteCookies).toBeCalledTimes(2) - expect(storageDeleteCookies).toBeCalledWith({ filter: { domain: 'foobar.com' } }) - expect(storageDeleteCookies).toBeCalledWith({ filter: { domain: 'foobar2.com' } }) + expect(storageDeleteCookies).toBeCalledWith({ + filter: { domain: 'foobar.com' }, + partition: { type: 'context', context: 'window-handle-2' } + }) + expect(storageDeleteCookies).toBeCalledWith({ + filter: { domain: 'foobar2.com' }, + partition: { type: 'context', context: 'window-handle-2' } + }) + }) + + it.each([undefined, 'cookie1'])('should use the new partition after switching windows with filter %j', async (filter) => { + await browser.deleteCookies(filter) + await browser.switchWindow('window-handle-3') + await browser.deleteCookies(filter) + + expect(storageDeleteCookies).toHaveBeenNthCalledWith(1, expect.objectContaining({ + partition: { type: 'context', context: 'window-handle-2' } + })) + expect(storageDeleteCookies).toHaveBeenNthCalledWith(2, expect.objectContaining({ + partition: { type: 'context', context: 'window-handle-3' } + })) }) it('should throw error if invalid arguments are passed', async () => { diff --git a/packages/webdriverio/tests/commands/browser/elementIndex.test.ts b/packages/webdriverio/tests/commands/browser/elementIndex.test.ts new file mode 100644 index 00000000000..75a4cfc5e1c --- /dev/null +++ b/packages/webdriverio/tests/commands/browser/elementIndex.test.ts @@ -0,0 +1,28 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { remote } from '../../../src/index.js' + +afterEach(() => (vi.mocked(fetch) as any).setMockResponse(null)) + +const element = (id: string) => ({ 'element-6066-11e4-a52e-4f735466cecf': id }) + +describe('chained element array index', () => { + it('refetches the array until the requested index exists', async () => { + const browser = await remote({ capabilities: { browserName: 'mock' }, waitforTimeout: 1000, waitforInterval: 5 }) + const fetchMock = vi.mocked(fetch) as any + fetchMock.mockClear() + fetchMock.setMockResponse([[], [element('first')], [element('first'), element('second')]]) + const result = await browser.$$('.foo')[1].getElement() + expect(result.elementId).toBe('second') + expect(fetchMock.mock.calls).toHaveLength(3) + expect(fetchMock.mock.calls.every(([url]: [URL]) => url.pathname.endsWith('/elements'))).toBe(true) + }) + + it('does not refetch an index already present', async () => { + const browser = await remote({ capabilities: { browserName: 'mock' } }) + const fetchMock = vi.mocked(fetch) as any + fetchMock.mockClear() + fetchMock.setMockResponse([[element('first')]]) + expect((await browser.$$('.foo')[0].getElement()).elementId).toBe('first') + expect(fetchMock.mock.calls).toHaveLength(1) + }) +}) diff --git a/packages/webdriverio/tests/commands/browser/getCookies.test.ts b/packages/webdriverio/tests/commands/browser/getCookies.test.ts index 738cb69f35f..a2ef1b09517 100644 --- a/packages/webdriverio/tests/commands/browser/getCookies.test.ts +++ b/packages/webdriverio/tests/commands/browser/getCookies.test.ts @@ -103,7 +103,8 @@ describe('getCookies', () => { }) as any)) }) - beforeEach(() => { + beforeEach(async () => { + await browser.switchWindow('window-handle-2') vi.mocked(fetch).mockClear() storageGetCookies.mockClear() }) @@ -111,7 +112,9 @@ describe('getCookies', () => { it('should return all cookies', async () => { const cookies = await browser.getCookies() expect(storageGetCookies).toBeCalledTimes(1) - expect(storageGetCookies).toBeCalledWith({}) + expect(storageGetCookies).toBeCalledWith({ + partition: { type: 'context', context: 'window-handle-2' } + }) expect(cookies).toEqual([ { name: 'cookie', @@ -120,6 +123,41 @@ describe('getCookies', () => { ]) }) + it.each([ + ['cookie', { name: 'cookie' }], + [['cookie'], { name: 'cookie' }], + [{ domain: 'foobar.com' }, { domain: 'foobar.com' }], + ])('should scope the filter %j to the current context', async (filter, expectedFilter) => { + await browser.getCookies(filter) + expect(storageGetCookies).toBeCalledWith({ + filter: expectedFilter, + partition: { type: 'context', context: 'window-handle-2' } + }) + }) + + it('should use the new partition after switching windows', async () => { + await browser.getCookies() + await browser.switchWindow('window-handle-3') + await browser.getCookies() + + expect(storageGetCookies).toHaveBeenNthCalledWith(1, { + partition: { type: 'context', context: 'window-handle-2' } + }) + expect(storageGetCookies).toHaveBeenNthCalledWith(2, { + partition: { type: 'context', context: 'window-handle-3' } + }) + }) + + it('should preserve the Classic fallback for multiple cookie names', async () => { + const cookies = await browser.getCookies(['cookie1', 'cookie3']) + + expect(storageGetCookies).not.toBeCalled() + expect(cookies).toEqual([ + { name: 'cookie1', value: 'dummy-value-1' }, + { name: 'cookie3', value: 'dummy-value-3' } + ]) + }) + afterEach(() => { vi.mocked(fetch).mockClear() }) diff --git a/packages/webdriverio/tests/commands/browser/puppeteerLifecycle.test.ts b/packages/webdriverio/tests/commands/browser/puppeteerLifecycle.test.ts new file mode 100644 index 00000000000..8f4264d6cfb --- /dev/null +++ b/packages/webdriverio/tests/commands/browser/puppeteerLifecycle.test.ts @@ -0,0 +1,131 @@ +import path from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import puppeteer from 'puppeteer-core' +import WebDriver from '@testplane/webdriver' +import { getPuppeteer } from '../../../src/commands/browser/getPuppeteer.js' +import { reloadSession } from '../../../src/commands/browser/reloadSession.js' +import { throttleCPU } from '../../../src/commands/browser/throttleCPU.js' +import { throttleNetwork } from '../../../src/commands/browser/throttleNetwork.js' + +vi.mock('puppeteer-core', () => ({ default: { connect: vi.fn() } })) +vi.mock('@testplane/webdriver', () => ({ default: { reloadSession: vi.fn() } })) +vi.mock('../../../src/session/index.js', () => ({ registerSessionManager: vi.fn() })) +vi.mock('@testplane/wdio-logger', () => import(path.join(process.cwd(), '__mocks__', '@testplane/wdio-logger'))) + +describe('Puppeteer session lifecycle', () => { + beforeEach(() => vi.clearAllMocks()) + + function browserWith(puppeteerSession?: object) { + const browser = { + sessionId: 'old-session', + options: { automationProtocol: '@testplane/webdriver', headers: { Authorization: 'token' } }, + capabilities: { 'se:cdp': 'ws://localhost/session/cdp' }, + requestedCapabilities: {}, + puppeteer: puppeteerSession, + deleteSession: vi.fn().mockResolvedValue(undefined) + } as unknown as WebdriverIO.Browser + browser.getPuppeteer = getPuppeteer.bind(browser) + return browser + } + + it('reuses a connected Puppeteer session', async () => { + const session = { connected: true } + expect(await getPuppeteer.call(browserWith(session))).toBe(session) + expect(puppeteer.connect).not.toHaveBeenCalled() + }) + + it('preserves the context isolation API used by Testplane', async () => { + const defaultContext = { id: undefined } + const privateContext = { id: 'isolated', close: vi.fn() } + const createBrowserContext = vi.fn().mockResolvedValue(privateContext) + const session = { + connected: true, + browserContexts: () => [defaultContext, privateContext], + defaultBrowserContext: () => defaultContext, + createBrowserContext + } + const browser = browserWith(session) + const compatible = await getPuppeteer.call(browser) + + expect(compatible.isConnected()).toBe(true) + expect(compatible.browserContexts().map(ctx => ctx.isIncognito())).toEqual([false, true]) + expect(compatible.defaultBrowserContext().isIncognito()).toBe(false) + expect(await compatible.createIncognitoBrowserContext({ proxyServer: 'http://proxy.test' })).toBe(privateContext) + expect(createBrowserContext).toHaveBeenCalledWith({ proxyServer: 'http://proxy.test' }) + session.connected = false + expect(compatible.isConnected()).toBe(false) + }) + + it('does not stack context wrappers on repeated getPuppeteer calls', async () => { + const context = { id: 'isolated' } + const session = { + connected: true, + browserContexts: () => [context], + defaultBrowserContext: () => context, + createBrowserContext: vi.fn().mockResolvedValue(context) + } + const browser = browserWith(session) + const compatible = await getPuppeteer.call(browser) + const createContext = compatible.createIncognitoBrowserContext + + expect(await getPuppeteer.call(browser)).toBe(compatible) + expect(compatible.createIncognitoBrowserContext).toBe(createContext) + expect((await compatible.createBrowserContext()).isIncognito()).toBe(true) + }) + + it('reloads the default webdriver protocol using the Testplane fork', async () => { + const browser = browserWith() + browser.options.automationProtocol = 'webdriver' + + await reloadSession.call(browser) + + expect(WebDriver.reloadSession).toHaveBeenCalledWith(browser, undefined) + }) + + it('reconnects when the previous Puppeteer session is disconnected', async () => { + const session = { connected: true } + vi.mocked(puppeteer.connect).mockResolvedValue(session as any) + const browser = browserWith({ connected: false }) + expect(await getPuppeteer.call(browser)).toBe(session) + expect(browser.puppeteer).toBe(session) + expect(puppeteer.connect).toHaveBeenCalledWith({ + browserWSEndpoint: 'ws://localhost/session/cdp', + defaultViewport: null, + headers: { Authorization: 'token' } + }) + }) + + it('waits for Puppeteer disconnect before reloading the WebDriver session', async () => { + const order: string[] = [] + const browser = browserWith({ + connected: true, + disconnect: async () => { + await new Promise(resolve => setTimeout(resolve, 20)) + order.push('disconnect') + } + }) + vi.mocked(WebDriver.reloadSession).mockImplementation(async () => { + order.push('reload') + browser.sessionId = 'new-session' + return 'new-session' as any + }) + expect(await reloadSession.call(browser)).toBe('new-session') + expect(order).toEqual(['disconnect', 'reload']) + }) + + it('sends CPU and network throttling commands through the CDP session', async () => { + const send = vi.fn().mockResolvedValue(undefined) + const browser = browserWith({ + connected: true, + pages: async () => [{ target: () => ({ createCDPSession: async () => ({ send }) }) }] + }) + await throttleCPU.call(browser, 2) + await throttleNetwork.call(browser, 'online') + expect(send.mock.calls).toEqual([ + ['Emulation.setCPUThrottlingRate', { rate: 2 }], + ['Network.emulateNetworkConditions', { + offline: false, latency: 0, downloadThroughput: -1, uploadThroughput: -1 + }] + ]) + }) +}) diff --git a/packages/webdriverio/tests/commands/browser/setCookies.test.ts b/packages/webdriverio/tests/commands/browser/setCookies.test.ts index e08e8dfe094..b75eec7de42 100644 --- a/packages/webdriverio/tests/commands/browser/setCookies.test.ts +++ b/packages/webdriverio/tests/commands/browser/setCookies.test.ts @@ -103,7 +103,8 @@ describe('setCookies', () => { storageSetCookie.mockImplementation((() => {}) as any) }) - beforeEach(() => { + beforeEach(async () => { + await browser.switchWindow('window-handle-2') storageSetCookie.mockClear() }) @@ -111,6 +112,7 @@ describe('setCookies', () => { await browser.setCookies([cookie1]) expect(storageSetCookie).toBeCalledTimes(1) expect(storageSetCookie).toBeCalledWith({ + partition: { type: 'context', context: 'window-handle-2' }, cookie: { domain: 'webdriver.io', name: 'cookie1', @@ -126,6 +128,7 @@ describe('setCookies', () => { await browser.setCookies(cookie1) expect(storageSetCookie).toBeCalledTimes(1) expect(storageSetCookie).toBeCalledWith({ + partition: { type: 'context', context: 'window-handle-2' }, cookie: { domain: 'webdriver.io', name: 'cookie1', @@ -140,6 +143,27 @@ describe('setCookies', () => { it('can be called multiple times', async () => { await browser.setCookies([cookie1, cookie2, cookie3]) expect(storageSetCookie).toBeCalledTimes(3) + for (let i = 1; i <= 3; i++) { + expect(storageSetCookie).toHaveBeenNthCalledWith(i, expect.objectContaining({ + partition: { type: 'context', context: 'window-handle-2' } + })) + } + }) + + it('should use the new partition after switching windows with an explicit domain', async () => { + const cookie = { ...cookie1, domain: 'foobar.com' } + await browser.setCookies(cookie) + await browser.switchWindow('window-handle-3') + await browser.setCookies(cookie) + + expect(storageSetCookie).toHaveBeenNthCalledWith(1, { + partition: { type: 'context', context: 'window-handle-2' }, + cookie: { ...cookie, value: { type: 'string', value: cookie.value } } + }) + expect(storageSetCookie).toHaveBeenNthCalledWith(2, { + partition: { type: 'context', context: 'window-handle-3' }, + cookie: { ...cookie, value: { type: 'string', value: cookie.value } } + }) }) }) }) diff --git a/packages/webdriverio/tests/commands/browser/switchWindow.test.ts b/packages/webdriverio/tests/commands/browser/switchWindow.test.ts index 9100455b5a3..a754ca427d9 100644 --- a/packages/webdriverio/tests/commands/browser/switchWindow.test.ts +++ b/packages/webdriverio/tests/commands/browser/switchWindow.test.ts @@ -1,6 +1,7 @@ import path from 'node:path' import { expect, describe, beforeEach, it, vi, beforeAll, afterAll } from 'vitest' import { remote } from '../../../src/index.js' +import { getContextManager } from '../../../src/session/context.js' vi.mock('fetch') vi.mock('@testplane/wdio-logger', () => import(path.join(process.cwd(), '__mocks__', '@testplane/wdio-logger'))) @@ -86,6 +87,23 @@ describe('switchWindow', () => { } }) + it('restores the original window and context when no window matches', async () => { + let activeWindow = 'window-handle-1' + vi.spyOn(browser, 'getWindowHandle').mockImplementation(async () => activeWindow) + vi.spyOn(browser, 'switchToWindow').mockImplementation(async (handle) => { + activeWindow = handle + return null + }) + vi.spyOn(browser, 'getUrl').mockResolvedValue('https://example.org/') + vi.spyOn(browser, 'getTitle').mockResolvedValue('Example') + vi.spyOn(browser, 'execute').mockResolvedValue('example-window') + + await expect(browser.switchWindow('missing-window')).rejects.toThrow('No window found') + + expect(await browser.getWindowHandle()).toBe('window-handle-1') + expect(await getContextManager(browser).getCurrentContext()).toBe('window-handle-1') + }) + it('should fail if parameter is not valid', async () => { // @ts-ignore uses expect-webdriverio expect.hasAssertions() diff --git a/packages/webdriverio/tests/commands/element/__snapshots__/click.test.ts.snap b/packages/webdriverio/tests/commands/element/__snapshots__/click.test.ts.snap index f859db84349..b6a0913ad7b 100644 --- a/packages/webdriverio/tests/commands/element/__snapshots__/click.test.ts.snap +++ b/packages/webdriverio/tests/commands/element/__snapshots__/click.test.ts.snap @@ -17,10 +17,6 @@ exports[`click test > should allow to left click on an element by passing a butt "button": 0, "type": "pointerDown", }, - { - "duration": 0, - "type": "pause", - }, { "button": 0, "type": "pointerUp", @@ -51,10 +47,6 @@ exports[`click test > should allow to left click on an element by passing a butt "button": 0, "type": "pointerDown", }, - { - "duration": 0, - "type": "pause", - }, { "button": 0, "type": "pointerUp", @@ -86,10 +78,6 @@ exports[`click test > should allow to left click on an element with an offset wi "button": 0, "type": "pointerDown", }, - { - "duration": 0, - "type": "pause", - }, { "button": 0, "type": "pointerUp", @@ -121,10 +109,6 @@ exports[`click test > should allow to middle click on an element 1`] = ` "button": 1, "type": "pointerDown", }, - { - "duration": 0, - "type": "pause", - }, { "button": 1, "type": "pointerUp", @@ -155,10 +139,6 @@ exports[`click test > should allow to middle click on an element 2`] = ` "button": 1, "type": "pointerDown", }, - { - "duration": 0, - "type": "pause", - }, { "button": 1, "type": "pointerUp", @@ -189,10 +169,6 @@ exports[`click test > should allow to right click on an element 1`] = ` "button": 2, "type": "pointerDown", }, - { - "duration": 0, - "type": "pause", - }, { "button": 2, "type": "pointerUp", @@ -223,10 +199,6 @@ exports[`click test > should allow to right click on an element 2`] = ` "button": 2, "type": "pointerDown", }, - { - "duration": 0, - "type": "pause", - }, { "button": 2, "type": "pointerUp", diff --git a/packages/webdriverio/tests/commands/element/click.test.ts b/packages/webdriverio/tests/commands/element/click.test.ts index a372d2ea66a..f8c7b11d18c 100644 --- a/packages/webdriverio/tests/commands/element/click.test.ts +++ b/packages/webdriverio/tests/commands/element/click.test.ts @@ -128,11 +128,10 @@ describe('click test', () => { .toBe(20) expect(JSON.parse(vi.mocked(fetch).mock.calls[3][1]?.body as any).actions[0].actions[0].y) .toBe(15) - expect(JSON.parse(vi.mocked(fetch).mock.calls[3][1]?.body as any).actions[0].actions[2]) - .toStrictEqual({ type: 'pause', duration: 0 }) + expect(JSON.parse(vi.mocked(fetch).mock.calls[3][1]?.body as any).actions[0].actions).toHaveLength(3) expect(JSON.parse(vi.mocked(fetch).mock.calls[3][1]?.body as any).actions[0].actions[1]) .toMatchSnapshot() - expect(JSON.parse(vi.mocked(fetch).mock.calls[3][1]?.body as any).actions[0].actions[3]) + expect(JSON.parse(vi.mocked(fetch).mock.calls[3][1]?.body as any).actions[0].actions[2]) .toStrictEqual({ type: 'pointerUp', button: 2 }) }) @@ -326,7 +325,7 @@ describe('click test', () => { const elem = await browser.$('#foo') // @ts-expect-error invalid param - expect(elem.click({ button: 'not-suppported' })).rejects.toThrow('Button type not suppported.') + expect(elem.click({ button: 'not-suppported' })).rejects.toThrow('Button type not supported.') }) it('should not call releaseAction when skipRelease is true', async () => { diff --git a/packages/webdriverio/tests/commands/element/getHTML.test.ts b/packages/webdriverio/tests/commands/element/getHTML.test.ts index a648f98da53..1071777fedc 100644 --- a/packages/webdriverio/tests/commands/element/getHTML.test.ts +++ b/packages/webdriverio/tests/commands/element/getHTML.test.ts @@ -27,7 +27,7 @@ describe('getHTML test', () => { .toBe('/session/foobar-123/execute/sync') expect(result).toBe('outer html') - result = await elem.getHTML({ includeSelectorTag: false }) + result = await elem.getHTML(false) expect(result).toBe('some inner html') }) diff --git a/packages/webdriverio/tests/commands/element/isDisplayed.test.ts b/packages/webdriverio/tests/commands/element/isDisplayed.test.ts index a5952c3c034..c55e643f742 100644 --- a/packages/webdriverio/tests/commands/element/isDisplayed.test.ts +++ b/packages/webdriverio/tests/commands/element/isDisplayed.test.ts @@ -38,12 +38,9 @@ describe('isDisplayed test', () => { fetch.customResponseFor(/\/css\/display/, { value: 'block' }) expect(await elem.isDisplayed()).toBe(true) - /** - * expect fetch to be called for - * - isElementDisplayed script - * - getCSSProperty for display property - */ - expect(fetch).toBeCalledTimes(2) + // This fork deliberately uses the v8 visibility script without an + // additional CSS/checkVisibility probe (commit 8cf366b0d). + expect(fetch).toBeCalledTimes(1) // @ts-expect-error mock implementation expect(vi.mocked(fetch).mock.calls[0][0]!.pathname) @@ -54,11 +51,11 @@ describe('isDisplayed test', () => { }) }) - it('should allow to check if element is displayed within viewport', async () => { + it('keeps the legacy DOM script when given the v9 viewport option', async () => { // @ts-expect-error mock feature fetch.customResponseFor(/\/css\/display/, { value: 'block' }) expect(await elem.isDisplayed({ withinViewport: true })).toBe(true) - expect(fetch).toBeCalledTimes(3) + expect(fetch).toBeCalledTimes(1) // @ts-expect-error mock implementation expect(vi.mocked(fetch).mock.calls[0][0]!.pathname) .toBe('/session/foobar-123/execute/sync') @@ -66,27 +63,19 @@ describe('isDisplayed test', () => { ELEMENT: 'some-elem-123', 'element-6066-11e4-a52e-4f735466cecf': 'some-elem-123', }) - // @ts-expect-error mock implementation - expect(vi.mocked(fetch).mock.calls[2][0]!.pathname) - .toBe('/session/foobar-123/execute/sync') - expect(JSON.parse(vi.mocked(fetch).mock.calls[2][1]?.body as any).args[0]).toEqual({ - ELEMENT: 'some-elem-123', - 'element-6066-11e4-a52e-4f735466cecf': 'some-elem-123', - }) + }) it('should use legacy script if element has display: contents set', async () => { // @ts-expect-error mock feature fetch.customResponseFor(/\/css\/display/, { value: 'contents' }) expect(await elem.isDisplayed()).toBe(true) - expect(fetch).toBeCalledTimes(3) + expect(fetch).toBeCalledTimes(1) // @ts-expect-error mock implementation expect(vi.mocked(fetch).mock.calls[0][0]!.pathname) .toBe('/session/foobar-123/execute/sync') - // @ts-expect-error mock implementation - expect(vi.mocked(fetch).mock.calls[2][0]!.pathname) - .toBe('/session/foobar-123/execute/sync') + }) it('should allow to check if element is displayed in mobile mode without browserName', async () => { @@ -129,7 +118,7 @@ describe('isDisplayed test', () => { // @ts-ignore test scenario delete elem.elementId expect(await elem.isDisplayed()).toBe(true) - expect(fetch).toBeCalledTimes(4) + expect(fetch).toBeCalledTimes(2) // @ts-expect-error mock implementation expect(vi.mocked(fetch).mock.calls[0][0]!.pathname) .toBe('/session/foobar-123/element') diff --git a/packages/webdriverio/tests/module.test.ts b/packages/webdriverio/tests/module.test.ts index 2dd4663d2a3..014208a06b3 100644 --- a/packages/webdriverio/tests/module.test.ts +++ b/packages/webdriverio/tests/module.test.ts @@ -9,7 +9,7 @@ import { remote, multiremote, attach, Key, SevereServiceError } from '../src/ind vi.mock('../src/utils/detectBackend', () => ({ default: vi.fn() })) vi.mock('@testplane/wdio-logger', () => import(path.join(process.cwd(), '__mocks__', '@testplane/wdio-logger'))) -vi.mock('webdriver', () => { +vi.mock('@testplane/webdriver', () => { const client = { sessionId: 'foobar-123', options: {}, @@ -40,7 +40,7 @@ vi.mock('webdriver', () => { } }) -vi.mock('devtools', () => { +vi.mock('@testplane/devtools', () => { const client = { sessionId: 'foobar-123', isDevTools: true } const newSessionMock = vi.fn() newSessionMock.mockReturnValue(new Promise((resolve) => resolve(client))) diff --git a/packages/webdriverio/tests/scripts/customElement.test.ts b/packages/webdriverio/tests/scripts/customElement.test.ts new file mode 100644 index 00000000000..87117755126 --- /dev/null +++ b/packages/webdriverio/tests/scripts/customElement.test.ts @@ -0,0 +1,200 @@ +import { JSDOM } from 'jsdom' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import customElementWrapper from '../../src/scripts/customElement.js' + +describe('customElementWrapper', () => { + let dom: JSDOM + let nativeAttachShadow: typeof Element.prototype.attachShadow + + beforeEach(() => { + dom = new JSDOM('') + for (const name of ['customElements', 'Element', 'HTMLElement', 'document', 'MutationObserver', 'ShadowRoot']) { + vi.stubGlobal(name, dom.window[name as keyof typeof dom.window]) + } + vi.spyOn(console, 'debug').mockImplementation(() => {}) + nativeAttachShadow = Element.prototype.attachShadow + customElementWrapper() + }) + + afterEach(() => { + dom.window.close() + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it.each(['open', 'closed'] as const)('registers a native %s root when its existing host is upgraded', (mode) => { + const component = document.createElement('native-component') + nativeAttachShadow.call(component, { mode }) + document.body.appendChild(component) + class NativeComponent extends HTMLElement {} + customElements.define('native-component', NativeComponent) + expect(console.debug).toHaveBeenCalledWith( + '[WDIO]', 'newShadowRoot', component, document, true, document.documentElement + ) + expect(console.debug).toHaveBeenCalledTimes(1) + }) + + it('registers a native closed parent before children inserted during its upgrade', () => { + const parent = document.createElement('native-parent') + const parentRoot = nativeAttachShadow.call(parent, { mode: 'closed' }) + const child = document.createElement('span') + child.attachShadow({ mode: 'closed' }) + document.body.appendChild(parent) + class NativeParent extends HTMLElement { + connectedCallback () { + parentRoot.appendChild(child) + // Synchronously attach a grandchild while the parent's callback runs. + const grandchild = document.createElement('div') + parentRoot.appendChild(grandchild) + grandchild.attachShadow({ mode: 'closed' }) + } + } + customElements.define('native-parent', NativeParent) + expect(vi.mocked(console.debug).mock.calls[0]).toEqual([ + '[WDIO]', 'newShadowRoot', parent, document, true, document.documentElement + ]) + }) + + it('reports a root attached after a rootless custom host was connected', () => { + class LateComponent extends HTMLElement {} + customElements.define('late-component', LateComponent) + const component = new LateComponent() + document.body.appendChild(component) + vi.mocked(console.debug).mockClear() + component.attachShadow({ mode: 'closed' }) + expect(console.debug).toHaveBeenCalledWith( + '[WDIO]', 'newShadowRoot', component, document, true, document.documentElement + ) + expect(console.debug).toHaveBeenCalledTimes(1) + }) + + it('registers a constructor-created shadow root only when its host is connected', () => { + class DetachedComponent extends HTMLElement { + constructor () { + super() + this.attachShadow({ mode: 'closed' }) + } + } + customElements.define('detached-component', DetachedComponent) + const component = new DetachedComponent() + expect(console.debug).not.toHaveBeenCalled() + + document.body.appendChild(component) + expect(console.debug).toHaveBeenCalledWith( + '[WDIO]', 'newShadowRoot', component, document, true, document.documentElement + ) + expect(console.debug).toHaveBeenCalledTimes(1) + }) + + it('registers a detached regular host after it is inserted into the document', async () => { + const component = document.createElement('div') + component.attachShadow({ mode: 'closed' }) + expect(console.debug).not.toHaveBeenCalled() + document.body.appendChild(component) + await Promise.resolve() + expect(console.debug).toHaveBeenCalledWith( + '[WDIO]', 'newShadowRoot', component, document, true, document.documentElement + ) + }) + + it('registers a shadow root created inside connectedCallback exactly once', () => { + class ConnectedComponent extends HTMLElement { + connectedCallback () { + this.attachShadow({ mode: 'open' }) + } + } + customElements.define('connected-component', ConnectedComponent) + const component = new ConnectedComponent() + document.body.appendChild(component) + expect(console.debug).toHaveBeenCalledTimes(1) + expect(console.debug).toHaveBeenCalledWith( + '[WDIO]', 'newShadowRoot', component, document, true, document.documentElement + ) + }) + + it('registers a detached host inserted into an existing closed shadow tree', async () => { + const parent = document.createElement('div') + document.body.appendChild(parent) + const parentRoot = parent.attachShadow({ mode: 'closed' }) + const child = document.createElement('span') + child.attachShadow({ mode: 'closed' }) + vi.mocked(console.debug).mockClear() + parentRoot.appendChild(child) + await Promise.resolve() + expect(console.debug).toHaveBeenCalledWith( + '[WDIO]', 'newShadowRoot', child, parentRoot, false, document.documentElement + ) + expect(console.debug).toHaveBeenCalledTimes(1) + }) + + it('registers detached parent shadow trees before their nested hosts', async () => { + const child = document.createElement('span') + child.attachShadow({ mode: 'closed' }) + const parent = document.createElement('div') + const parentRoot = parent.attachShadow({ mode: 'closed' }) + parentRoot.appendChild(child) + document.body.appendChild(parent) + await Promise.resolve() + expect(vi.mocked(console.debug).mock.calls.map((args) => args[2])).toEqual([parent, child]) + }) + + it('registers a custom host again after it disconnects and reconnects', () => { + class ReconnectedComponent extends HTMLElement { + constructor () { + super() + this.attachShadow({ mode: 'closed' }) + } + } + customElements.define('reconnected-component', ReconnectedComponent) + const component = new ReconnectedComponent() + document.body.appendChild(component) + component.remove() + document.body.appendChild(component) + expect(vi.mocked(console.debug).mock.calls.map((args) => args[1])).toEqual([ + 'newShadowRoot', 'removeShadowRoot', 'newShadowRoot' + ]) + }) + + it('continues registering connected hosts when a pending weak reference was collected', async () => { + // Model collection deterministically: do not depend on when the VM runs GC. + const pendingReferences: { target?: HTMLElement }[] = [] + vi.stubGlobal('WeakRef', class { + target?: HTMLElement + constructor (target: HTMLElement) { + this.target = target + pendingReferences.push(this) + } + deref () { + return this.target + } + }) + const discarded = document.createElement('div') + discarded.attachShadow({ mode: 'closed' }) + for (const reference of pendingReferences) { + reference.target = undefined + } + // A collected host must never be queried again by the observer. + Object.defineProperty(discarded, 'isConnected', { + get () { throw new Error('queried a collected host') } + }) + const connected = document.createElement('span') + connected.attachShadow({ mode: 'closed' }) + document.body.appendChild(connected) + await Promise.resolve() + expect(console.debug).toHaveBeenCalledWith( + '[WDIO]', 'newShadowRoot', connected, document, true, document.documentElement + ) + expect(console.debug).toHaveBeenCalledTimes(1) + }) + + it('registers shadow roots attached to connected regular elements', () => { + const component = document.createElement('div') + document.body.appendChild(component) + const root = component.attachShadow({ mode: 'open' }) + expect(component.shadowRoot).toBe(root) + expect(console.debug).toHaveBeenCalledWith( + '[WDIO]', 'newShadowRoot', component, document, true, document.documentElement + ) + }) +}) diff --git a/packages/webdriverio/tests/session/context.test.ts b/packages/webdriverio/tests/session/context.test.ts new file mode 100644 index 00000000000..ef5a2415ceb --- /dev/null +++ b/packages/webdriverio/tests/session/context.test.ts @@ -0,0 +1,80 @@ +import path from 'node:path' +import { EventEmitter } from 'node:events' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import logger from '@testplane/wdio-logger' + +import { ContextManager } from '../../src/session/context.js' + +vi.mock('@testplane/wdio-logger', () => import(path.join(process.cwd(), '__mocks__', '@testplane/wdio-logger'))) + +const log = logger('webdriverio:context') + +describe('ContextManager command results', () => { + afterEach(() => vi.unstubAllEnvs()) + + function setup() { + vi.stubEnv('WDIO_UNIT_TESTS', '') + const browser = Object.assign(new EventEmitter(), { + sessionId: 'context-session', + isBidi: true, + capabilities: {}, + sessionSubscribe: vi.fn().mockResolvedValue({}), + switchToWindow: vi.fn().mockResolvedValue(null), + browsingContextGetTree: vi.fn().mockResolvedValue({ contexts: [{ context: 'other-window', children: [] }] }) + }) as unknown as WebdriverIO.Browser + const manager = new ContextManager(browser) + manager.setCurrentContext('original-window') + return { browser, manager } + } + + it('does not switch context before switchToWindow succeeds', async () => { + const { browser, manager } = setup() + browser.emit('command', { command: 'switchToWindow', body: { handle: 'missing-window' } }) + expect(await manager.getCurrentContext()).toBe('original-window') + browser.emit('result', { + command: 'switchToWindow', body: { handle: 'missing-window' }, + result: { error: new Error('no such window') } + }) + expect(await manager.getCurrentContext()).toBe('original-window') + manager.removeListeners() + }) + + it('updates context from the successful switchToWindow result', async () => { + const { browser, manager } = setup() + browser.emit('result', { + command: 'switchToWindow', body: { handle: 'other-window' }, result: { value: null } + }) + expect(await manager.getCurrentContext()).toBe('other-window') + manager.removeListeners() + }) + + it('does not automatically switch windows after closeWindow', async () => { + const { browser, manager } = setup() + browser.emit('result', { command: 'closeWindow', result: { value: ['other-window'] } }) + expect(await manager.getCurrentContext()).toBe('original-window') + expect(browser.switchToWindow).not.toHaveBeenCalled() + manager.removeListeners() + }) + + it('reports navigation failures without poisoning the current context', async () => { + const { browser, manager } = setup() + const error = new Error('no such window') + vi.mocked(browser.switchToWindow).mockRejectedValueOnce(error) + browser.emit('browsingContext.navigationStarted', { context: 'other-window' }) + await vi.waitFor(() => expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('navigation'), error)) + expect(await manager.getCurrentContext()).toBe('original-window') + manager.removeListeners() + }) + + it('reports context tree failures from navigation events', async () => { + const { browser, manager } = setup() + const error = new Error('session deleted') + vi.mocked(browser.browsingContextGetTree).mockRejectedValueOnce(error) + browser.emit('browsingContext.navigationStarted', { context: 'other-window' }) + await vi.waitFor(() => expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('navigation'), error)) + expect(await manager.getCurrentContext()).toBe('original-window') + manager.removeListeners() + }) + +}) diff --git a/packages/webdriverio/tests/session/dialog.test.ts b/packages/webdriverio/tests/session/dialog.test.ts new file mode 100644 index 00000000000..caf05bc73a5 --- /dev/null +++ b/packages/webdriverio/tests/session/dialog.test.ts @@ -0,0 +1,60 @@ +import { EventEmitter } from 'node:events' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { DialogManager } from '../../src/session/dialog.js' +import webdriverMonad from '../../../wdio-utils/src/monad.js' +import { logMock } from '../../../../__mocks__/@testplane/wdio-logger.js' + +vi.mock('@testplane/wdio-logger', () => import(path.join(process.cwd(), '__mocks__', '@testplane/wdio-logger'))) + +function createBrowser (implementation: string) { + const browser = implementation === 'WebDriver monad' ? webdriverMonad({})('dialog-session') : new EventEmitter() + return Object.assign(browser, { + isBidi: true, + sessionId: 'dialog-session', + sessionSubscribe: vi.fn().mockResolvedValue({}), + browsingContextHandleUserPrompt: vi.fn().mockResolvedValue({}) + }) as unknown as WebdriverIO.Browser +} + +const prompt = { context: 'tab', type: 'alert', message: 'hello' } + +describe.each(['EventEmitter', 'WebDriver monad'])('DialogManager with %s', (implementation) => { + beforeEach(() => { + vi.stubEnv('WDIO_UNIT_TESTS', '') + vi.clearAllMocks() + }) + afterEach(() => vi.unstubAllEnvs()) + + it('uses actual listeners, including once and multiple listeners', async () => { + const browser = createBrowser(implementation) + const manager = new DialogManager(browser) + await manager.initialize() + const first = vi.fn() + const second = vi.fn() + browser.on('dialog', first) + browser.once('dialog', second) + browser.emit('browsingContext.userPromptOpened', prompt) + expect(first).toHaveBeenCalledOnce() + expect(second).toHaveBeenCalledOnce() + expect(browser.browsingContextHandleUserPrompt).not.toHaveBeenCalled() + browser.emit('browsingContext.userPromptOpened', prompt) + expect(first).toHaveBeenCalledTimes(2) + expect(second).toHaveBeenCalledOnce() + browser.off('dialog', first) + browser.emit('browsingContext.userPromptOpened', prompt) + expect(browser.browsingContextHandleUserPrompt).toHaveBeenCalledWith({ context: 'tab', accept: false }) + manager.removeListeners() + }) + + it('handles auto-dismiss rejection without an unhandled promise and reports unexpected errors', async () => { + const browser = createBrowser(implementation) + const manager = new DialogManager(browser) + await manager.initialize() + vi.mocked(browser.browsingContextHandleUserPrompt).mockRejectedValue(new Error('connection lost')) + browser.emit('browsingContext.userPromptOpened', prompt) + await vi.waitFor(() => expect(logMock.warn).toHaveBeenCalledWith(expect.stringContaining('connection lost'))) + manager.removeListeners() + }) +}) diff --git a/packages/webdriverio/tests/session/lifecycle.test.ts b/packages/webdriverio/tests/session/lifecycle.test.ts new file mode 100644 index 00000000000..54c5dfa5f61 --- /dev/null +++ b/packages/webdriverio/tests/session/lifecycle.test.ts @@ -0,0 +1,65 @@ +import { EventEmitter } from 'node:events' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { SessionManager } from '../../src/session/session.js' +import { ContextManager } from '../../src/session/context.js' +import { DialogManager } from '../../src/session/dialog.js' +import { NetworkManager } from '../../src/session/networkManager.js' +import { PolyfillManager } from '../../src/session/polyfill.js' +import { ShadowRootManager } from '../../src/session/shadowRoot.js' + +function createBrowser() { + return Object.assign(new EventEmitter(), { + sessionId: 'lifecycle-session', + isBidi: true, + capabilities: {}, + sessionSubscribe: vi.fn().mockResolvedValue({}), + browsingContextGetTree: vi.fn().mockResolvedValue({ contexts: [] }), + scriptAddPreloadScript: vi.fn().mockResolvedValue({ script: 'script-id' }), + scriptCallFunction: vi.fn().mockResolvedValue({ result: { type: 'undefined' } }) + }) as unknown as WebdriverIO.Browser +} + +describe('session manager listener lifecycle', () => { + afterEach(() => vi.unstubAllEnvs()) + + it.each([ContextManager, DialogManager, NetworkManager, PolyfillManager, ShadowRootManager].map((Manager) => ({ name: Manager.name, Manager })))( + '$name removes only its own listeners across repeated sessions', async ({ Manager }) => { + vi.stubEnv('WDIO_UNIT_TESTS', '') + const browser = createBrowser() + const userListener = vi.fn() + browser.on('command', userListener) + // @ts-expect-error private event may also have listeners owned by other integrations + browser.on('_dialogListenerRegistered', userListener) + for (let i = 0; i < 3; i++) { + const manager = SessionManager.getSessionManager(browser, Manager) + await Promise.resolve() + browser.emit('command', { command: 'deleteSession' }) + expect(browser.listeners('command')).toEqual([userListener]) + expect(browser.listeners('_dialogListenerRegistered')).toEqual([userListener]) + expect(browser.eventNames().sort()).toEqual(['_dialogListenerRegistered', 'command']) + expect(SessionManager.getSessionManager(browser, Manager)).not.toBe(manager) + browser.emit('command', { command: 'deleteSession' }) + browser.sessionId = `reloaded-session-${i}` + } + } + ) + + it('cleans up managers for separate browser objects sharing a session id', () => { + class Manager extends SessionManager { + constructor(browser: WebdriverIO.Browser) { + super(browser, Manager.name) + } + } + const firstBrowser = createBrowser() + const secondBrowser = createBrowser() + const first = SessionManager.getSessionManager(firstBrowser, Manager) + const second = SessionManager.getSessionManager(secondBrowser, Manager) + firstBrowser.emit('command', { command: 'deleteSession' }) + secondBrowser.emit('command', { command: 'deleteSession' }) + expect(SessionManager.getSessionManager(firstBrowser, Manager)).not.toBe(first) + expect(SessionManager.getSessionManager(secondBrowser, Manager)).not.toBe(second) + firstBrowser.emit('command', { command: 'deleteSession' }) + secondBrowser.emit('command', { command: 'deleteSession' }) + }) +}) diff --git a/packages/webdriverio/tests/session/polyfill.test.ts b/packages/webdriverio/tests/session/polyfill.test.ts index 6a4a3176b3d..df05ff8b156 100644 --- a/packages/webdriverio/tests/session/polyfill.test.ts +++ b/packages/webdriverio/tests/session/polyfill.test.ts @@ -2,6 +2,7 @@ import path from 'node:path' import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest' +import { logMock } from '../../../../__mocks__/@testplane/wdio-logger.js' import { PolyfillManager, polyfillFn } from '../../src/session/polyfill.js' import { // @ts-expect-error mock @@ -85,4 +86,21 @@ describe('PolyfillManager', () => { expect(browser.scriptCallFunction).toBeCalledTimes(2) expect(await manager.initialize()).toBe(true) }) + it('tolerates a context destroyed before its preload registration completes', async () => { + const manager = new PolyfillManager(browser) + await manager.initialize() + vi.mocked(browser.scriptAddPreloadScript).mockRejectedValueOnce(new Error( + 'WebDriver Bidi command "script.addPreloadScript" failed with error: no such frame - context destroyed' + )) + await expect(browser.on.mock.calls[0][1]({ context: 'closed-tab' })).resolves.toBeUndefined() + }) + + it('reports an unexpected event-driven registration error without rejecting the event handler', async () => { + const manager = new PolyfillManager(browser) + await manager.initialize() + vi.mocked(browser.scriptAddPreloadScript).mockRejectedValueOnce(new Error('connection lost')) + await expect(browser.on.mock.calls[0][1]({ context: 'new-tab' })).resolves.toBeUndefined() + expect(logMock.warn).toHaveBeenCalledWith(expect.stringContaining('connection lost')) + }) + }) diff --git a/packages/webdriverio/tests/session/session.test.ts b/packages/webdriverio/tests/session/session.test.ts index 940fd067e3b..56acda3f818 100644 --- a/packages/webdriverio/tests/session/session.test.ts +++ b/packages/webdriverio/tests/session/session.test.ts @@ -4,13 +4,13 @@ import { SessionManager } from '../../src/session/session.js' import { ContextManager } from '../../src/session/context.js' describe('SessionManager', () => { - const browser ={ - sessionId: '123', - on: vi.fn(), - } as unknown as WebdriverIO.Browser + let browser: WebdriverIO.Browser - beforeEach(()=>{ - vi.mocked(browser.on).mockClear() + beforeEach(() => { + browser = { + sessionId: '123', + on: vi.fn(), + } as unknown as WebdriverIO.Browser }) it('should listener registered', ()=>{ diff --git a/packages/webdriverio/tests/utils.test.ts b/packages/webdriverio/tests/utils.test.ts index 1fb138c5f93..b44d4abe7f4 100644 --- a/packages/webdriverio/tests/utils.test.ts +++ b/packages/webdriverio/tests/utils.test.ts @@ -1,4 +1,5 @@ import path from 'node:path' +import type http from 'node:http' import { ELEMENT_KEY } from '@testplane/webdriver' import { describe, expect, it, vi, beforeEach } from 'vitest' @@ -14,11 +15,13 @@ import { validateUrl } from '../src/utils/index.js' -vi.mock('http', () => { +vi.mock('http', async (importOriginal) => { + const actual = await importOriginal() const req = { on: vi.fn(), end: vi.fn() } let response = { statusCode: 200 } return { default: { + ...actual, setResponse: (data: any) => { response = data }, diff --git a/packages/webdriverio/tests/utils/interception/bidi.test.ts b/packages/webdriverio/tests/utils/interception/bidi.test.ts new file mode 100644 index 00000000000..1cb102b16f5 --- /dev/null +++ b/packages/webdriverio/tests/utils/interception/bidi.test.ts @@ -0,0 +1,471 @@ +import { EventEmitter } from 'node:events' +import { describe, expect, it, vi } from 'vitest' +import BidiInterception from '../../../src/utils/interception/bidi.js' + +function browserMock (browserName = 'chrome') { + let id = 0 + return Object.assign(new EventEmitter(), { + sessionId: 'session', + isFirefox: browserName === 'firefox', + options: { waitforTimeout: 20, waitforInterval: 5 }, + call: (fn: () => unknown) => fn(), + sessionSubscribe: vi.fn().mockResolvedValue({}), + networkAddIntercept: vi.fn().mockImplementation(async () => ({ intercept: String(++id) })), + networkContinueRequest: vi.fn().mockResolvedValue({}), + networkFailRequest: vi.fn().mockResolvedValue({}), + networkProvideResponse: vi.fn().mockResolvedValue({}), + networkRemoveIntercept: vi.fn().mockResolvedValue({}), + getWindowHandle: vi.fn().mockResolvedValue('window') + }) +} + +function event (url: string, intercepts = ['1']) { + return { + isBlocked: true, intercepts, + request: { request: 'request', url, method: 'GET', headers: [] }, + response: { status: 200, headers: [] } + } +} + +describe('BiDi interception routing', () => { + it('does not send wildcard components as literal protocol URL parts', async () => { + const browser = browserMock() + await BidiInterception.initiate('**/api', {}, browser as any) + expect(browser.networkAddIntercept).toHaveBeenCalledWith({ + phases: ['beforeRequestSent', 'responseStarted'], + urlPatterns: [{ type: 'pattern' }] + }) + }) + + it('continues unmatched requests captured by a broad protocol intercept', async () => { + const browser = browserMock() + const mock = await BidiInterception.initiate('**/api', {}, browser as any) + mock.respond({ source: 'mock' }) + browser.emit('network.beforeRequestSent', event('https://example.org/other')) + browser.emit('network.responseStarted', event('https://example.org/other')) + expect(browser.networkContinueRequest).toHaveBeenCalledTimes(1) + expect(browser.networkContinueRequest).toHaveBeenCalledWith({ request: 'request' }) + expect(browser.networkProvideResponse).toHaveBeenCalledTimes(1) + expect(browser.networkProvideResponse).toHaveBeenCalledWith({ request: 'request' }) + expect(mock.calls).toEqual([]) + }) + + it('routes overlapping intercepts once to the matching mock', async () => { + const browser = browserMock() + const other = await BidiInterception.initiate('**/other', {}, browser as any) + const mock = await BidiInterception.initiate('**/api', {}, browser as any) + other.respond({ source: 'other' }) + mock.respond({ source: 'mock' }) + browser.emit('network.responseStarted', event('https://example.org/api', ['1', '2'])) + expect(browser.networkProvideResponse).toHaveBeenCalledTimes(1) + expect(browser.networkProvideResponse).toHaveBeenCalledWith({ + request: 'request', body: { type: 'string', value: '{"source":"mock"}' } + }) + expect(other.calls).toHaveLength(0) + await vi.waitFor(() => expect(mock.calls).toHaveLength(1)) + }) + + it('subscribes once per session, including concurrent mocks and a reloaded session', async () => { + const browser = browserMock() + await Promise.all([ + BidiInterception.initiate('**/one', {}, browser as any), + BidiInterception.initiate('**/two', {}, browser as any) + ]) + expect(browser.sessionSubscribe).toHaveBeenCalledTimes(1) + browser.sessionId = 'reloaded' + await BidiInterception.initiate('**/three', {}, browser as any) + expect(browser.sessionSubscribe).toHaveBeenCalledTimes(2) + const anotherBrowser = browserMock() + await BidiInterception.initiate('**/four', {}, anotherBrowser as any) + expect(anotherBrowser.sessionSubscribe).toHaveBeenCalledTimes(1) + }) + it('continues in-flight requests until the intercept has been removed', async () => { + const browser = browserMock() + let finishRemoval!: () => void + browser.networkRemoveIntercept.mockImplementation(() => new Promise(resolve => { finishRemoval = resolve })) + const mock = await BidiInterception.initiate('**/api', {}, browser as any) + mock.respond({ source: 'mock' }) + const restoring = mock.restore() + // Let the public restore command reach the remote-end removal call. + await Promise.resolve() + browser.emit('network.responseStarted', event('https://example.org/api')) + expect(browser.networkProvideResponse).toHaveBeenCalledWith({ request: 'request' }) + finishRemoval() + await restoring + expect(browser.listenerCount('network.beforeRequestSent')).toBe(0) + expect(browser.listenerCount('network.responseStarted')).toBe(0) + expect(() => mock.respond({})).toThrow() + }) + +}) + +describe('BiDi response mocking phases', () => { + it.each(['chrome', 'firefox'])('does not suppress no-such-request failures when fulfilling a response before the request is sent (%s)', async browserName => { + const browser = browserMock(browserName) + const mock = await BidiInterception.initiate('**/api', {}, browser as any) + let reject!: (error: Error) => void + browser.networkProvideResponse.mockImplementation(() => new Promise((_resolve, rejectPromise) => { reject = rejectPromise })) + mock.respond('mock body', { fetchResponse: false }) + const calls = mock.calls + const { response, ...request } = event('https://example.org/api') + browser.emit('network.beforeRequestSent', request) + browser.emit('network.responseStarted', { ...request, response, isBlocked: browserName === 'chrome' }) + const waiting = Promise.resolve(mock.waitForResponse({ timeout: 100 })).then(value => value, error => error) + const error = new Error('WebDriver Bidi command failed: no such request') + reject(error) + const waitResult = await waiting + const restoreResult = await mock.restore().then(value => value, error => error) + expect(waitResult).toMatchObject({ message: expect.stringContaining(error.message) }) + expect(restoreResult).toBe(error) + expect(calls).toEqual([]) + }) + + it.each(['none', 'clear', 'reset'])('does not publish a late Firefox response after its request fulfillment failed (history reset: %s)', async historyReset => { + const browser = browserMock('firefox') + const mock = await BidiInterception.initiate('**/api', {}, browser as any) + const error = new Error('provide failed') + browser.networkProvideResponse.mockRejectedValue(error) + mock.respond('mock body') + const originalCalls = mock.calls + const { response, ...request } = event('https://example.org/api') + browser.emit('network.beforeRequestSent', request) + await vi.waitFor(() => expect(browser.networkFailRequest).toHaveBeenCalledWith({ request: 'request' })) + const calls = historyReset === 'clear' + ? mock.clear().calls + : historyReset === 'reset' ? mock.reset().calls : originalCalls + browser.emit('network.responseStarted', { ...request, response, isBlocked: false }) + await new Promise(resolve => setImmediate(resolve)) + const waitResult = await Promise.resolve(mock.waitForResponse()).then(value => value, error => error) + const restoreResult = await mock.restore().then(value => value, error => error) + expect(waitResult).toBeInstanceOf(Error) + expect(restoreResult).toBe(historyReset === 'none' ? error : mock) + expect(originalCalls).toEqual([]) + expect(calls).toEqual([]) + if (historyReset === 'none') { + expect(() => mock.calls).toThrow(error.message) + } + }) + + it.each(['clear', 'reset'] as const)('retains a request failure that arrives after %s was called while fulfillment was pending', async historyReset => { + const browser = browserMock('firefox') + const mock = await BidiInterception.initiate('**/api', {}, browser as any) + let reject!: (error: Error) => void + browser.networkProvideResponse.mockImplementation(() => new Promise((_resolve, rejectPromise) => { reject = rejectPromise })) + mock.respond('mock body') + const { response, ...request } = event('https://example.org/api') + browser.emit('network.beforeRequestSent', request) + const calls = mock[historyReset]().calls + const error = new Error('provide failed after clearing history') + reject(error) + await vi.waitFor(() => expect(browser.networkFailRequest).toHaveBeenCalledWith({ request: 'request' })) + browser.emit('network.responseStarted', { ...request, response, isBlocked: false }) + await new Promise(resolve => setImmediate(resolve)) + await expect(mock.waitForResponse()).rejects.toThrow(error.message) + await expect(mock.restore()).rejects.toBe(error) + expect(calls).toEqual([]) + }) + + it.each(['chrome', 'firefox'])('does not publish a response before its protocol operation succeeds (%s)', async browserName => { + const browser = browserMock(browserName) + const mock = await BidiInterception.initiate('**/api', {}, browser as any) + let fulfill!: () => void + browser.networkProvideResponse.mockImplementation(() => new Promise(resolve => { fulfill = resolve })) + mock.respond('mock body') + const { response, ...request } = event('https://example.org/api') + if (browserName === 'firefox') { + browser.emit('network.beforeRequestSent', request) + } + browser.emit('network.responseStarted', { ...request, response, isBlocked: browserName === 'chrome' }) + const settled = vi.fn() + const waiting = Promise.resolve(mock.waitForResponse({ timeout: 1000 })).then(settled) + try { + await new Promise(resolve => setTimeout(resolve, 20)) + expect(settled).not.toHaveBeenCalled() + expect(mock.calls).toEqual([]) + } finally { + fulfill() + await waiting + } + expect(settled).toHaveBeenCalledWith(true) + expect(mock.calls).toHaveLength(1) + await mock.restore() + }) + + it.each(['chrome', 'firefox'])('propagates a delayed response failure to an already waiting caller and immediate restore (%s)', async browserName => { + const browser = browserMock(browserName) + const mock = await BidiInterception.initiate('**/api', {}, browser as any) + let reject!: (error: Error) => void + browser.networkProvideResponse.mockImplementation(() => new Promise((_resolve, rejectPromise) => { reject = rejectPromise })) + mock.respond('mock body') + const { response, ...request } = event('https://example.org/api') + if (browserName === 'firefox') { + browser.emit('network.beforeRequestSent', request) + } + browser.emit('network.responseStarted', { ...request, response, isBlocked: browserName === 'chrome' }) + const waiting = Promise.resolve(mock.waitForResponse({ timeout: 1000 })).then(value => value, error => error) + const restoring = mock.restore().then(value => value, error => error) + await new Promise(resolve => setTimeout(resolve, 20)) + const error = new Error('delayed provideResponse failure') + reject(error) + expect(await waiting).toMatchObject({ message: expect.stringContaining(error.message) }) + expect(await restoring).toBe(error) + expect(() => mock.calls).toThrow(error.message) + expect(browser.listenerCount('network.beforeRequestSent')).toBe(0) + expect(browser.listenerCount('network.responseStarted')).toBe(0) + }) + + it('drains requests received while the remote intercept is being removed', async () => { + const browser = browserMock() + const mock = await BidiInterception.initiate('**/api', {}, browser as any) + let remove!: () => void + let reject!: (error: Error) => void + browser.networkRemoveIntercept.mockImplementation(() => new Promise(resolve => { remove = resolve })) + browser.networkProvideResponse.mockImplementation(() => new Promise((_resolve, rejectPromise) => { reject = rejectPromise })) + const settled = vi.fn() + const restoring = mock.restore().then(settled, error => error) + browser.emit('network.responseStarted', event('https://example.org/api')) + remove() + await new Promise(resolve => setTimeout(resolve, 20)) + expect(settled).not.toHaveBeenCalled() + const error = new Error('in-flight continuation failure during restore') + reject(error) + expect(await restoring).toBe(error) + expect(browser.listenerCount('network.beforeRequestSent')).toBe(0) + expect(browser.listenerCount('network.responseStarted')).toBe(0) + }) + + it('publishes only successful concurrent responses while another response is pending or fails', async () => { + const browser = browserMock() + const mock = await BidiInterception.initiate('**/api', {}, browser as any) + let reject!: (error: Error) => void + browser.networkProvideResponse.mockImplementation(({ request }) => request === 'pending' + ? new Promise((_resolve, rejectPromise) => { reject = rejectPromise }) + : Promise.resolve()) + mock.respond('mock body') + const pending = event('https://example.org/api') + pending.request.request = 'pending' + const successful = event('https://example.org/api') + successful.request.request = 'successful' + const calls = mock.calls + browser.emit('network.responseStarted', pending) + browser.emit('network.responseStarted', successful) + await vi.waitFor(() => expect(calls.map(call => call.request.request)).toEqual(['successful'])) + const error = new Error('pending response failed') + reject(error) + await expect(mock.restore()).rejects.toBe(error) + expect(calls.map(call => call.request.request)).toEqual(['successful']) + }) + + it('provides Firefox response bodies before the request is sent', async () => { + const browser = browserMock('firefox') + const first = await BidiInterception.initiate('**/first', {}, browser as any) + const second = await BidiInterception.initiate('**/second', {}, browser as any) + first.respond({ source: 'first' }) + second.respond({ source: 'second' }) + + for (const name of ['first', 'second']) { + const { response, ...request } = event(`https://example.org/${name}`, ['1', '2']) + request.request.request = name + browser.emit('network.beforeRequestSent', request) + expect(browser.networkProvideResponse).toHaveBeenLastCalledWith({ + request: name, body: { type: 'string', value: `{"source":"${name}"}` } + }) + // Firefox responses are observed, not intercepted: intercepting a + // synthetic response races its beforeRequestSent continuation. + browser.emit('network.responseStarted', { ...request, response, isBlocked: false }) + } + + expect(browser.networkContinueRequest).not.toHaveBeenCalled() + expect(browser.networkProvideResponse).toHaveBeenCalledTimes(2) + await vi.waitFor(() => expect(first.calls).toHaveLength(1)) + await vi.waitFor(() => expect(second.calls).toHaveLength(1)) + }) + + it('keeps Chromium default overwrites at responseStarted to retain the real response', async () => { + const browser = browserMock() + const mock = await BidiInterception.initiate('**/api', { statusCode: 201 }, browser as any) + mock.respond('mock body') + const { response, ...request } = event('https://example.org/api') + response.status = 201 + + browser.emit('network.beforeRequestSent', request) + expect(browser.networkContinueRequest).toHaveBeenCalledWith({ request: 'request' }) + expect(browser.networkProvideResponse).not.toHaveBeenCalled() + browser.emit('network.responseStarted', { ...request, response }) + expect(browser.networkProvideResponse).toHaveBeenCalledWith({ + request: 'request', body: { type: 'string', value: 'mock body' } + }) + await vi.waitFor(() => expect(mock.calls).toHaveLength(1)) + }) + + it.each(['chrome', 'firefox'])('honors fetchResponse: false on %s', async browserName => { + const browser = browserMock(browserName) + const mock = await BidiInterception.initiate('**/api', {}, browser as any) + mock.respond('mock body', { fetchResponse: false, statusCode: 202, headers: { 'x-mock': 'yes' } }) + const { response: _response, ...request } = event('https://example.org/api') + + browser.emit('network.beforeRequestSent', request) + expect(browser.networkContinueRequest).not.toHaveBeenCalled() + expect(browser.networkProvideResponse).toHaveBeenCalledWith({ + request: 'request', statusCode: 202, + headers: [{ name: 'x-mock', value: { type: 'string', value: 'yes' } }], + body: { type: 'string', value: 'mock body' } + }) + }) + + it('does not consume the next respondOnce overwrite at the synthetic response phase', async () => { + const browser = browserMock('firefox') + const mock = await BidiInterception.initiate('**/api', {}, browser as any) + mock.respondOnce('first') + mock.respondOnce('second') + const { response, ...request } = event('https://example.org/api') + + browser.emit('network.beforeRequestSent', request) + browser.emit('network.responseStarted', { ...request, response, isBlocked: false }) + expect(browser.networkProvideResponse.mock.calls.map(([params]) => params.body?.value)).toEqual(['first']) + request.request = { ...request.request, request: 'next' } + browser.emit('network.beforeRequestSent', request) + expect(browser.networkProvideResponse).toHaveBeenLastCalledWith({ + request: 'next', body: { type: 'string', value: 'second' } + }) + }) + + it('records an unblocked synthetic response without attempting to resume it', async () => { + const browser = browserMock('firefox') + const mock = await BidiInterception.initiate('**/api', {}, browser as any) + mock.respond('mock body') + const { response, ...request } = event('https://example.org/api') + browser.emit('network.beforeRequestSent', request) + browser.emit('network.responseStarted', { ...request, response, isBlocked: false }) + await vi.waitFor(() => expect(mock.calls).toHaveLength(1)) + expect(browser.networkProvideResponse).toHaveBeenCalledTimes(1) + }) + + it('rejects fetchResponse: true on Firefox before accepting a response overwrite', async () => { + const browser = browserMock('firefox') + const mock = await BidiInterception.initiate('**/api', {}, browser as any) + expect(() => mock.respond('mock body', { fetchResponse: true })).toThrow(/Firefox.*fetchResponse/) + browser.emit('network.beforeRequestSent', event('https://example.org/api')) + expect(browser.networkContinueRequest).toHaveBeenCalledWith({ request: 'request' }) + }) + + it.each([{ statusCode: 200 }, { responseHeaders: { 'x-server': 'yes' } }])('rejects response-dependent filters for Firefox body mocking: %j', async filter => { + const browser = browserMock('firefox') + const mock = await BidiInterception.initiate('**/api', filter, browser as any) + expect(() => mock.respond('mock body')).toThrow(/response.*filter/i) + }) + + it('rejects response-dependent status callbacks before request-stage mocking', async () => { + const browser = browserMock('firefox') + const mock = await BidiInterception.initiate('**/api', {}, browser as any) + expect(() => mock.respond('mock body', { statusCode: response => response.response.status })).toThrow(/statusCode.*function/) + }) + + it('does not silently ignore response filters when fetchResponse: false is explicit', async () => { + const browser = browserMock() + const mock = await BidiInterception.initiate('**/api', { statusCode: 200 }, browser as any) + expect(() => mock.respond('mock body', { fetchResponse: false })).toThrow(/response.*filter/i) + }) + + it.each(['respond-first', 'request-first'])('rejects combining request overwrites with request-stage responses (%s)', async order => { + const browser = browserMock('firefox') + const mock = await BidiInterception.initiate('**/api', {}, browser as any) + if (order === 'respond-first') { + mock.respond('mock body') + expect(() => mock.request({ method: 'POST' })).toThrow(/request.*overwrite/i) + } else { + mock.request({ method: 'POST' }) + expect(() => mock.respond('mock body')).toThrow(/request.*overwrite/i) + } + }) + + it('preserves abortOnce precedence without consuming the response overwrite', async () => { + const browser = browserMock('firefox') + const mock = await BidiInterception.initiate('**/api', {}, browser as any) + mock.abortOnce() + mock.respondOnce('mock body') + const { response: _response, ...request } = event('https://example.org/api') + browser.emit('network.beforeRequestSent', request) + expect(browser.networkFailRequest).toHaveBeenCalledWith({ request: 'request' }) + expect(browser.networkProvideResponse).not.toHaveBeenCalled() + request.request = { ...request.request, request: 'next' } + browser.emit('network.beforeRequestSent', request) + expect(browser.networkProvideResponse).toHaveBeenCalledWith({ + request: 'next', body: { type: 'string', value: 'mock body' } + }) + }) + + it('does not apply a newly configured Firefox response to an already sent request', async () => { + const browser = browserMock('firefox') + const mock = await BidiInterception.initiate('**/api', {}, browser as any) + const { response, ...request } = event('https://example.org/api') + browser.emit('network.beforeRequestSent', request) + mock.respondOnce('mock body') + browser.emit('network.responseStarted', { ...request, response }) + expect(browser.networkProvideResponse).toHaveBeenCalledWith({ request: 'request' }) + request.request = { ...request.request, request: 'next' } + browser.emit('network.beforeRequestSent', request) + expect(browser.networkProvideResponse).toHaveBeenLastCalledWith({ + request: 'next', body: { type: 'string', value: 'mock body' } + }) + }) + + it('retains in-flight response ownership when reset removes the configured overwrites', async () => { + const browser = browserMock('firefox') + const mock = await BidiInterception.initiate('**/api', {}, browser as any) + mock.respondOnce('first') + const { response, ...request } = event('https://example.org/api') + browser.emit('network.beforeRequestSent', request) + mock.reset().respondOnce('next') + browser.emit('network.responseStarted', { ...request, response, isBlocked: false }) + expect(browser.networkProvideResponse.mock.calls.map(([params]) => params.body?.value)).toEqual(['first']) + }) + + it.each(['chrome', 'firefox'])('reports asynchronous response failures through the mock rather than an unhandled rejection (%s)', async browserName => { + const browser = browserMock(browserName) + const mock = await BidiInterception.initiate('**/api', {}, browser as any) + browser.networkProvideResponse.mockRejectedValue(new Error('unsupported operation: cannot provide response')) + mock.respond('mock body') + const { response, ...request } = event('https://example.org/api') + browser.emit(browserName === 'firefox' ? 'network.beforeRequestSent' : 'network.responseStarted', { ...request, ...(browserName === 'chrome' ? { response } : {}) }) + + await vi.waitFor(() => expect(browser.networkFailRequest).toHaveBeenCalledWith({ request: 'request' })) + expect(() => mock.calls).toThrow('unsupported operation: cannot provide response') + await expect(mock.waitForResponse()).rejects.toThrow('unsupported operation: cannot provide response') + mock.reset() + expect(mock.calls).toEqual([]) + }) + + it('does not intercept Firefox responses while providing synthetic request-stage responses', async () => { + const browser = browserMock('firefox') + await BidiInterception.initiate('**/api', {}, browser as any) + expect(browser.networkAddIntercept).toHaveBeenCalledWith({ + phases: ['beforeRequestSent'], urlPatterns: [{ type: 'pattern' }] + }) + }) + + it('records matching unblocked Firefox responses while retaining response filters for spies', async () => { + const browser = browserMock('firefox') + const mock = await BidiInterception.initiate('**/api', { statusCode: 200 }, browser as any) + browser.emit('network.responseStarted', { ...event('https://example.org/api'), isBlocked: false }) + browser.emit('network.responseStarted', { ...event('https://example.org/other'), isBlocked: false }) + browser.emit('network.responseStarted', { + ...event('https://example.org/api'), isBlocked: false, response: { status: 404, headers: [] } + }) + await vi.waitFor(() => expect(mock.calls).toHaveLength(1)) + expect(browser.networkProvideResponse).not.toHaveBeenCalled() + await expect(mock.waitForResponse()).resolves.toBe(true) + }) + + it('does not resume an already provided Chromium response even when its event advertises intercepts', async () => { + const browser = browserMock() + const mock = await BidiInterception.initiate('**/api', {}, browser as any) + mock.respondOnce('first', { fetchResponse: false }) + mock.respondOnce('second', { fetchResponse: false }) + const { response, ...request } = event('https://example.org/api') + browser.emit('network.beforeRequestSent', request) + browser.emit('network.responseStarted', { ...request, response }) + expect(browser.networkProvideResponse.mock.calls.map(([params]) => params.body?.value)).toEqual(['first']) + await vi.waitFor(() => expect(mock.calls).toHaveLength(1)) + }) +}) diff --git a/packages/webdriverio/tests/utils/interception/devtools.test.ts b/packages/webdriverio/tests/utils/interception/devtools.test.ts new file mode 100644 index 00000000000..565b2f318c5 --- /dev/null +++ b/packages/webdriverio/tests/utils/interception/devtools.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from 'vitest' +import type { CDPSession, Protocol } from 'puppeteer-core' +import DevtoolsInterception from '../../../src/utils/interception/devtools.js' + +describe('CDP response interception', () => { + it('enriches a raw CDP request in place and fulfills a mocked response', async () => { + const send = vi.fn().mockResolvedValue({ body: '{"original":true}' }) + const client = { send } as unknown as CDPSession + const mock = new DevtoolsInterception('**/api', {}, {} as WebdriverIO.Browser) + mock.respond({ mocked: true }) + const event: Protocol.Fetch.RequestPausedEvent = { + requestId: 'request', frameId: 'frame', resourceType: 'Fetch', + request: { + url: 'https://example.test/api', method: 'GET', headers: {}, + initialPriority: 'High', referrerPolicy: 'no-referrer' + }, + responseStatusCode: 200, + responseHeaders: [{ name: 'Content-Type', value: 'application/json' }] + } + + await DevtoolsInterception.handleRequestInterception(client, new Set([mock]))(event) + + expect(mock.calls).toHaveLength(1) + expect(mock.calls[0]).toBe(event.request) + expect(mock.calls[0]).toMatchObject({ + body: { original: true }, statusCode: 200, + responseHeaders: { 'Content-Type': 'application/json' }, + mockedResponse: '{"mocked":true}' + }) + expect(send).toHaveBeenCalledWith('Fetch.fulfillRequest', { + requestId: 'request', responseCode: 200, + responseHeaders: event.responseHeaders, + body: Buffer.from('{"mocked":true}').toString('base64') + }) + }) +}) diff --git a/packages/webdriverio/tests/utils/interception/index.test.ts b/packages/webdriverio/tests/utils/interception/index.test.ts index 2040222bf3d..7c8b03ed696 100644 --- a/packages/webdriverio/tests/utils/interception/index.test.ts +++ b/packages/webdriverio/tests/utils/interception/index.test.ts @@ -1,6 +1,6 @@ import { EventEmitter } from 'node:events' import { describe, it, expect, vi } from 'vitest' -import WebDriverInterception from '../../../src/utils/interception/index.js' +import WebDriverInterception from '../../../src/utils/interception/bidi.js' describe('WebDriverInterception', () => { it('initiate', async () => { diff --git a/packages/webdriverio/tests/utils/interception/matching.test.ts b/packages/webdriverio/tests/utils/interception/matching.test.ts new file mode 100644 index 00000000000..d335cb8c22e --- /dev/null +++ b/packages/webdriverio/tests/utils/interception/matching.test.ts @@ -0,0 +1,28 @@ +import { spawnSync } from 'node:child_process' +import { describe, expect, it } from 'vitest' +import Interception from '../../../src/utils/interception/index.js' + +describe('request URL matching', () => { + it.each([ + ['https://example.test/api', 'https://example.test/api', true], + ['https://example.test/api', 'https://example.test/other', false], + ['**/api/*', 'https://example.test/api/users', true], + ['**/api/**', 'https://example.test/api/users/42', true], + [/\/api\/\d+$/, 'https://example.test/api/42', true], + [/\/api\/\d+$/, 'https://example.test/api/users', false] + ])('matches %s against %s', (pattern, url, expected) => { + expect(Interception.isMatchingRequest(pattern as string | RegExp, url as string)).toBe(expected) + }) + + it('handles consecutive wildcards from GHSA-3ppc-4f35-3m26 without hanging', () => { + const moduleUrl = new URL('../../../src/utils/interception/index.ts', import.meta.url).href + const result = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '-e', ` + import Interception from ${JSON.stringify(moduleUrl)}; + const result = Interception.isMatchingRequest('*'.repeat(40) + 'b***', 'a'.repeat(80)); + if (result !== false) process.exit(1); + `], { timeout: 5000, encoding: 'utf8' }) + + expect(result.error).toBeUndefined() + expect(result.status, result.stderr).toBe(0) + }, 10000) +}) diff --git a/packages/webdriverio/tests/utils/interception/utils.test.ts b/packages/webdriverio/tests/utils/interception/utils.test.ts index e8ad5d27f20..edb51920d00 100644 --- a/packages/webdriverio/tests/utils/interception/utils.test.ts +++ b/packages/webdriverio/tests/utils/interception/utils.test.ts @@ -92,7 +92,7 @@ describe('getPatternParam', () => { expect(getPatternParam({ protocol: 'https', hostname: 'foo.com', port: '443', pathname: '/bar' } as any, 'pathname')).toBe('/bar') expect(getPatternParam({ protocol: 'https', hostname: 'foo.com', port: '', pathname: '/bar' } as any, 'port')).toBe('443') expect(getPatternParam({ protocol: 'https', hostname: 'foo.com', port: '', pathname: '/bar' } as any, 'pathname')).toBe('/bar') - expect(getPatternParam({ protocol: 'https', hostname: 'foo.com', port: '', pathname: '*' } as any, 'pathname')).toBe('\\*') + expect(getPatternParam({ protocol: 'https', hostname: 'foo.com', port: '', pathname: '*' } as any, 'pathname')).toBe(undefined) expect(getPatternParam({ pathname: '*', hostname: '*' } as any, 'hostname')).toBe(undefined) }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 09518aef20b..5c54828735d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -158,6 +158,9 @@ importers: npm-run-all2: specifier: ^6.2.2 version: 6.2.6 + pkg-pr-new: + specifier: 0.0.88 + version: 0.0.88 puppeteer-core: specifier: ^23.0.2 version: 23.11.1 @@ -507,17 +510,14 @@ importers: specifier: ^4.0.0 version: 4.1.0 puppeteer-core: - specifier: 20.9.0 - version: 20.9.0(encoding@0.1.13)(typescript@5.7.3) + specifier: ^25.10.0 + version: 25.10.0(proxy-agent@6.5.0)(yauzl@3.2.0) query-selector-shadow-dom: specifier: ^1.0.0 version: 1.0.1 ua-parser-js: specifier: ^1.0.37 version: 1.0.40 - uuid: - specifier: ^9.0.0 - version: 9.0.1 which: specifier: ^4.0.0 version: 4.0.0 @@ -525,9 +525,6 @@ importers: '@types/ua-parser-js': specifier: ^0.7.33 version: 0.7.39 - '@types/uuid': - specifier: ^9.0.0 - version: 9.0.8 '@types/which': specifier: ^3.0.3 version: 3.0.4 @@ -917,8 +914,8 @@ importers: specifier: workspace:* version: link:../wdio-utils deepmerge-ts: - specifier: ^7.0.3 - version: 7.1.4 + specifier: ^8.0.2 + version: 8.0.2 glob: specifier: ^10.2.2 version: 10.4.5 @@ -1427,8 +1424,8 @@ importers: packages/wdio-utils: dependencies: '@puppeteer/browsers': - specifier: ^2.2.0 - version: 2.6.1 + specifier: ^3.2.2 + version: 3.2.2(proxy-agent@6.5.0)(yauzl@3.2.0) '@testplane/edgedriver': specifier: ^6.1.4 version: 6.1.4 @@ -1445,8 +1442,8 @@ importers: specifier: ^6.0.0 version: 6.0.0 deepmerge-ts: - specifier: ^7.0.3 - version: 7.1.4 + specifier: ^8.0.2 + version: 8.0.2 get-port: specifier: ^7.0.0 version: 7.1.0 @@ -1515,20 +1512,17 @@ importers: specifier: ^8.5.3 version: 8.5.14 deepmerge-ts: - specifier: ^7.0.3 - version: 7.1.4 + specifier: ^8.0.2 + version: 8.0.2 got: specifier: 12.6.1 version: 12.6.1 ky: - specifier: 0.33.0 - version: 0.33.0 - undici: - specifier: 6.12.0 - version: 6.12.0 + specifier: 0.33.3 + version: 0.33.3 ws: - specifier: ^8.8.0 - version: 8.18.0 + specifier: ^8.21.3 + version: 8.21.3 packages/webdriverio: dependencies: @@ -1596,11 +1590,11 @@ importers: specifier: ^4.2.0 version: 4.2.0 minimatch: - specifier: 9.0.0 - version: 9.0.0 + specifier: ^9.0.9 + version: 9.0.9 puppeteer-core: - specifier: ^20.9.0 - version: 20.9.0(encoding@0.1.13)(typescript@5.7.3) + specifier: ^25.10.0 + version: 25.10.0(proxy-agent@6.5.0)(yauzl@3.2.0) query-selector-shadow-dom: specifier: ^1.0.1 version: 1.0.1 @@ -4708,6 +4702,19 @@ packages: engines: {node: '>=18'} hasBin: true + '@puppeteer/browsers@3.2.2': + resolution: {integrity: sha512-q2BU4YfO9h/Wt7IcWPcggpOOqLk2Tbs1hDwolvKZrweRjy751OJBKMN9zO5bfD0pzU7X/tvKw/exQds4pM/LOg==} + engines: {node: '>=22.12.0'} + hasBin: true + peerDependencies: + proxy-agent: '>=8.0.1' + yauzl: ^2.10.0 || ^3.4.0 + peerDependenciesMeta: + proxy-agent: + optional: true + yauzl: + optional: true + '@rollup/plugin-babel@5.3.1': resolution: {integrity: sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==} engines: {node: '>= 10.0.0'} @@ -6398,6 +6405,10 @@ packages: resolution: {integrity: sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==} engines: {node: '>=12'} + ansi-regex@6.3.0: + resolution: {integrity: sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==} + engines: {node: '>=12'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} @@ -6701,6 +6712,9 @@ packages: brace-expansion@2.0.1: resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + brace-expansion@2.1.4: + resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -7009,6 +7023,12 @@ packages: peerDependencies: devtools-protocol: '*' + chromium-bidi@17.0.2: + resolution: {integrity: sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg==} + engines: {node: '>=20.19.0 <22.0.0 || >=22.12.0'} + peerDependencies: + devtools-protocol: '*' + ci-info@2.0.0: resolution: {integrity: sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==} @@ -7086,6 +7106,10 @@ packages: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} + cliui@9.0.1: + resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} + engines: {node: '>=20'} + clone-deep@4.0.1: resolution: {integrity: sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==} engines: {node: '>=6'} @@ -7685,6 +7709,10 @@ packages: resolution: {integrity: sha512-fxqo6nHGQ9zOVgI4KXqtWXJR/yCLtC7aXIVq+6jc8tHPFUxlFmuUcm2kC4vztQ+LJxQ3gER/XAWearGYQ8niGA==} engines: {node: '>=16.0.0'} + deepmerge-ts@8.0.2: + resolution: {integrity: sha512-uqbvqLUMrc6p0MO+WBRtTxY55hmyh94WRwI5a++PZe54X+bfVh59FSN7uWCBCW1CCVjzjnrwzfI8zidE2obMMw==} + engines: {node: '>=16.9.0'} + deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} @@ -7812,6 +7840,9 @@ packages: devtools-protocol@0.0.1415363: resolution: {integrity: sha512-5AmTnmdlfA3cz0aGGD54zy6R/u+ql1uOiQPBNThRNjfZ8on9u4t8ZKPtyehkArBbOtrxOsOUwFRQvTu3l9rq/w==} + devtools-protocol@0.0.1666840: + resolution: {integrity: sha512-gCcO42XCHKEs7Ag0S7aGYsnJ7hlgrO3qderYqeiY0Eqk+0GFfuvT13IA0hHreJTa2KCdDVyGMeOhdMNmrrTjVg==} + devtools@8.42.0: resolution: {integrity: sha512-Y9LRUJlGI0wjXLbeU6TEHufF9HnG2H22+/EABD0KtHlJt5AIRQnTGi8uLAJsE1aeQMF1YXd8l7ExaxBkfEBq8w==} engines: {node: ^16.13 || >=18} @@ -8792,6 +8823,10 @@ packages: resolution: {integrity: sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==} engines: {node: '>=18'} + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + get-intrinsic@1.2.6: resolution: {integrity: sha512-qxsEs+9A+u85HhllWJJFicJfPDhRmjzoYdl64aMWW9yRIJmSyxdn8IEkuIM530/7T+lv0TIHd8L6Q/ra0tEoeA==} engines: {node: '>= 0.4'} @@ -10024,10 +10059,6 @@ packages: kolorist@1.8.0: resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} - ky@0.33.0: - resolution: {integrity: sha512-peKzuOlN/q3Q3jOgi4t0cp6DOgif5rVnmiSIsjsmkiOcdnSjkrKSUqQmRWYCTqjUtR9b3xQQr8aj7KwSW1r49A==} - engines: {node: '>=14.16'} - ky@0.33.3: resolution: {integrity: sha512-CasD9OCEQSFIam2U8efFK81Yeg8vNMTBUqtMOHlrcWQHqUX3HeCl9Dr31u4toV7emlH8Mymk5+9p0lL6mKb/Xw==} engines: {node: '>=14.16'} @@ -10673,10 +10704,6 @@ packages: resolution: {integrity: sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA==} engines: {node: '>=16 || 14 >=14.17'} - minimatch@9.0.0: - resolution: {integrity: sha512-0jJj8AvgKqWN05mrwuqi8QYKx1WmYSUoKSxu5Qhs9prezTz10sxAHGNZe9J9cqIJzta8DWsleh2KaVaLl6Ru2w==} - engines: {node: '>=16 || 14 >=14.17'} - minimatch@9.0.1: resolution: {integrity: sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==} engines: {node: '>=16 || 14 >=14.17'} @@ -10689,6 +10716,10 @@ packages: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + minimist-options@4.1.0: resolution: {integrity: sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==} engines: {node: '>= 6'} @@ -10781,6 +10812,10 @@ packages: peerDependencies: esbuild: ^0.14.0 || ^0.15.0 || ^0.16.0 || ^0.17.0 || ^0.18.0 + modern-tar@0.8.5: + resolution: {integrity: sha512-snEhs+6G5Tjd4I7tLCDOaoln2RgE0bD19RzEKgvgK2hZ5VKy3MpLhLTZ2fWpXSTg4K2cyPwp+VHATFJhxfnOeA==} + engines: {node: '>=18.0.0'} + modify-values@1.0.1: resolution: {integrity: sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw==} engines: {node: '>=0.10.0'} @@ -11445,6 +11480,10 @@ packages: resolution: {integrity: sha512-Ie9z/WINcxxLp27BKOCHGde4ITq9UklYKDzVo1nhk5sqGEXU3FpkwP5GM2voTGJkGd9B3Otl+Q4uwSOeSUtOBA==} engines: {node: '>=14.16'} + pkg-pr-new@0.0.88: + resolution: {integrity: sha512-Xc6PMJ2gher0WZP+rtjefFk26hIb7V1PTLL30bmy1Z2vsRmSvqiss7Ag1XUdyplTGYzIlFNJp4L3vMNmK44N6g==} + hasBin: true + pkg-types@1.2.1: resolution: {integrity: sha512-sQoqa8alT3nHjGuTjuKgOnvjo4cljkufdtLMnO2LBP/wRwuDlo1tkaEdMxCRhyGRPacv/ztlZgDPm2b7FAmEvw==} @@ -12093,6 +12132,10 @@ packages: resolution: {integrity: sha512-3HZ2/7hdDKZvZQ7dhhITOUg4/wOrDRjyK2ZBllRB0ZCOi9u0cwq1ACHDjBB+nX+7+kltHjQvBRdeY7+W0T+7Gg==} engines: {node: '>=18'} + puppeteer-core@25.10.0: + resolution: {integrity: sha512-Hy5eMQshOEMil4JUUx03h5pw1HYkYCso1RG/gcpPlFSd4cYPOcopxcXEAxpLPOkOPJb9LIJtwxuj66bSdvknFg==} + engines: {node: '>=22.12.0'} + qs@6.13.0: resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} engines: {node: '>=0.6'} @@ -13063,6 +13106,10 @@ packages: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} + string-width@8.2.2: + resolution: {integrity: sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==} + engines: {node: '>=20'} + string.prototype.matchall@4.0.11: resolution: {integrity: sha512-NUdh0aDavY2og7IbBPenWqR9exH+E26Sv8e0/eTe1tltDGZL+GtBkDAnnyBtmekfK6/Dq3MkcGtzXFEd1LQrtg==} engines: {node: '>= 0.4'} @@ -13107,6 +13154,10 @@ packages: resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} engines: {node: '>=12'} + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + strip-bom-string@1.0.0: resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==} engines: {node: '>=0.10.0'} @@ -13594,6 +13645,9 @@ packages: typed-query-selector@2.12.0: resolution: {integrity: sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==} + typed-query-selector@2.12.2: + resolution: {integrity: sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==} + typedarray-to-buffer@3.1.5: resolution: {integrity: sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==} @@ -13643,10 +13697,6 @@ packages: undici-types@6.20.0: resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} - undici@6.12.0: - resolution: {integrity: sha512-d87yk8lqSFUYtR5fTFe2frpkMIrUEz+lgoJmhcL+J3StVl+8fj8ytE4lLnJOTPCE12YbumNGzf4LYsQyusdV5g==} - engines: {node: '>=18.0'} - undici@6.21.1: resolution: {integrity: sha512-q/1rj5D0/zayJB2FraXdaWxbhWiNKDvu8naDT2dl1yTlvJp4BLtOcp2a5BvgGNQpYYJzau7tf1WgKv3b+7mqpQ==} engines: {node: '>=18.17'} @@ -14053,6 +14103,9 @@ packages: web-vitals@4.2.4: resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==} + webdriver-bidi-protocol@0.4.3: + resolution: {integrity: sha512-uuN0goWfxP22B7J/uAgBpOYNPttC+XVseYE+rSY5+rQ+YBeVz/VORw8WbmLVcqW78zNg5A4qnjNXYUWR3il2ig==} + webdriver@8.32.0: resolution: {integrity: sha512-lSl+oPkn77Sku3ui3CP0gNi7kMwzND0gNIs+qRSB0QhmweNWpXKaNffhSn8lS5ovswOOKCc4QuWPvjaAZBpRqw==} engines: {node: ^16.13 || >=18} @@ -14317,6 +14370,10 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -14386,6 +14443,18 @@ packages: utf-8-validate: optional: true + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xdg-basedir@4.0.0: resolution: {integrity: sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==} engines: {node: '>=8'} @@ -14448,6 +14517,10 @@ packages: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs-parser@22.0.0: + resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yargs-unparser@2.0.0: resolution: {integrity: sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==} engines: {node: '>=10'} @@ -14464,6 +14537,10 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} + yargs@18.1.0: + resolution: {integrity: sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yauzl@2.10.0: resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} @@ -14504,6 +14581,9 @@ packages: zod@3.23.8: resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -18012,7 +18092,7 @@ snapshots: json-parse-even-better-errors: 3.0.2 json-stringify-nice: 1.1.4 lru-cache: 10.4.3 - minimatch: 9.0.5 + minimatch: 9.0.9 nopt: 7.2.1 npm-install-checks: 6.3.0 npm-package-arg: 11.0.2 @@ -18065,7 +18145,7 @@ snapshots: dependencies: '@npmcli/name-from-folder': 2.0.0 glob: 10.4.5 - minimatch: 9.0.5 + minimatch: 9.0.9 read-package-json-fast: 3.0.2 '@npmcli/metavuln-calculator@7.1.1': @@ -18448,6 +18528,7 @@ snapshots: typescript: 5.7.3 transitivePeerDependencies: - supports-color + optional: true '@puppeteer/browsers@1.9.1': dependencies: @@ -18515,6 +18596,14 @@ snapshots: - bare-buffer - supports-color + '@puppeteer/browsers@3.2.2(proxy-agent@6.5.0)(yauzl@3.2.0)': + dependencies: + modern-tar: 0.8.5 + yargs: 18.1.0 + optionalDependencies: + proxy-agent: 6.5.0 + yauzl: 3.2.0 + '@rollup/plugin-babel@5.3.1(@babel/core@7.26.8)(@types/babel__core@7.20.5)(rollup@2.79.2)': dependencies: '@babel/core': 7.26.8 @@ -19497,7 +19586,7 @@ snapshots: '@tufjs/models@2.0.1': dependencies: '@tufjs/canonical-json': 2.0.0 - minimatch: 9.0.5 + minimatch: 9.0.9 '@tybys/wasm-util@0.9.0': dependencies: @@ -19959,7 +20048,7 @@ snapshots: debug: 4.4.0(supports-color@8.1.1) fast-glob: 3.3.3 is-glob: 4.0.3 - minimatch: 9.0.5 + minimatch: 9.0.9 semver: 7.7.1 ts-api-utils: 1.4.3(typescript@5.7.3) typescript: 5.7.3 @@ -20131,7 +20220,7 @@ snapshots: '@vue/compiler-vue2': 2.7.16 '@vue/shared': 3.5.13 alien-signals: 0.4.14 - minimatch: 9.0.5 + minimatch: 9.0.9 muggle-string: 0.4.1 path-browserify: 1.0.1 optionalDependencies: @@ -20696,6 +20785,8 @@ snapshots: ansi-regex@6.1.0: {} + ansi-regex@6.3.0: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 @@ -21070,6 +21161,10 @@ snapshots: dependencies: balanced-match: 1.0.2 + brace-expansion@2.1.4: + dependencies: + balanced-match: 1.0.2 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -21461,6 +21556,7 @@ snapshots: dependencies: devtools-protocol: 0.0.1147663 mitt: 3.0.0 + optional: true chromium-bidi@0.5.10(devtools-protocol@0.0.1249869): dependencies: @@ -21482,6 +21578,12 @@ snapshots: urlpattern-polyfill: 10.0.0 zod: 3.23.8 + chromium-bidi@17.0.2(devtools-protocol@0.0.1666840): + dependencies: + devtools-protocol: 0.0.1666840 + mitt: 3.0.1 + zod: 3.25.76 + ci-info@2.0.0: {} ci-info@3.9.0: {} @@ -21554,6 +21656,12 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + cliui@9.0.1: + dependencies: + string-width: 7.2.0 + strip-ansi: 7.1.0 + wrap-ansi: 9.0.2 + clone-deep@4.0.1: dependencies: is-plain-object: 2.0.4 @@ -22186,6 +22294,8 @@ snapshots: deepmerge-ts@7.1.4: {} + deepmerge-ts@8.0.2: {} + deepmerge@4.3.1: {} default-gateway@6.0.3: @@ -22305,7 +22415,8 @@ snapshots: dependencies: dequal: 2.0.3 - devtools-protocol@0.0.1147663: {} + devtools-protocol@0.0.1147663: + optional: true devtools-protocol@0.0.1232444: optional: true @@ -22323,6 +22434,8 @@ snapshots: devtools-protocol@0.0.1415363: {} + devtools-protocol@0.0.1666840: {} + devtools@8.42.0(encoding@0.1.13): dependencies: '@types/node': 22.13.1 @@ -23619,6 +23732,8 @@ snapshots: get-east-asian-width@1.3.0: {} + get-east-asian-width@1.6.0: {} + get-intrinsic@1.2.6: dependencies: call-bind-apply-helpers: 1.0.1 @@ -24322,7 +24437,7 @@ snapshots: ignore-walk@6.0.5: dependencies: - minimatch: 9.0.5 + minimatch: 9.0.9 ignore@5.3.2: {} @@ -25022,10 +25137,7 @@ snapshots: kolorist@1.8.0: {} - ky@0.33.0: {} - - ky@0.33.3: - optional: true + ky@0.33.3: {} latest-version@5.1.0: dependencies: @@ -26125,10 +26237,6 @@ snapshots: dependencies: brace-expansion: 2.0.1 - minimatch@9.0.0: - dependencies: - brace-expansion: 2.0.1 - minimatch@9.0.1: dependencies: brace-expansion: 2.0.1 @@ -26141,6 +26249,10 @@ snapshots: dependencies: brace-expansion: 2.0.1 + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.4 + minimist-options@4.1.0: dependencies: arrify: 1.0.1 @@ -26200,7 +26312,8 @@ snapshots: minipass: 3.3.6 yallist: 4.0.0 - mitt@3.0.0: {} + mitt@3.0.0: + optional: true mitt@3.0.1: {} @@ -26253,6 +26366,8 @@ snapshots: transitivePeerDependencies: - rollup + modern-tar@0.8.5: {} + modify-values@1.0.1: {} morgan@1.10.0: @@ -26993,6 +27108,8 @@ snapshots: dependencies: find-up: 6.3.0 + pkg-pr-new@0.0.88: {} + pkg-types@1.2.1: dependencies: confbox: 0.1.8 @@ -27622,6 +27739,7 @@ snapshots: socks-proxy-agent: 8.0.5 transitivePeerDependencies: - supports-color + optional: true proxy-agent@6.3.1: dependencies: @@ -27701,6 +27819,7 @@ snapshots: - encoding - supports-color - utf-8-validate + optional: true puppeteer-core@21.11.0(encoding@0.1.13): dependencies: @@ -27757,6 +27876,20 @@ snapshots: - supports-color - utf-8-validate + puppeteer-core@25.10.0(proxy-agent@6.5.0)(yauzl@3.2.0): + dependencies: + '@puppeteer/browsers': 3.2.2(proxy-agent@6.5.0)(yauzl@3.2.0) + chromium-bidi: 17.0.2(devtools-protocol@0.0.1666840) + devtools-protocol: 0.0.1666840 + typed-query-selector: 2.12.2 + webdriver-bidi-protocol: 0.4.3 + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - proxy-agent + - utf-8-validate + - yauzl + qs@6.13.0: dependencies: side-channel: 1.1.0 @@ -28968,6 +29101,11 @@ snapshots: get-east-asian-width: 1.3.0 strip-ansi: 7.1.0 + string-width@8.2.2: + dependencies: + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + string.prototype.matchall@4.0.11: dependencies: call-bind: 1.0.8 @@ -29039,6 +29177,10 @@ snapshots: dependencies: ansi-regex: 6.1.0 + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.3.0 + strip-bom-string@1.0.0: {} strip-bom@3.0.0: {} @@ -29214,6 +29356,7 @@ snapshots: mkdirp-classic: 0.5.3 pump: 3.0.2 tar-stream: 3.1.7 + optional: true tar-fs@3.0.5: dependencies: @@ -29328,7 +29471,7 @@ snapshots: dependencies: '@istanbuljs/schema': 0.1.3 glob: 10.4.5 - minimatch: 9.0.5 + minimatch: 9.0.9 testingbot-tunnel-launcher@1.1.14: dependencies: @@ -29557,6 +29700,8 @@ snapshots: typed-query-selector@2.12.0: {} + typed-query-selector@2.12.2: {} + typedarray-to-buffer@3.1.5: dependencies: is-typedarray: 1.0.0 @@ -29608,8 +29753,6 @@ snapshots: undici-types@6.20.0: {} - undici@6.12.0: {} - undici@6.21.1: {} unicode-canonical-property-names-ecmascript@2.0.1: {} @@ -30076,6 +30219,8 @@ snapshots: web-vitals@4.2.4: {} + webdriver-bidi-protocol@0.4.3: {} + webdriver@8.32.0: dependencies: '@types/node': 20.17.17 @@ -30088,7 +30233,7 @@ snapshots: deepmerge-ts: 5.1.0 got: 12.6.1 ky: 0.33.3 - ws: 8.18.0 + ws: 8.21.3 transitivePeerDependencies: - bare-buffer - bufferutil @@ -30107,7 +30252,7 @@ snapshots: '@wdio/utils': 9.11.0 deepmerge-ts: 7.1.4 undici: 6.21.1 - ws: 8.18.0 + ws: 8.21.3 transitivePeerDependencies: - bare-buffer - bufferutil @@ -30125,7 +30270,7 @@ snapshots: '@wdio/utils': 9.4.3 deepmerge-ts: 7.1.4 undici: 6.21.1 - ws: 8.18.0 + ws: 8.21.3 transitivePeerDependencies: - bare-buffer - bufferutil @@ -30151,7 +30296,7 @@ snapshots: is-plain-obj: 4.1.0 lodash.clonedeep: 4.5.0 lodash.zip: 4.2.0 - minimatch: 9.0.5 + minimatch: 9.0.9 puppeteer-core: 20.9.0(encoding@0.1.13)(typescript@5.7.3) query-selector-shadow-dom: 1.0.1 resq: 1.11.0 @@ -30226,7 +30371,7 @@ snapshots: jszip: 3.10.1 lodash.clonedeep: 4.5.0 lodash.zip: 4.2.0 - minimatch: 9.0.5 + minimatch: 9.0.9 query-selector-shadow-dom: 1.0.1 resq: 1.11.0 rgb2hex: 0.2.5 @@ -30305,7 +30450,7 @@ snapshots: sockjs: 0.3.24 spdy: 4.0.2 webpack-dev-middleware: 5.3.4(webpack@5.97.1(@swc/core@1.10.1)) - ws: 8.18.0 + ws: 8.21.3 optionalDependencies: webpack: 5.97.1(@swc/core@1.10.1) transitivePeerDependencies: @@ -30625,6 +30770,12 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.1.0 + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.1 + string-width: 7.2.0 + strip-ansi: 7.1.0 + wrappy@1.0.2: {} write-file-atomic@2.4.3: @@ -30662,12 +30813,15 @@ snapshots: ws@7.5.10: {} - ws@8.13.0: {} + ws@8.13.0: + optional: true ws@8.16.0: {} ws@8.18.0: {} + ws@8.21.3: {} + xdg-basedir@4.0.0: {} xdg-basedir@5.1.0: {} @@ -30705,6 +30859,8 @@ snapshots: yargs-parser@21.1.1: {} + yargs-parser@22.0.0: {} + yargs-unparser@2.0.0: dependencies: camelcase: 6.3.0 @@ -30731,6 +30887,7 @@ snapshots: string-width: 4.2.3 y18n: 5.0.8 yargs-parser: 21.1.1 + optional: true yargs@17.7.2: dependencies: @@ -30742,6 +30899,15 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 + yargs@18.1.0: + dependencies: + cliui: 9.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + string-width: 8.2.2 + y18n: 5.0.8 + yargs-parser: 22.0.0 + yauzl@2.10.0: dependencies: buffer-crc32: 0.2.13 @@ -30786,4 +30952,6 @@ snapshots: zod@3.23.8: {} + zod@3.25.76: {} + zwitch@2.0.4: {} diff --git a/scripts/security/COMPATIBILITY.md b/scripts/security/COMPATIBILITY.md new file mode 100644 index 00000000000..77503ea3f57 --- /dev/null +++ b/scripts/security/COMPATIBILITY.md @@ -0,0 +1,107 @@ +# Puppeteer 20.9.0 → 25.10.0: проверка совместимости + +## Вывод + +**Это не обновление только Node.js и не полностью прозрачная замена.** В текущем Testplane обнаружены реальные вызовы удалённых API. Для изоляции контекстов добавлен ограниченный адаптер в форке. Отказ от Firefox через CDP согласован; совместимость произвольного пользовательского Puppeteer-кода по-прежнему не заявляется. + +По состоянию на 2026-09-08 свежий production consumer имеет audit 0, но этого недостаточно для разрешения бесшовного выпуска. + +## Существенные изменения по major-версиям + +| Переход | Изменение | Влияние на этот форк и потребителей | +|---|---|---| +| 20 → 21 | Callback фильтрации targets получает Target | Форк не задаёт такой callback; важен для пользовательского прямого connect/launch. [Release 21](https://github.com/puppeteer/puppeteer/releases/tag/puppeteer-core-v21.0.0). | +| 21 → 22 | `createIncognitoBrowserContext` переименован; удалены `$x`, `waitForXPath`, `waitForTimeout`; сменились exports devices/network conditions и event listener API | Изоляция Testplane действительно вызывает старое имя контекста. Этот вызов восстановлен адаптером. Page-методы и все прежние exports не восстанавливаются. [Release 22](https://github.com/puppeteer/puppeteer/releases/tag/puppeteer-core-v22.0.0). | +| 21 → 22 | Новый headless по умолчанию, ReadableStream для PDF stream, изменения element screenshot/доступности PDF | Форк в getPuppeteer подключается к уже запущенному браузеру, а не вызывает Puppeteer.launch. Пользовательские screenshot/PDF-сценарии могут отличаться. [Release 22](https://github.com/puppeteer/puppeteer/releases/tag/puppeteer-core-v22.0.0). | +| 22 → 23 | Удалён `BrowserContext.isIncognito`; бинарный API типизирован как Uint8Array; переименованы ignoreHTTPSErrors/product; Firefox по умолчанию использует BiDi | `isIncognito` действительно используется в Testplane и восстановлен через публичный context.id. Изменение binary-контракта остаётся: в наших CDP-прогонах screenshot по-прежнему был Buffer, что не гарантирует Buffer для всех API/бэкендов. [Release 23](https://github.com/puppeteer/puppeteer/releases/tag/puppeteer-core-v23.0.0). | +| 23 → 24 | Удалена поддержка Firefox через CDP; удалены deprecated connect/launch options; HTTPRequest/Response URL включает fragment | Отказ от Firefox/CDP согласован. Это не удаление Firefox вообще или CDP для Chromium. [Release 24](https://github.com/puppeteer/puppeteer/releases/tag/puppeteer-core-v24.0.0). | +| 24 → 25 | ESM-only; новый минимум Node; удалён Browser.isConnected; executablePath/defaultArgs стали async; изменены cookie/clickCount/header API | CJS-входы проверены на Node 22.12/22/24; внутренний код использует connected, а старый isConnected возвращён адаптером. Остальные публичные изменения не маскируются. [Release 25](https://github.com/puppeteer/puppeteer/releases/tag/puppeteer-core-v25.0.0). | + +Это перечень влияющих на совместимость областей, не полный changelog каждого patch-релиза. Полная история: [официальный changelog](https://github.com/puppeteer/puppeteer/blob/main/packages/puppeteer-core/CHANGELOG.md). + +## Что обнаружено в потребителе и исправлено + +Read-only исследование соседнего Testplane выявило `_performBidiIsolation`: он вызывает `createIncognitoBrowserContext()`, открывает страницу, сопоставляет `page.target()._targetId` с WebDriver window handle, затем различает контексты через `isIncognito()`. + +Добавлен адаптер, который сохраняет объект Browser и объекты BrowserContext, не меняет глобальные прототипы и устанавливается один раз: + +- `createIncognitoBrowserContext(options)` делегирует `createBrowserContext(options)`; +- `isIncognito()` возвращает наличие context.id, как старый CDP BrowserContext; +- `isConnected()` возвращает текущее browser.connected; +- контексты из browserContexts/defaultBrowserContext/createBrowserContext получают старый метод; +- декларации getPuppeteer сохраняют эти методы для TypeScript-потребителя. + +Новые unit tests сначала упали без адаптера и прошли после его добавления. Настоящий browser smoke проверяет не только наличие методов, но и cookies isolation, target/window mapping, возврат в исходное окно, mock/throttle/reload. + +Соседний Testplane не изменялся. Собственные тесты пользователей могут обращаться к другим удалённым Puppeteer-методам — адаптер не означает полной эмуляции версии 20. + +## Наблюдаемые результаты + +| Проверка | До | После | +|---|---|---| +| Clean npm consumer audit | 12 high | 0 | +| Raw Puppeteer context isolation API на Chrome 115/152 | PASS, 20.9.0 | FAIL, 25.10.0 без адаптера | +| Context isolation через getPuppeteer форка на Chrome 115/152 | Старый API присутствовал | PASS с адаптером | +| CDP connect, pages/evaluate/CDPSession на Chrome 115/152 | PASS | PASS | +| Cookies isolation, target/window mapping, CDP mock/restore, throttle, reload | Не используется как доказательство полного старого baseline | PASS на исправленных пакетах | +| CJS/ESM и isolation smoke, Node 22.12.0 / 22.21.1 / 24.19.0 | — | PASS | +| BiDi `mock('**/api')` | FAIL: возвращается исходный ответ | PASS после отдельного исправления BiDi routing и подписок | +| Полный unit suite монорепозитория после сборки | 134 failed suites / 412 failed tests | 305 passed suites / 3478 passed tests; 1 suite и 13 tests уже были skipped | + +Firefox 128 проверялся отдельным CDP-процессом: соединение устанавливается обоими клиентами, но полный сценарий не проходит. У 20 также есть ошибки navigation/evaluate; у 25 блокируется получение страницы. Поэтому старый Firefox/CDP не объявляется ни полностью рабочим baseline, ни совместимым после обновления. + +## Почему не ограничиться Puppeteer 24 + +Проверены npm-метаданные последней 24-й версии: `puppeteer-core@24.43.1` зависит от `@puppeteer/browsers@2.13.2`. Эта ветка содержит extract-zip; простое обновление до 24 не устраняет всю уязвимую цепочку. Корневой override не является исправлением распространяемого npm-пакета, потому что не наследуется приложением-потребителем. + +## Согласованное изменение + +- Отказ от Firefox через CDP принят пользователем: сам Firefox удалил CDP в версии 141 ([Mozilla release notes](https://developer.mozilla.org/en-US/docs/Mozilla/Firefox/Releases/141)). Firefox остаётся поддерживаемым через WebDriver/BiDi, Chromium — через CDP. Восстанавливать старый Firefox/CDP не требуется; этот пункт больше не блокирует обновление. + +## Не закрыто + +- Пользовательские `$x`/waitForXPath/waitForTimeout, PDF/stream/screenshot и прямые Puppeteer imports: необходим аудит используемого API либо breaking migration. +- Удалённые Selenium/Selenoid/Moon, авторизация, корпоративные прокси/TLS и Linux/Windows: живое окружение не проверялось. +- Полная Testplane CI-матрица с Linux grid остаётся не проверенной локально. Совместная проверка изменённых Testplane/WDIO и контроль на настоящем master выполнены 2026-09-11; результаты ниже. + +Публикация не выполнялась. Считать ветку безусловно безопасным бесшовным релизом для всех нынешних пользователей нельзя. + +## Дополнительные исправления по запросу о тестах всего монорепозитория + +- BiDi: wildcard-компоненты не передаются как буквальные URL в `network.addIntercept`; полная фильтрация выполняется локально. Неподходящие запросы продолжаются, пересекающиеся перехваты обрабатываются ровно одним mock; подписки привязаны к сессии, а не к глобальному флагу. +- Browser HTTP transport: `ky` 0.33.3 вместо 0.33.0 для native Request на Node 22; корректная передача timeout, один уровень retries и URL независимо от JS realm. Node/got остаётся транспортом по умолчанию. +- Devtools: нулевой implicit timeout больше не запускает ожидание селектора с таймаутом Puppeteer по умолчанию. +- `click({ duration })`: явно заданная задержка удержания теперь выполняется; обычный клик по-прежнему не получает лишнего pause(0). +- Внутренний `SESSION_BIDI_MOCKS` исключён из публичного списка команд; stacktrace filtering учитывает имена форков. +- Тестовые фикстуры обновлены под имена форков и реальные Request/Response. Старые тесты удалённых request-классов перенесены на публичные makeRequest/error helpers с проверками тела, headers, auth, transforms, событий, retries и 429. +- Сохранено намеренное поведение форка: legacy DOM visibility script и boolean-параметр getHTML; не восстановлены автоматически механизмы upstream v9, ранее отключённые в этом репозитории. +- Цепочка `$$()[index]` ожидает появления нужного индекса в пределах waitforTimeout; повторяется исходный запрос со всеми аргументами, включая React filters и custom selector options. Для уже найденного индекса дополнительные запросы не выполняются. +- Lighthouse выбирает target типа `page`, затем CDP endpoint по публичному `Target.getTargetInfo`, а не первому элементу `/json/list` или приватному frame id. Binary trace разбирается через Buffer.from(Uint8Array). Реальные локальные PWA, tracing и performance audits проходят. +- BrowserFramework повторяет polling при transient execution-context reset и связывает результат с конкретным spec; остальные ошибки завершают spec с ошибкой, а не уходят в unhandled rejection. +- Shadow roots регистрируются после подключения host с parent-before-child порядком, включая обычные detached hosts и native/declarative roots. Ожидающие hosts хранятся через WeakRef, чтобы не удерживать detached DOM. +- Session managers снимают те же callback, которые подписали, и очищаются при deleteSession; чужие listeners сохраняются. Неуспешный switchWindow восстанавливает исходное окно, context обновляется только после успешной команды. closeWindow по-прежнему не переключает окно автоматически. +- Проверка необработанных ошибок Vitest больше не отключена. + +Эти исправления шире исходного обновления зависимостей. Их нужно просматривать отдельно от security diff. Подробные зелёные локальные smoke/component/E2E результаты и ограничения приведены в TEST-RESULTS.md. Они не заменяют облачную, кроссплатформенную и полную Testplane-интеграционную проверку. + +## Optional Devtools package: 2026-09-11 + +Отдельная проверка consumer с явно установленным `@testplane/devtools` обнаружила оставшийся runtime `puppeteer-core@20.9.0`: audit этого consumer показал 9 уязвимостей, хотя consumer без optional Devtools уже проходил. Поэтому проверка только обязательного дерева Testplane недостаточна для вывода обо всех распространяемых WDIO-пакетах. + +В `@testplane/devtools` runtime обновлён до `^25.10.0`, минимум Node — `>=22.12.0`. Перенесены импорты на поддерживаемые exports, `ignoreHTTPSErrors` переводится в `acceptInsecureCerts`, Chromium/Edge используют современный selector `browser`, двойной клик — `Mouse.click({ count: 2 })`. Генерация UUID v4 для session/window IDs использует встроенный `crypto.randomUUID`; runtime `uuid` и его types удалены после того, как повторный optional audit выявил оставшуюся уязвимость этой зависимости. CJS-вход использует асинхронный ESM bridge, поскольку зависимость `@testplane/wdio-config` не предоставляет require-вход. Firefox/CDP отклоняется явно до запуска браузера и исключён из `SUPPORTED_BROWSER`; это не отменяет поддержку Firefox через WebDriver BiDi. + +На Node 22.21.1 и Chrome 152.0.7977.82 отдельный настоящий Devtools smoke проверил навигацию/title, CSS/XPath/shadow selectors, ввод/keyboard actions, DOM-событие double-click, cookies add/read/delete, iframe, создание/переключение/закрытие окна, PNG screenshot/reload и завершение сессии. Команды проверяются отдельно от Testplane: Testplane 9 не разрешает Devtools как automation backend. Доказательства и команды находятся в `vibe/release-readiness/devtools/`. + +Ограничение логирования: Puppeteer 25 больше не использует пакет `debug`, поэтому прежний `patchDebug` не перенаправляет CDP protocol logs в WDIO logger. В строгом workspace он предупреждает об отсутствующем `debug`; наличие транзитивного `debug` в consumer может скрыть это предупреждение, но не восстанавливает protocol logging. Диагностика самого Puppeteer доступна через `NODE_DEBUG=puppeteer:*`; прозрачное сохранение прежнего logging API не заявляется. Пользовательские прямые Puppeteer API по-прежнему требуют собственного аудита. Финальный audit optional consumer необходимо проверять по свежему `pack-consumer.mjs --with-devtools`, а не переносить результат consumer без Devtools. + +Финальная проверка optional consumer `testplane-release-consumer-hCkj2F`: production audit **0**, `npm ls --all --omit=dev` проходит, установленные build-файлы совпадают с локальными tarball preview, нет overrides и package symlinks. Настоящий Devtools smoke из установленного consumer прошёл **36/36** проверок: ESM и холодный CJS на Node 22.21.1 и минимальном Node 22.12.0, Chrome 152.0.7977.82 и Puppeteer 25.10.0. Полный package unit suite — **118/118**, package ESLint и ESM/CJS/declaration build проходят. Локальные пути и JSON-результаты сохранены в `vibe/release-readiness/devtools/consumer-summary.json`; эти результаты не заменяют проверку удалённых browser providers. + +## Совместная проверка с Testplane, 2026-09-11 + +Проверены обе изменённые ветки одновременно, а для атрибуции ошибок — четыре независимые комбинации настоящего Testplane master/PR и старого/замороженного нового WDIO. У каждого Testplane собственные lockfile, npm ci и build; проверены реальные загруженные модули. REPL-регрессия относилась к Testplane PR: master проходил с обеими версиями WDIO, PR падал с обеими. Исправлена последовательность preload WDIO/Mocha, сохранены исходные тесты с конфликтующим loader; REPL 5/5 проходит на Node 22.12.0, 22.21.1 и 24.19.0. + +После дополнительных исправлений cookies partition, диалогов, preload lifecycle и BiDi network errors чистая установка Testplane из локальных tarballs с восемью обязательными WDIO-пакетами имеет production audit **0**, валидное dependency tree и проходит **35/35** Chrome/Firefox сценариев как на Node 22.21.1, так и на минимальном 22.12.0. Это не linked-only проверка: package symlinks, devDependencies и корневые overrides отсутствуют, пути master/worker и содержимое build проверены. Во временных manifest только ссылки на неопубликованные first-party пакеты заменены на tarballs; выбор новых release-версий и обновление зависимостей Testplane остаются отдельным шагом выпуска. + +Firefox body mocking теперь выполняется до отправки запроса: по умолчанию сервер не вызывается и его status/headers не наследуются. Явный `fetchResponse: true` для Firefox и response-зависимые фильтры при ранней подмене отклоняются с объяснением. Chromium сохраняет прежнее поведение по умолчанию; ранняя подмена доступна через `fetchResponse: false`. Поздняя ошибка протокольного fulfillment не считается успешным вызовом и доступна через `waitForResponse`/`restore`. Это существенная граница совместимости, не просто повышение версии Node. + +Финальные WDIO unit: **3540 passed / 13 existing skipped**; локальные testrunner, Classic, multiremote, standalone, reloadSession и Chrome/Firefox launch/BiDi завершаются успешно. Testplane unit: **3462 passed / 1 existing pending**, выбранные integration-наборы **40/40**. Весь Testplane репозиторий зелёным не объявляется: Linux-grid E2E не получили браузер в локальном окружении; пять screenshot integration и 50 browser-env failures воспроизведены на baseline без изменения эталонов. Установленный production dependency graph проверен свежим npm audit, но отдельная актуальная проверка всех сторонних пакетов внутри runtime-бандла пока ожидает разрешения на отправку их имён/версий в npm. Полный локальный отчёт и воспроизводимые команды сохранены в untracked `vibe/release-readiness/README.md`. diff --git a/scripts/security/README.md b/scripts/security/README.md new file mode 100644 index 00000000000..d32f58bff41 --- /dev/null +++ b/scripts/security/README.md @@ -0,0 +1,122 @@ +# Воспроизводимые проверки security-update + +Скрипты не публикуют пакеты, не меняют версии и не используют npm overrides. Все npm-установки выполняются в новых временных каталогах. Сборка обновляет обычные build-артефакты репозитория. Нужны Node **>=22.12.0**, установленный pnpm и зависимости workspace (`pnpm install`). + +## 1. Audit до и после + +Выполнить из корня checkout: + +```bash +REPO="$PWD" +node "$REPO/scripts/security/prepare.mjs" --baseline +node "$REPO/scripts/security/prepare.mjs" +``` + +Первый запуск устанавливает исторические опубликованные версии; второй пересобирает и упаковывает восемь текущих пакетов. Каждый выводит **свой** путь `Consumer: ...`. Каталоги не удаляются автоматически: в них остаются tarball, логи сборки/установки, полный dependency tree, audit и evidence.json с версией Node/platform/revision. + +Ожидание на 2026-09-08: **12 vulnerabilities → 0**. Число baseline может меняться с базой advisories; после исправления скрипт требует ровно 0. Он также проверяет отсутствие старых вложенных workspace-пакетов, undici, extract-zip, CJS/ESM-входы и TypeScript-контракт getPuppeteer/context isolation. Скрипт завершится с ошибкой, если сборка, audit или проверка не пройдёт. + +## 2. Реальные браузерные сценарии + +Перейти в **исправленный** consumer, путь которого напечатал второй запуск: + +```bash +cd /absolute/path/from/output/consumer +node check-consumer.mjs + +# Найти установленный Chrome либо скачать браузер при его отсутствии. +node browser.mjs + +# Явно скачать выбранную версию в browser-cache текущего consumer. +BROWSER_VERSION=115.0.5790.170 node browser.mjs +BROWSER=firefox BROWSER_VERSION=stable node browser.mjs + +# Использовать конкретный установленный бинарник. +BROWSER_BINARY="/absolute/path/to/chrome" node browser.mjs + +# Выполнить тот же smoke другим Node, включая минимальную версию. +/absolute/path/to/node-v22.12.0/bin/node check-consumer.mjs +/absolute/path/to/node-v22.12.0/bin/node browser.mjs +``` + +Переменные окружения: `BROWSER=chrome|firefox`, `BROWSER_VERSION`, `BROWSER_BINARY`, `BROWSER_CACHE`. В PowerShell задавать их через `$env:BROWSER = "firefox"` и т. п. Приведённые shell-команды — для bash/zsh; Windows пока не проверен. + +Chrome smoke проверяет: + +- WebDriver-сессию, навигацию, поиск элемента, reload/disconnect; +- getPuppeteer и повторное использование соединения; +- старые вызовы Testplane `createIncognitoBrowserContext()` и `isIncognito()`; +- изоляцию cookies, открытие страницы в контексте, соответствие `_targetId` WebDriver-окну, переключение окна; +- CDP response mock/restore, CPU/network throttling. + +Firefox smoke проверяет обычный WebDriver, **не Firefox/CDP**. Selenium Grid, Selenoid/Moon, удалённые TLS/proxy-окружения этим локальным smoke не покрываются. + +`BIDI=1 node browser.mjs` включает BiDi. Исторический consumer не подменял ответ `mock('**/api')`; этот существующий дефект исправлен отдельно. Скрипт проверяет mock/restore, повторное создание мока после reload и отсутствие зависания неперехватываемых запросов. + +Скачивание браузеров требует сети и места на диске. Для ZIP нужны системный `unzip` либо совместимый optional `yauzl`; Windows использует системные инструменты. Старые браузеры проверять только с локальными тестовыми страницами и отдельными профилями. + +## 3. A/B-сравнение исходного Puppeteer 20 и нового 25 + +Старый клиент устанавливается **отдельно**, чтобы не загрязнить исправленный audit: + +```bash +OLD_CLIENT=$(mktemp -d) +npm install --prefix "$OLD_CLIENT" --ignore-scripts --no-audit --no-fund puppeteer-core@20.9.0 + +# Находясь в исправленном consumer: +OLD_PUPPETEER_DIR="$OLD_CLIENT" node compare-puppeteer.mjs +OLD_PUPPETEER_DIR="$OLD_CLIENT" BROWSER_VERSION=115.0.5790.170 node compare-puppeteer.mjs +``` + +Оба клиента по очереди подключаются к одному браузеру. `puppeteer-comparison.json` содержит PASS/FAIL/INFO/SKIP каждой проверки; каждая операция ограничена таймаутом. **Exit code 1 здесь ожидаем:** проверяются сырые upstream-клиенты, без нашего адаптера. В 25 отсутствуют старые методы контекстов, isConnected, XPath/waitForTimeout. Их нельзя объявлять совместимыми на основании одного успешного connect. + +Для собственного CDP HTTP endpoint можно задать `CDP_BROWSER_URL=http://127.0.0.1:9222`: тогда скрипт не создаёт WebDriver-сессию и не закрывает внешне запущенный браузер. В частности, это позволяет проверить старый Firefox без ограничений современного geckodriver. Firefox 128 запускался отдельно с новым профилем и настройкой `user_pref("remote.active-protocols", 3);`. Полная совместимость этого сценария **не подтверждена**. + +## Что эти проверки не доказывают + +- Нулевой audit касается выбранной production-цепочки, не всего монорепозитория и не всех остальных зависимостей Testplane. +- Chrome 115 и 152 не представляют все промежуточные версии, облачные гриды и пользовательские расширения Puppeteer API. +- Адаптер сохраняет API изоляции, используемый текущим Testplane, а не весь Puppeteer 20 API. +- Unit baseline был красным до обновления. После отдельного ремонта тестов и дефектов весь unit-набор проходит с включённой проверкой необработанных ошибок; это не заменяет component/cloud/E2E проверки. + +Разбор несовместимостей и результаты A/B находятся в `COMPATIBILITY.md` рядом с этим файлом. + +## 4. Проверки монорепозитория + +Из корня checkout после `pnpm install`: + +```bash +pnpm run compile:all:all +pnpm run test:unit:run +pnpm run test:typings +pnpm run test:eslint +pnpm run test:ejslint +pnpm run test:depcheck +pnpm run test:interop +CI=1 pnpm run test:smoke +CI=1 pnpm run test:component +pnpm run test:e2e:testrunner +pnpm run test:e2e:classic +pnpm run test:e2e:multiremote +pnpm run test:e2e:webdriver +pnpm run test:e2e:standalone +pnpm exec vitest --config ./e2e/vitest.config.ts --run e2e/launch/reloadSession.test.ts +``` + +Компоненты запускаются в headless Chrome, локальная WebDriver-матрица — Chrome/Firefox, без Edge/Safari. Не пересобирать пакеты одновременно с browser-runner: Vite HMR изменяет исполняемый код посреди теста. Основные DOM/navigation/PWA E2E используют настоящие локальные HTTP-фикстуры вместо нестабильных внешних страниц; браузерные команды не замоканы. + +Последняя команда запускает только локальный launch-сценарий. Общий `test:e2e:launch` дополнительно загружает исторически отключённый внутри тела AWS-тест, который требует credentials на уровне модуля; без них команда падает. AWS/Sauce здесь не считаются пройденными. Существующие skipped/no-op наборы и точные результаты перечислены в `TEST-RESULTS.md`. + +## 5. Параллельные E2E с пустым кэшем браузеров + +После сборки из корня checkout: + +```bash +node scripts/security/testrunner-cold-cache.mjs testrunner +node scripts/security/testrunner-cold-cache.mjs multiremote +node scripts/security/testrunner-cold-cache.mjs classic +``` + +Каждый запуск создаёт новый временный каталог, передаёт его как cacheDir настоящему Launcher и запускает существующий E2E-конфиг без уменьшения maxInstances: пять workers у testrunner, два workers с двумя браузерами в каждом у multiremote. Скрипт требует exit code 0; путь к сохранённым логам и кэшу печатается как `Cold-cache evidence: ...`. Нужен доступ к загрузке Chrome/ChromeDriver; testrunner дополнительно проверяет reload Chrome → Firefox. Edge/Safari не запускаются. Существующий пользовательский кэш не очищается. + +Такой прогон проверяет подготовку бинарников **до** запуска параллельных воркеров. Обычный локальный запуск с уже установленным браузером не обнаруживает гонку скачивания/распаковки. На коде до исправления PR #52 этот multiremote-прогон воспроизвёл CI-ошибку: каталог ChromeDriver существует, но бинарник ещё отсутствует. diff --git a/scripts/security/TEST-RESULTS.md b/scripts/security/TEST-RESULTS.md new file mode 100644 index 00000000000..06b1db69aca --- /dev/null +++ b/scripts/security/TEST-RESULTS.md @@ -0,0 +1,108 @@ +# Проверки всего монорепозитория + +Дата: 2026-09-08. Ветка: `sp.fix-vulnerabilities`. Локальное окружение: Node.js 22.21.1, macOS arm64. + +**Локально доступные проверки исправлены и проходят. Ветка предназначена для draft PR, а не для публикации или объявления полной совместимости.** Облачные/кроссплатформенные проверки и исторически отключённые наборы перечислены отдельно. Новые skips для сокрытия ошибок не добавлялись; проверка необработанных ошибок Vitest включена. + +## Подтверждённые результаты + +| Команда / набор | Результат | +|---|---| +| `pnpm run compile:all:all` | PASS | +| `pnpm run test:unit:run` | 305 passed suites, 3478 passed tests; существующие 1 suite / 13 tests skipped | +| `pnpm run test:typings` | PASS | +| `pnpm run test:eslint`, `test:ejslint`, `test:depcheck` | PASS | +| `pnpm run test:interop` | PASS | +| `CI=1 pnpm run test:smoke` | PASS, финальное сообщение `All smoke tests passed!`; это framework smoke с mock service, не браузерная матрица | +| `CI=1 pnpm run test:component` | PASS: React 2, Vue 3, Svelte 2, Lit 72, Stencil 5; всего 84 passed, 1 существующий Stencil skipped; coverage thresholds и screenshot-artifact check проходят | +| `pnpm run test:e2e:testrunner` | PASS: 98 tests; основной файл 95, mock 1, Puppeteer 1, reload 1; 2 существующих source-maps skipped | +| `pnpm run test:e2e:classic` | PASS, 2 tests | +| `pnpm run test:e2e:multiremote` | PASS, 2 configurations × 2 tests; по 3 существующих skipped | +| `pnpm run test:e2e:webdriver` | PASS, только Chrome / Firefox: 9 / 8 tests; 2 / 3 существующих skipped | +| `pnpm run test:e2e:standalone` | PASS, 1 attach test | +| `pnpm exec vitest --config ./e2e/vitest.config.ts --run e2e/launch/reloadSession.test.ts` | PASS, 1 BiDi reconnect test | +| `node scripts/security/prepare.mjs` | PASS: чистый production consumer audit 0, CJS/ESM/TypeScript | +| Real-browser smoke из свежего consumer, Chrome 152, CDP / BiDi | PASS: isolation, target mapping, mock/restore, throttle, reload, повторный mock, unmatched request, disconnect | +| Тот же consumer на минимальном Node 22.12.0 | PASS: CJS/ESM imports и реальный Chrome/CDP smoke | + +Audit 0 относится **только к распространяемой production-цепочке выбранных форков**, установленной из tarball в чистый npm consumer без overrides. Это не нулевой audit всех devDependencies монорепозитория или всех зависимостей Testplane. Исторический consumer давал 12 high vulnerabilities. + +Исходный полный unit-прогон после сборки: 134 failed suites / 412 failed tests. Ошибки исправлены без массового отключения тестов. Удалён `describe.only` в основном E2E, ранее пропущенный iframe moveTo-сценарий включён обратно. + +## Что исправлено после предыдущего отчёта + +- **React/Lit:** сама команда `getHTML` не отключена. `getHTML()` / `getHTML(true)` возвращают DOM outerHTML, `getHTML(false)` — innerHTML. Закомментирована расширенная upstream v9 реализация object-options. Этот legacy-контракт сохранён; snapshots и вызовы тестов приведены к нему, вложенный open/closed shadow DOM отдельно проверяется селекторами. +- **BrowserFramework:** polling переживает transient execution-context reset, корректно возвращает остальные ошибки и не переносит завершение poll предыдущего spec на следующий. +- **Shadow roots:** отложенная регистрация подключённых hosts в порядке parent-before-child, обычные элементы с attachShadow до append, native/declarative roots и weak pending references. Stencil/Lit проходят. +- **Lighthouse:** target выбирается по типу page и URL; CDP endpoint — по настоящему targetId вместо private frame id или первого элемента списка. Trace Uint8Array декодируется корректно. Проверяются настоящий локальный PWA с manifest/service worker, все семь PWA audits, отрицательный PWA, tracing, metrics и performance score. +- **Контекст и listeners:** успешное переключение обновляет context; неуспешный switchWindow восстанавливает исходное окно. Менеджеры удаляют собственные listeners, сохраняют чужие, очищаются при deleteSession. closeWindow не получает неявного переключения. Предыдущее MaxListenersExceededWarning при проверенном BiDi reload больше не воспроизводится. +- **E2E fixtures:** зависимые от нестабильных внешних сайтов DOM/navigation/auth/iframe/scroll сценарии используют локальные HTTP-страницы. Бесконечное рекурсивное iframe-вложение заменено конечной фикстурой; wait:none проверяется серверно управляемой выдачей HTML. Настоящие браузерные операции и содержательные assertions сохранены. +- **Асинхронные сетевые события:** E2E ждёт появления ожидаемого subresource в живом объекте запроса; завершение BiDi navigate само по себе не гарантирует доставку всех network events. Headers сравниваются без учёта регистра. +- **Повторный поиск $$()[index]:** сохраняет исходные React filters/custom selector arguments. Регрессия найдена независимым review, воспроизведена красными тестами и исправлена. + +## Ограничения и оставшаяся работа для PR + +- **Edge/Safari исключены по согласованному объёму**, не запускались и не блокируют локальный результат. Reload E2E проверяет переход Chrome → Firefox. +- **AWS/Sauce не подтверждены.** `test:e2e:launch` без credentials завершается с ошибкой при загрузке `e2e/launch/aws.test.ts`. В исходном файле тело теста уже отключено через `return`, но env validation находится на уровне модуля. Файл не подменялся passing stub; локальный launch проверен отдельной точной командой выше. Полный `pnpm run test:e2e` поэтому не объявляется зелёным. +- **Исторические skips/no-op остаются видимыми:** unit, Stencil, source-maps, multiremote, Chrome/Firefox skipped перечислены в таблице. Preact/misc component npm-команды уже содержали `echo "UNSKIP ME" || …`; их exit 0 не является выполнением этих тестов. Они не включены в 84 passed. +- **Linux/Windows и удалённые Selenium/Selenoid/Moon, proxy/TLS не проверены локально.** CI Node matrix обновлена до 22.12 / 22 / 24; её будущий результат не предсказывается. Существующий Windows component CI остаётся отключённым. +- **Полный consumer Testplane integration suite не выполнялся.** Проверен воспроизводимый сценарий его context isolation; соседний checkout не изменялся. +- **Puppeteer 20 → 25 содержит breaking changes помимо Node.js.** Ограниченный адаптер сохраняет найденные Testplane Browser/Context calls, но не произвольный пользовательский API. Подробности — в COMPATIBILITY.md. Firefox-over-CDP removal согласован; Firefox/WebDriver/BiDi и Chromium/CDP не удалены. +- Во время диагностики замечен отдельный существующий дефект NetworkManager: один child может добавляться и на beforeRequestSent, и на responseCompleted. Он не является причиной исправленных падений и не изменялся в этой ветке; дедупликация требует отдельной спецификации/регрессии. + +## Воспроизведение и доказательства + +Команды монорепозитория, чистой установки и браузерные скрипты приведены в README.md рядом с этим файлом. Скрипты сохраняют audit, dependency tree, tarball и логи в напечатанном временном каталоге. + +Локальные артефакты текущей проверки (не входят в Git): + +- `/tmp/webdriverio-final-tests`: `build-final.log`, `unit-final.log`, `unit-final.json`, `eslint-final.log`, `typings.log`, `ejslint.log`, `depcheck.log`, `interop.log`, `smoke-final.log`. +- Там же: `e2e-testrunner-final.log`, `e2e-webdriver-final.log`, `e2e-classic.log`, `e2e-multiremote.log`, `e2e-standalone.log`, `launch-local-final.log`; `e2e-launch.log` сохраняет AWS env failure. +- Там же: `consumer-final.log`, `consumer-cdp-final.log`, `consumer-bidi-final.log`, `consumer-min-node-final.log`. +- `/tmp/webdriverio-monorepo-tests/component-release-final.log`: полный component-прогон и coverage; в том же каталоге RED/GREEN-логи lifecycle, polling и shadow regressions. + +Пакеты не публиковались. Файлы планов остаются локально и не включаются в PR. + +## PR #52: исправления по логам GitHub Actions + +Исходный [CI run 34214203198](https://github.com/gemini-testing/webdriverio/actions/runs/34214203198) выявил три причины, не проявившиеся в предыдущем локальном Node 22 прогоне: + +- **Node 24 / JSDOM:** нативный Request Node отвергает AbortSignal из JSDOM до вызова fetch mock. Тестовое окружение теперь использует согласованные нативные AbortController/AbortSignal; новая регрессия проверяет создание Request, clone и распространение abort. Производственный HTTP transport не менялся. До исправления локально воспроизведены те же 3 failed suites, после — полный unit-набор на Node 22.21.1 и 24.19.0: **306 suites / 3479 tests passed**, существующие 1 suite / 13 tests skipped. Минимальный Node 22.12.0: targeted regression/polling tests 4 passed. +- **Windows / polling tests:** литерал file:///spec.ts не является абсолютным Windows file URL. Фикстуры теперь получают URL относительно import.meta.url, сохраняя native drive/path. CI раньше завершал runSpec до polling с `File URL path must be absolute`; последующий unhandled rejection был следствием незапущенного poll, а не отдельным production-сбоем. +- **macOS / Lit:** сумма четырёх проверок отсутствующего элемента занимала 1405–1785 мс вместо искусственного лимита 1000 мс. По уточнённому объёму пользователя job Component Tests удалён из Test workflow: Testplane использует собственную реализацию компонентных тестов. Зависимости needs у E2E обновлены, остальные проверки сохранены. Локальные component-команды и код Lit-теста в этом CI-исправлении не меняются; эксперимент по замене временного лимита не включён в коммит. + +В следующих E2E jobs заменён выведенный из эксплуатации macos-13 на macos-15-intel, сохраняя Intel-архитектуру и набор проверок. [Официальное уведомление GitHub](https://github.blog/changelog/2025-09-19-github-actions-macos-13-runner-image-is-closing-down/). + +Логи локального RED/GREEN и полных Node 22/24 прогонов: `/tmp/webdriverio-ci52`. Успех локальных проверок не объявляется успешным Windows/полным GitHub CI: итог нового CI нужно проверять после push. PR остаётся в текущем статусе, без перевода в draft. + +Автоматическая preview-публикация pkg-pr-new для PR отключена: Continuous Releases продолжает сборку, но Publish выполняется только на существующем push-триггере main. Это не npm-релиз; существующий первый PR run запускал preview-публикацию автоматически, поэтому предыдущая фраза «пакеты не публиковались» относится к отсутствию ручного npm publish, а не к действиям этого CI workflow. + +## PR #52: гонка установки браузеров в четырёх E2E jobs + +[Run 34216214214](https://github.com/gemini-testing/webdriverio/actions/runs/34216214214) подтвердил исправление предыдущих unit/smoke проблем: все девять комбинаций OS/Node каждого набора прошли. Четыре jobs Testrunner / Multiremote на Windows и Intel macOS упали при подготовке сессий, до проверок тестов: + +- Windows: одновременная работа с архивом Chrome завершалась `EBUSY: resource busy or locked, unlink`. +- macOS: параллельный install обнаруживал уже созданный каталог Chrome/ChromeDriver до появления исполняемого файла. + +Причина: историческая правка `5836bd8050` подменила аргументы предварительной установки в Launcher на `{}, []`. Воркеры начинали независимые скачивания/распаковки в общий пустой кэш. Восстановлены `setupDriver(config, caps)` и `setupBrowser(config, caps)` с ожиданием завершения до запуска workers. Дополнительно учтены per-instance hostname/port/user/key у multiremote и connection overrides обычных capabilities: для удалённых сессий локальные бинарники не скачиваются; смешанный local/remote конфиг подготавливает только локальный браузер. Новые unit-регрессии проверяют эти случаи, передачу конфигурации, ожидание подготовки и отказ запуска workers при ошибке. + +Локальная проверка на Node 22.21.1 / macOS arm64 / Chrome 152.0.7977.82: + +| Проверка | Результат | +| --- | --- | +| Новый cold-cache multiremote-скрипт на старом Launcher | RED: оба spec files упали с тем же отсутствующим ChromeDriver в существующем каталоге | +| Тот же скрипт после исправления, новый пустой кэш | GREEN: 2 spec files, 4 tests passed, 6 существующих skips | +| Cold-cache testrunner, пять параллельных workers | GREEN: 5 spec files, 98 tests passed, 2 существующих skips | +| Classic E2E | GREEN: 1 spec file, 2 tests passed | +| Полный unit-набор после всех правок | GREEN: 306 suites / 3485 tests passed, 1 suite / 13 tests skipped | +| Сборка utils/CLI, TypeScript обоих пакетов, ESLint изменённых файлов | GREEN | + +Команды воспроизведения — раздел 5 `scripts/security/README.md`; логи этого локального RED/GREEN — `/tmp/webdriverio-ci52-cold`, отдельные browser caches сохраняются по напечатанным скриптом путям. Параллельность не уменьшалась; E2E не отключались. Это не утверждение об успешном новом Windows/Intel macOS CI: его результат проверяется отдельно после push. + +## Следующий CI: исправление неверного scroll assertion + +[Run 34222643939](https://github.com/gemini-testing/webdriverio/actions/runs/34222643939) подтвердил устранение гонки установки: Windows Testrunner и оба Multiremote jobs прошли. Intel macOS Testrunner тоже создал все сессии, но выявил отдельную ошибку теста `should be able to scroll up and down`: после относительной прокрутки `scroll(0, -y)` тест ожидал прежнюю позицию `[x, y]` вместо правильной `[x, 0]`. Остальные 94 проверки основного spec прошли. + +Тест теперь ждёт применения wheel action по условию `scrollY === 0` и проверяет `[x, 0]`. Производственный `scroll` не менялся. Предыдущее локальное прохождение ошибочного assertion объясняется чтением позиции до применения прокрутки. + +Проверка на реальном Chrome: ожидание завершения прокрутки вместе со старым assertion воспроизвело RED (`1365` ожидалось, `0` получено); исправленный assertion дал GREEN. Контрольная временная замена upward scroll на no-op дала ожидаемое падение по условию достижения верха страницы, после чего файл восстановлен. Логи: `scroll-red.log`, `scroll-green.log`, `scroll-mutation.log` в `/tmp/webdriverio-ci52-cold`. Полный локальный Testrunner и Classic повторяются перед push; результат следующего CI проверяется отдельно. diff --git a/scripts/security/browser.mjs b/scripts/security/browser.mjs new file mode 100644 index 00000000000..e139bdf8c0c --- /dev/null +++ b/scripts/security/browser.mjs @@ -0,0 +1,115 @@ +import assert from 'node:assert/strict' +import http from 'node:http' +import { once } from 'node:events' +import path from 'node:path' +import { remote } from '@testplane/webdriverio' + +const mode = process.env.BROWSER || 'chrome' +assert.ok(['chrome', 'firefox'].includes(mode)) +const server = http.createServer((req, res) => { + res.setHeader('Cache-Control', 'no-store') + if (req.url === '/api') { + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ source: 'server' })) + } else { + res.setHeader('Content-Type', 'text/html') + res.end('Security smoke

Hello

') + } +}) +server.listen(0, '127.0.0.1') +await once(server, 'listening') +const url = `http://127.0.0.1:${server.address().port}` +let browser +try { + browser = await remote({ + cacheDir: path.resolve(process.env.BROWSER_CACHE || 'browser-cache'), + logLevel: 'warn', connectionRetryCount: 0, connectionRetryTimeout: 30000, + capabilities: { + browserName: mode, + browserVersion: process.env.BROWSER_VERSION, + 'wdio:enforceWebDriverClassic': process.env.BIDI !== '1', + [mode === 'chrome' ? 'goog:chromeOptions' : 'moz:firefoxOptions']: { + binary: process.env.BROWSER_BINARY, + args: mode === 'chrome' ? ['--headless=new', '--disable-gpu', '--no-first-run'] : ['-headless'] + } + } + }) + console.log(`${process.version}: ${mode} ${browser.capabilities.browserVersion}, BiDi=${browser.isBidi}`) + await browser.url(url) + assert.equal(await browser.getTitle(), 'Security smoke') + assert.equal(await browser.$('#hello').getText(), 'Hello') + if (mode === 'chrome') { + const pptr = await browser.getPuppeteer() + assert.equal(pptr.isConnected(), true) + assert.equal(await browser.getPuppeteer(), pptr) + + // The context calls below reproduce Testplane's existing session isolation API. + const originalHandle = await browser.getWindowHandle() + const defaultContext = pptr.defaultBrowserContext() + assert.equal(defaultContext.isIncognito(), false) + await browser.setCookies([{ name: 'security-original', value: 'yes', domain: '127.0.0.1', path: '/' }]) + const context = await pptr.createIncognitoBrowserContext() + try { + assert.equal(context.isIncognito(), true) + assert.ok(pptr.browserContexts().includes(context)) + const page = await context.newPage() + await page.goto(url) + assert.equal(await page.evaluate(() => globalThis.document.cookie), '') + const handle = (await browser.getWindowHandles()).find(id => id.includes(page.target()._targetId)) + assert.ok(handle, 'Testplane target ID to WebDriver window mapping') + await browser.switchToWindow(handle) + await page.bringToFront() + assert.equal(await browser.getTitle(), 'Security smoke') + } finally { + await context.close() + await browser.switchToWindow(originalHandle) + } + assert.ok((await browser.getCookies()).some(cookie => cookie.name === 'security-original')) + + const response = await browser.mock('**/api') + await response.respond({ source: 'mock' }) + const request = () => browser.executeAsync(done => { + fetch('/api').then(r => r.json()).then(done, error => done({ error: String(error) })) + }) + assert.deepEqual(await request(), { source: 'mock' }) + await response.restore() + assert.deepEqual(await request(), { source: 'server' }) + await browser.throttleCPU(2) + await browser.throttleCPU(1) + await browser.throttleNetwork('WiFi') + await browser.throttleNetwork('online') + console.log(`PASS isolation, target mapping, ${browser.isBidi ? 'BiDi' : 'CDP'} mock/restore, CPU/network throttle`) + } + const oldSession = browser.sessionId + await browser.reloadSession() + assert.notEqual(browser.sessionId, oldSession) + await browser.url(url) + assert.equal(await browser.getTitle(), 'Security smoke') + if (mode === 'chrome') { + // A second mock after reload proves the new session subscribed again. + const response = await browser.mock('**/api') + response.respond({ source: 'after-reload' }) + const value = await browser.executeAsync(done => { + fetch('/api').then(r => r.json()).then(done, error => done({ error: String(error) })) + }) + assert.deepEqual(value, { source: 'after-reload' }) + // A wildcard intercept must not leave unrelated requests blocked. + assert.equal(await browser.executeAsync(done => { + fetch('/unmatched').then(r => r.text()).then(text => done(text.includes('Security smoke'))) + }), true) + await response.restore() + const pptr = await browser.getPuppeteer() + await pptr.disconnect() + assert.equal(pptr.isConnected(), false) + } + console.log('PASS navigation, element, reload, disconnect') +} finally { + try { + if (browser) { + await browser.deleteSession() + } + } finally { + server.closeAllConnections() + await new Promise(resolve => server.close(resolve)) + } +} diff --git a/scripts/security/check-consumer.mjs b/scripts/security/check-consumer.mjs new file mode 100644 index 00000000000..333fa423e23 --- /dev/null +++ b/scripts/security/check-consumer.mjs @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' + +const require = createRequire(import.meta.url) +const lock = JSON.parse(readFileSync(new URL('package-lock.json', import.meta.url))) +const audit = JSON.parse(readFileSync(new URL('audit.json', import.meta.url))) +const manifest = JSON.parse(readFileSync(new URL('package.json', import.meta.url))) +assert.equal(audit.metadata.vulnerabilities.total, 0) +assert.equal(manifest.overrides, undefined) +assert.equal(manifest.devDependencies, undefined) +for (const name of ['wdio-types', 'wdio-logger', 'wdio-protocols', 'wdio-repl', 'wdio-utils', 'wdio-config', 'webdriver', 'webdriverio']) { + const key = `node_modules/@testplane/${name}` + assert.match(lock.packages[key].resolved, /^file:.*\.tgz$/) + assert.ok(!Object.keys(lock.packages).some(p => p !== key && p.endsWith(key)), `Nested old ${name}`) + const pkg = JSON.parse(readFileSync(new URL(`${key}/package.json`, import.meta.url))) + assert.ok(!Object.values(pkg.dependencies || {}).some(v => v.startsWith('workspace:'))) +} +for (const name of ['undici', 'extract-zip']) { + assert.ok(!Object.keys(lock.packages).some(p => p.endsWith(`node_modules/${name}`)), `Unexpected ${name}`) +} +for (const specifier of ['@testplane/webdriverio', '@testplane/webdriver', '@testplane/wdio-utils', '@testplane/wdio-utils/node']) { + const esm = await import(specifier) + const cjs = require(specifier) + assert.ok(esm && cjs) + for (const method of specifier === '@testplane/webdriverio' ? ['remote', 'attach', 'multiremote'] : + specifier === '@testplane/wdio-utils/node' ? ['startWebDriver', 'setupBrowser', 'setupDriver'] : []) { + assert.equal(typeof esm[method], 'function') + assert.equal(typeof cjs[method], 'function') + } +} +console.log(`PASS ${process.version}: clean packed tree, audit 0, CJS/ESM public entries`) diff --git a/scripts/security/compare-puppeteer.mjs b/scripts/security/compare-puppeteer.mjs new file mode 100644 index 00000000000..ce2a7f06c5a --- /dev/null +++ b/scripts/security/compare-puppeteer.mjs @@ -0,0 +1,137 @@ +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import { writeFileSync } from 'node:fs' +import path from 'node:path' +import http from 'node:http' +import { once } from 'node:events' +import puppeteer from 'puppeteer-core' +import { remote } from '@testplane/webdriverio' + +assert.ok(process.env.OLD_PUPPETEER_DIR, 'Set OLD_PUPPETEER_DIR to a separate npm project containing puppeteer-core@20.9.0') +const oldRequire = createRequire(path.resolve(process.env.OLD_PUPPETEER_DIR, 'package.json')) +const oldPuppeteer = oldRequire('puppeteer-core') +assert.equal(oldRequire('puppeteer-core/package.json').version, '20.9.0') +const mode = process.env.BROWSER || 'chrome' +assert.ok(['chrome', 'firefox'].includes(mode)) +const server = http.createServer((_req, res) => { + res.setHeader('Content-Type', 'text/html') + res.end('Compatibility

Same browser, two clients

') +}) +server.listen(0, '127.0.0.1') +await once(server, 'listening') +const url = `http://127.0.0.1:${server.address().port}` +const results = [] +let browser +async function probe(client, name, action) { + let timer + try { + const detail = await Promise.race([ + Promise.resolve().then(action), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${name}: timed out after 10 seconds`)), 10000) + }) + ]) + results.push({ client, name, status: 'PASS', detail }) + } catch (error) { + results.push({ client, name, status: 'FAIL', error: error.message }) + } finally { + clearTimeout(timer) + console.log(JSON.stringify(results.at(-1))) + } +} +try { + if (!process.env.CDP_BROWSER_URL) { + browser = await remote({ + cacheDir: path.resolve(process.env.BROWSER_CACHE || 'browser-cache'), + logLevel: 'warn', connectionRetryCount: 0, connectionRetryTimeout: 30000, + capabilities: { + browserName: mode, + browserVersion: process.env.BROWSER_VERSION, + 'wdio:enforceWebDriverClassic': true, + ...(mode === 'firefox' ? { 'moz:debuggerAddress': true } : {}), + [mode === 'chrome' ? 'goog:chromeOptions' : 'moz:firefoxOptions']: { + binary: process.env.BROWSER_BINARY, + args: mode === 'chrome' ? ['--headless=new', '--disable-gpu'] : ['-headless'], + ...(mode === 'firefox' ? { prefs: { 'remote.active-protocols': 3 } } : {}) + } + } + }) + await browser.url(url) + } + const endpoint = process.env.CDP_BROWSER_URL || (mode === 'chrome' + ? browser.capabilities['goog:chromeOptions'].debuggerAddress + : browser.capabilities['moz:debuggerAddress']) + assert.ok(endpoint, 'Browser must expose a CDP HTTP debugger address') + for (const [name, library] of [['20.9.0', oldPuppeteer], ['candidate', puppeteer]]) { + let client + try { + await probe(name, 'CDP connect', async () => { + client = await library.connect({ + browserURL: endpoint.startsWith('http') ? endpoint : `http://${endpoint}`, + defaultViewport: null, protocol: 'cdp', protocolTimeout: 5000 + }) + return client.version() + }) + if (!client) { + continue + } + let page + await probe(name, 'pages', async () => { + page = (await client.pages())[0] || await client.newPage() + }) + if (!page) { + results.push({ client: name, name: 'Page-dependent checks', status: 'SKIP', detail: 'No page after the failed pages probe' }) + continue + } + await probe(name, 'navigation', () => page.goto(url, { waitUntil: 'domcontentloaded', timeout: 5000 }).then(() => undefined)) + await probe(name, 'pages/evaluate/CDPSession', async () => { + assert.equal(await page.evaluate(() => globalThis.document.title), 'Compatibility') + const cdp = await page.target().createCDPSession() + try { + await cdp.send('Runtime.evaluate', { expression: '1 + 1' }) + } finally { + await cdp.detach() + } + }) + await probe(name, 'Testplane legacy context isolation', async () => { + const context = await client.createIncognitoBrowserContext() + try { + assert.equal(context.isIncognito(), true) + await context.newPage() + } finally { + await context.close() + } + }) + await probe(name, 'legacy isConnected', () => assert.equal(client.isConnected(), true)) + await probe(name, 'legacy Buffer screenshot', async () => { + assert.ok(Buffer.isBuffer(await page.screenshot()), 'Expected Buffer, not plain Uint8Array') + }) + results.push({ client: name, name: 'Page API shape', status: 'INFO', detail: { + waitForTimeout: typeof page.waitForTimeout, + $x: typeof page.$x, + waitForXPath: typeof page.waitForXPath + } }) + } finally { + if (client) { + await client.disconnect() + } + } + } +} finally { + try { + if (browser) { + await browser.deleteSession() + } + } finally { + server.closeAllConnections() + await new Promise(resolve => server.close(resolve)) + const report = { node: process.version, browser: browser?.capabilities.browserVersion, mode, results } + writeFileSync('puppeteer-comparison.json', JSON.stringify(report, null, 2) + '\n') + console.log(JSON.stringify(report, null, 2)) + } +} +// This intentionally tests raw upstream clients, not the compatibility adapter. +// Nonzero is meaningful evidence of removed APIs, not a reason to skip the probes. +if (results.some(result => result.status === 'FAIL')) { + process.exitCode = 1 +} diff --git a/scripts/security/prepare.mjs b/scripts/security/prepare.mjs new file mode 100644 index 00000000000..018165cb77e --- /dev/null +++ b/scripts/security/prepare.mjs @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { copyFileSync, mkdirSync, mkdtempSync, readdirSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const source = path.dirname(fileURLToPath(import.meta.url)) +const root = path.resolve(source, '../..') +const baseline = process.argv.includes('--baseline') +const output = mkdtempSync(path.join(tmpdir(), baseline ? 'wdio-security-before-' : 'wdio-security-after-')) +const consumer = path.join(output, 'consumer') +const tarballs = path.join(output, 'tarballs') +mkdirSync(consumer) +mkdirSync(tarballs) +console.log(`Evidence directory: ${output}`) + +function run(command, args, cwd, log, allowedStatuses = [0]) { + const result = spawnSync(command, args, { + cwd, encoding: 'utf8', timeout: 600000, maxBuffer: 32 * 1024 * 1024, + shell: process.platform === 'win32', + env: { ...process.env, HUSKY: '0', npm_config_cache: path.join(output, 'npm-cache') } + }) + writeFileSync(path.join(output, log), `${result.stdout || ''}${result.stderr || ''}`) + assert.ifError(result.error) + assert.ok(allowedStatuses.includes(result.status), `${command} failed (${result.status}); see ${path.join(output, log)}`) + return result.stdout +} + +const packages = ['wdio-types', 'wdio-logger', 'wdio-protocols', 'wdio-repl', 'wdio-utils', 'wdio-config', 'webdriver', 'webdriverio'] +writeFileSync(path.join(consumer, 'package.json'), JSON.stringify({ + name: 'testplane-security-consumer', version: '1.0.0', private: true, type: 'module' +}, null, 2) + '\n') + +let dependencies +if (baseline) { + // Historical versions are deliberately isolated from the repository and the fixed consumer. + dependencies = ['@testplane/webdriverio@9.6.2', '@testplane/wdio-utils@9.5.5', '@testplane/wdio-protocols@9.4.7'] +} else { + run('pnpm', ['run', 'compile:all:core'], root, 'build-core.log') + run('pnpm', ['exec', 'tsx', 'infra/compiler/src/index.ts', + '-p', '@testplane/wdio-utils', '-p', '@testplane/wdio-config', + '-p', '@testplane/webdriver', '-p', '@testplane/webdriverio'], root, 'build-runtime.log') + for (const name of packages) { + run('pnpm', ['pack', '--pack-destination', tarballs], path.join(root, 'packages', name), `pack-${name}.log`) + } + dependencies = readdirSync(tarballs).filter(name => name.endsWith('.tgz')).map(name => path.join(tarballs, name)) + assert.equal(dependencies.length, packages.length) +} +run('npm', ['install', '--ignore-scripts', '--no-fund', '--registry=https://registry.npmjs.org', ...dependencies], consumer, 'install.log') +// Audit's nonzero vulnerability status is expected for the historical consumer, not for the fixed one. +const audit = run('npm', ['audit', '--omit=dev', '--json', '--registry=https://registry.npmjs.org'], consumer, 'audit.log', [0, 1]) +writeFileSync(path.join(consumer, 'audit.json'), audit) +const total = JSON.parse(audit).metadata.vulnerabilities.total +assert.ok(baseline ? total > 0 : total === 0, `Unexpected vulnerability count: ${total}`) +run('npm', ['ls', '--all'], consumer, 'dependency-tree.log') +for (const name of ['check-consumer.mjs', 'browser.mjs', 'compare-puppeteer.mjs', 'public-api.ts']) { + copyFileSync(path.join(source, name), path.join(consumer, name)) +} +writeFileSync(path.join(output, 'evidence.json'), JSON.stringify({ + baseline, node: process.version, platform: process.platform, arch: process.arch, + createdAt: new Date().toISOString(), vulnerabilities: total, consumer, + revision: run('git', ['rev-parse', 'HEAD'], root, 'revision.log').trim() +}, null, 2) + '\n') +if (!baseline) { + run(process.execPath, ['check-consumer.mjs'], consumer, 'check-consumer.log') + writeFileSync(path.join(consumer, 'tsconfig.json'), JSON.stringify({ + compilerOptions: { target: 'ES2022', module: 'NodeNext', moduleResolution: 'NodeNext', strict: true, noEmit: true, skipLibCheck: true }, + files: ['public-api.ts'] + }, null, 2) + '\n') + run(process.execPath, [path.join(root, 'node_modules/typescript/bin/tsc'), '-p', 'tsconfig.json'], consumer, 'public-types.log') +} +console.log(`Audit: ${total} vulnerabilities. Consumer: ${consumer}`) +console.log(`Next: cd ${JSON.stringify(consumer)} && node browser.mjs`) diff --git a/scripts/security/public-api.ts b/scripts/security/public-api.ts new file mode 100644 index 00000000000..82f63b69019 --- /dev/null +++ b/scripts/security/public-api.ts @@ -0,0 +1,21 @@ +import { remote, type Browser } from '@testplane/webdriverio' +import { setupBrowser, setupDriver } from '@testplane/wdio-utils/node' +import type { Browser as PuppeteerBrowser, CDPSession } from 'puppeteer-core' + +async function check(browser: Browser) { + const compatible = await browser.getPuppeteer() + const modern: PuppeteerBrowser = compatible + const context = await compatible.createIncognitoBrowserContext() + const privateContext: boolean = context.isIncognito() + const connected: boolean = compatible.isConnected() + const page = await context.newPage() + const session: CDPSession = await page.target().createCDPSession() + await session.detach() + await context.close() + await modern.disconnect() + return { privateContext, connected } +} +void check +void remote +void setupBrowser +void setupDriver diff --git a/scripts/security/testrunner-cold-cache.mjs b/scripts/security/testrunner-cold-cache.mjs new file mode 100644 index 00000000000..b061d32450f --- /dev/null +++ b/scripts/security/testrunner-cold-cache.mjs @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict' +import { mkdtemp } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { Launcher } from '../../packages/wdio-cli/build/index.js' + +const configs = { + testrunner: 'wdio.conf.ts', + multiremote: 'wdio-multiremote.conf.ts', + classic: 'wdio.classic.conf.ts' +} +const mode = process.argv[2] || 'testrunner' +assert.ok(Object.hasOwn(configs, mode), 'Expected testrunner, multiremote or classic') +const e2eDir = fileURLToPath(new URL('../../e2e/', import.meta.url)) +const evidenceDir = await mkdtemp(path.join(os.tmpdir(), `wdio-cold-${mode}-`)) +process.chdir(e2eDir) +console.log(`Cold-cache evidence: ${evidenceDir}`) +const launcher = new Launcher(path.join(e2eDir, 'wdio', configs[mode]), { + cacheDir: path.join(evidenceDir, 'browser-cache'), + outputDir: evidenceDir +}) +const exitCode = await launcher.run() +assert.equal(exitCode, 0, `${mode} failed; logs and browser cache retained in ${evidenceDir}`) +console.log(`PASS: ${mode} with an initially empty cache; default worker parallelism preserved`) +// Like the CLI, exit after launcher shutdown: services may keep parent-process sockets open. +process.exit(0) diff --git a/tests/helpers/fixtures.js b/tests/helpers/fixtures.js index fae734650aa..c9bf9013dee 100644 --- a/tests/helpers/fixtures.js +++ b/tests/helpers/fixtures.js @@ -59,8 +59,10 @@ onHookEnd onTestStart onBeforeCommand onAfterCommand +onBeforeAssertion onBeforeCommand onAfterCommand +onAfterAssertion onTestPass onTestEnd onTestStart diff --git a/tests/interop/webdriverio.e2e.js b/tests/interop/webdriverio.e2e.js index d89bf406b33..8cfb4f2b9b6 100644 --- a/tests/interop/webdriverio.e2e.js +++ b/tests/interop/webdriverio.e2e.js @@ -1,5 +1,5 @@ const assert = require('node:assert') -const { remote, attach, multiremote, Key, SevereServiceError } = require('webdriverio') +const { remote, attach, multiremote, Key, SevereServiceError } = require('@testplane/webdriverio') ;(async () => { assert.equal(typeof remote, 'function') diff --git a/tests/smoke.runner.js b/tests/smoke.runner.js index 1797114249d..09c730264e8 100644 --- a/tests/smoke.runner.js +++ b/tests/smoke.runner.js @@ -4,7 +4,7 @@ import path from 'node:path' import assert from 'node:assert' import { expect } from 'expect-webdriverio' -import { SevereServiceError } from 'webdriverio' +import { SevereServiceError } from '@testplane/webdriverio' const __dirname = path.dirname(url.fileURLToPath(import.meta.url)) const baseConfig = path.resolve(__dirname, 'helpers', 'config.js') diff --git a/tests/typings/webdriverio/async.ts b/tests/typings/webdriverio/async.ts index 6e92e2d1dc3..71d4743f459 100644 --- a/tests/typings/webdriverio/async.ts +++ b/tests/typings/webdriverio/async.ts @@ -424,8 +424,8 @@ async function bar() { method: 'get', requestHeaders: { foo: 'bar' } }) - mock.abort(true) - mock.abortOnce() + mock.abort('Failed') + mock.abortOnce('Failed') mock.clear() mock.respond('/other/resource.jpg') mock.respond('/other/resource.jpg', { @@ -441,8 +441,10 @@ async function bar() { }) mock.restore() const match = mock.calls[0] - match.isBlocked - match.response.content + match.url + match.statusCode + match.responseHeaders + match.body // async chain API expectType( diff --git a/vitest.config.ts b/vitest.config.ts index b3f81d7d380..6c9f4c138b3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,7 +2,6 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { - dangerouslyIgnoreUnhandledErrors: true, include: ['packages/**/*.test.ts'], /** * not to ESM ported packages