Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/cr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,6 @@ jobs:
GITHUB_AUTH: ${{ secrets.GITHUB_TOKEN }}

- name: Publish
# PR validation builds packages without publishing preview releases.
if: github.event_name == 'push'
run: pnpx pkg-pr-new publish --pnpm './packages/*'
2 changes: 1 addition & 1 deletion .github/workflows/test-component.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test-launch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test-multiremote.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test-standalone.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test-testrunner.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test-unit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
15 changes: 5 additions & 10 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v20.11.1
v22.21.1
1 change: 1 addition & 0 deletions __mocks__/@testplane/wdio-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from '../@wdio/config.js'
1 change: 1 addition & 0 deletions __mocks__/@testplane/wdio-config/node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from '../../@wdio/config/node.js'
1 change: 1 addition & 0 deletions __mocks__/@testplane/wdio-repl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from '../@wdio/repl.js'
1 change: 1 addition & 0 deletions __mocks__/@testplane/wdio-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from '../@wdio/utils.js'
1 change: 1 addition & 0 deletions __mocks__/@testplane/wdio-utils/node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from '../../@wdio/utils/node.js'
38 changes: 36 additions & 2 deletions __mocks__/fetch.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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)
2 changes: 2 additions & 0 deletions __mocks__/puppeteer-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
130 changes: 130 additions & 0 deletions e2e/__fixtures__/pages.ts
Original file line number Diff line number Diff line change
@@ -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<void>(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('<!doctype html><title>Deferred response</title><div id="useragent"></div>')
void deferredPage.then(() => res.end('<script src="/resource.js"></script>'))
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(`<!doctype html><title>Pointer fixture</title>
<style>
#parent { width:160px; height:120px; position:relative; background:#99c0c3 }
#child { width:50%; height:50%; position:absolute; left:25%; top:25%; background:#ffde99 }
iframe { display:block; margin-top:600px; width:600px; height:300px; border:0 }
</style>
<div id="parent"><div id="child"></div></div><input id="text">
<script>
document.getElementById('parent').addEventListener('mousemove', event => {
document.getElementById('text').value = event.target.id === 'child' ? 'center' : 'out'
})
</script>
${pathname === '/pointer.html' ? '<iframe class="code-tabs__result" src="/pointer-inner.html"></iframe>' : ''}`)
return
}
if (pathname === '/contribute.html') {
res.setHeader('Content-Type', 'text/html; charset=utf-8')
res.end('<!doctype html><title>Contribute | WebdriverIO</title><h1>Contribute</h1>')
return
}
const framePages: Record<string, string> = {
'/iframe.html': '<title>Frame Demo</title><h1>Frame Demo</h1><iframe id="A" src="/iframeA.html" style="width:100%;height:192px"></iframe>',
'/iframeA.html': '<title>IFrame A</title><style>h2{margin:0}body{margin:0;white-space:nowrap}</style><h2>IFrame A</h2><iframe id="A1" src="/iframeA1.html" width="180" height="85"></iframe><iframe id="A2" src="/iframeA2.html" width="180" height="85"></iframe>',
'/iframeA1.html': '<title>IFrame A1</title><h3>IFrame A1</h3>',
'/iframeA2.html': '<title>IFrame A2</title><style>html{height:100%}body,h3{margin:0}</style><h3>IFrame A2</h3>',
'/nested_frames': '<frameset><frame src="/frame_top"></frameset>',
'/frame_top': '<frameset cols="50%,50%"><frame src="/frame_left"><frame src="/frame_right"></frameset>',
'/frame_left': '<h1>Left frame</h1>',
'/frame_right': '<h1>Right frame</h1>',
'/iframe': '<iframe src="/editor.html"></iframe>',
'/editor.html': '<div id="tinymce" contenteditable="true">Editable frame</div>',
'/iframeNavigation.html': '<iframe src="/iframeNavigationInner.html"></iframe>',
'/iframeNavigationInner.html': '<a href="/iframeTarget.html" target="_top">Navigate top</a><button onclick="top.location.href=\'/iframeTarget.html\'">Navigate with location</button>',
'/iframeTarget.html': '<h1>Iframe Target</h1>',
}
if (pathname in framePages) {
res.setHeader('Content-Type', 'text/html; charset=utf-8')
res.end(`<!doctype html>${framePages[pathname]}`)
return
}
if (pathname === '/shadow.html') {
res.setHeader('Content-Type', 'text/html; charset=utf-8')
res.end(`<!doctype html><h1>Shadow DOM</h1><div id="host"><ul slot="my-text"><li>In a list!</li></ul></div>
<script>
document.getElementById('host').attachShadow({ mode: 'open' }).innerHTML =
'<slot name="my-text"></slot><input class="new-todo" placeholder="What needs to be done?">'
</script>`)
return
}
if (pathname === '/reloadCounter.html') {
res.setHeader('Content-Type', 'text/html; charset=utf-8')
res.end(`<!doctype html><section id="reloadCounter"><input id="counter" type="number" disabled><button id="reset">Reset</button></section>
<script>
const counter = document.getElementById('counter')
const reloads = Number(localStorage.getItem('reloadCounter') || 0)
counter.value = reloads
localStorage.setItem('reloadCounter', String(reloads + 1))
document.getElementById('reset').onclick = () => {
counter.value = 0
localStorage.removeItem('reloadCounter')
}
</script>`)
return
}
if (pathname === '/other.html') {
res.setHeader('Content-Type', 'text/html; charset=utf-8')
res.end('<!doctype html><title>Another test page</title><h1 class="hero__subtitle">Another page</h1>')
return
}
const title = pathname === '/two.html' ? 'two' : 'WebdriverJS Testpage'
res.setHeader('Content-Type', 'text/html; charset=utf-8')
res.end(`<!doctype html><title>${title}</title><h1>${title}</h1>
<h1 class="findme">Test CSS Attributes</h1>
<div class="red">Red box</div>
<button aria-label="Toggle navigation bar" onclick="document.querySelector('.navbar-sidebar').hidden = false">Menu</button>
<nav class="navbar-sidebar" hidden>
<a class="menu__link" href="/">Home</a>
<a class="menu__link" href="/two.html">Second page</a>
<a class="menu__link" href="/contribute.html">Contribute</a>
</nav>
<input class="searchinput" style="display:block; margin-top:1500px; margin-left:1200px">
<footer>Footer</footer>
<div id="useragent"></div>
<script src="/resource.js"></script>
${pathname === '/basic_auth' ? '<p>Congratulations! You must have the proper credentials.</p>' : ''}
${pathname === '/two.html'
? '<div id="second-window">Second window</div><div class="page">Second page!</div>'
: '<a id="newWindow" href="/two.html" target="_blank">Open new tab</a><a id="open-window" href="/two.html" target="_blank">Open second window</a>'}`)
})
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<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
}
}
}
63 changes: 63 additions & 0 deletions e2e/__fixtures__/pwa.ts
Original file line number Diff line number Diff line change
@@ -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('<!doctype html><title>Not a PWA</title><h1>Plain rendered page</h1><p>No manifest</p>')
}
res.end(`<!doctype html><html lang="en"><head>
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="theme-color" content="#ea5906">
<link rel="manifest" href="/manifest.json">
<link rel="apple-touch-icon" href="/icon-192x192.png">
<title>Local WebdriverIO PWA</title>
</head><body><h1>Local installable app</h1><p>Real Lighthouse audit fixture.</p>
<script>
navigator.serviceWorker.register('/sw.js').then(() => navigator.serviceWorker.ready)
.then(() => { document.body.dataset.ready = 'true'; });
</script></body></html>`)
})
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<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
}
}
}
Loading
Loading