feat(webdriverio): support fake timers (#13252)

* feat(webdriverio): support fake timers

* finish implementation and add tests

* fix test

* add docs

* change __dirname

* minor test improvements

* resolve issues within tests

* add comment

* add typing tests

* more docs
This commit is contained in:
Christian Bromann 2024-08-01 23:57:16 -07:00 committed by GitHub
parent d1ba15487f
commit a79210972e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 2546 additions and 65 deletions

View file

@ -7,3 +7,4 @@
*.d.ts
!packages/**/src/cjs
packages/wdio-protocols/src/protocols/webdriverBidi.ts
packages/webdriverio/third_party/fake-timers.js

View file

@ -356,4 +356,27 @@ describe('main suite 1', () => {
expect(await browser.execute(() => Math.random())).not.toBe(42)
})
})
describe('emulate clock', () => {
const now = new Date(2021, 3, 14)
const getDateString = () => (new Date()).toString()
it('should allow to mock the clock', async () => {
await browser.emulate('clock', { now })
expect(await browser.execute(getDateString))
.toBe(now.toString())
await browser.url('http://guinea-pig.webdriver.io')
expect(await browser.execute(getDateString))
.toBe(now.toString())
})
it('should allow to restore the clock', async () => {
await browser.restore('clock')
expect(await browser.execute(getDateString))
.not.toBe(now.toString())
await browser.url('http://guinea-pig.webdriver.io/pointer.html')
expect(await browser.execute(getDateString))
.not.toBe(now.toString())
})
})
})

View file

@ -56,7 +56,7 @@ export default class ProxyDriver {
const environment = sessionEnvironmentDetector({ capabilities: params.capabilities, requestedCapabilities: {} })
const environmentPrototype: Record<string, PropertyDescriptor> = getEnvironmentVars(environment)
// have debug command
const commandsProcessedInNodeWorld = [...commands, 'debug', 'saveScreenshot', 'savePDF']
const commandsProcessedInNodeWorld = [...commands, 'debug', 'saveScreenshot', 'savePDF', 'emulate', 'restore']
const protocolCommands = commandsProcessedInNodeWorld.reduce((prev, commandName) => {
prev[commandName] = {
value: this.#getMockedCommand(commandName)

View file

@ -2,6 +2,7 @@ import logger from '@wdio/logger'
import type { ClientOptions, RawData, WebSocket } from 'ws'
import Socket from './socket.js'
import type * as remote from './remoteTypes.js'
import type { CommandData } from './remoteTypes.js'
import type { CommandResponse } from './localTypes.js'
@ -85,9 +86,20 @@ export class BidiCore {
throw new Error('No connection to WebDriver Bidi was established')
}
log.info('BIDI COMMAND', params.method, JSON.stringify(params.params))
log.info('BIDI COMMAND', ...parseBidiCommand(params))
const id = ++this.#id
this.#ws.send(JSON.stringify({ id, ...params }))
return id
}
}
function parseBidiCommand (params: Omit<CommandData, 'id'>) {
const commandName = params.method
if (commandName === 'script.addPreloadScript') {
const param = params.params as remote.ScriptAddPreloadScriptParameters
const logString = `{ functionDeclaration: <PreloadScript[${Buffer.byteLength(param.functionDeclaration, 'utf-8')} bytes]>, contexts: ${JSON.stringify(param.contexts)} }`
return [commandName, logString]
}
return [commandName, JSON.stringify(params.params)]
}

View file

@ -60,6 +60,7 @@
"types": "./build/index.d.ts",
"typeScriptVersion": "3.8.3",
"devDependencies": {
"@sinonjs/fake-timers": "^11.2.2",
"@types/archiver": "^6.0.2",
"@types/aria-query": "^5.0.4",
"@types/lodash.clonedeep": "^4.5.9",
@ -68,6 +69,7 @@
},
"dependencies": {
"@types/node": "^20.11.30",
"@types/sinonjs__fake-timers": "^8.1.5",
"@wdio/config": "workspace:*",
"@wdio/logger": "workspace:*",
"@wdio/protocols": "workspace:*",

View file

@ -0,0 +1,164 @@
import logger from '@wdio/logger'
import type { FakeTimerInstallOpts, InstalledClock, install } from '@sinonjs/fake-timers'
const log = logger('webdriverio:ClockManager')
declare global {
interface Window {
__clock: InstalledClock
__wdio_sinon: {
install: typeof install
}
}
}
function installFakeTimers (options: FakeTimerInstallOpts) {
window.__clock = window.__wdio_sinon.install(options)
}
function uninstallFakeTimers () {
window.__clock.uninstall()
}
export class ClockManager {
#browser: WebdriverIO.Browser
#resetFn: (() => Promise<any>) = () => Promise.resolve()
#isInstalled = false
constructor(browser: WebdriverIO.Browser) {
this.#browser = browser
}
/**
* Install fake timers on the browser. If you call the `emulate` command, WebdriverIO will automatically install
* the fake timers for you. You can use this method to re-install the fake timers if you have called `restore`.
*
* @param options {FakeTimerInstallOpts} Options to pass to the fake clock
* @returns {Promise<void>}
*/
async install(options?: FakeTimerInstallOpts) {
if (this.#isInstalled) {
return log.warn('Fake timers are already installed')
}
/**
* skip if we are not running in a Node.js environment
*/
if (globalThis.window) {
return
}
/**
* load Node.js specific modules dynamically to avoid loading them in the browser
*/
const url = await import('node:url')
const path = await import('node:path')
const fs = await import('node:fs/promises')
const __dirname = path.dirname(url.fileURLToPath(import.meta.url))
const rootDir = path.resolve(__dirname, '..')
const emulateOptions = options || {} as FakeTimerInstallOpts
const scriptPath = path.join(rootDir, 'third_party', 'fake-timers.js')
const functionDeclaration = await fs.readFile(scriptPath, 'utf-8')
const installOptions: FakeTimerInstallOpts = {
...emulateOptions,
now: emulateOptions.now && (emulateOptions.now instanceof Date) ? emulateOptions.now.getTime() : emulateOptions.now
}
const [, libScript, restoreInstallScript] = await Promise.all([
/**
* install fake timers for current ex
*/
this.#browser.execute(`return (${functionDeclaration}).apply(null, arguments)`, []).then(() => (
this.#browser.execute(installFakeTimers, installOptions)
)),
/**
* add preload script to to emulate clock for upcoming page loads
*/
this.#browser.scriptAddPreloadScript({ functionDeclaration }),
this.#browser.addInitScript(installFakeTimers, installOptions)
])
this.#resetFn = async () => Promise.all([
this.#browser.scriptRemovePreloadScript({ script: libScript.script }),
this.#browser.execute(uninstallFakeTimers),
restoreInstallScript
])
this.#isInstalled = true
}
/**
* Restore all overridden native functions. This is automatically called between tests, so should not
* generally be needed.
*
* ```ts
* it('should restore the clock', async () => {
* console.log(new Date()) // returns e.g. 1722560447102
*
* const clock = await browser.emulate('clock', { now: new Date(2021, 3, 14) })
* console.log(await browser.execute(() => new Date().getTime())) // returns 1618383600000
*
* await clock.restore()
* console.log(await browser.execute(() => new Date().getTime())) // returns 1722560447102
* })
* ```
*
* @returns {Promise<void>}
*/
async restore() {
await this.#resetFn()
this.#isInstalled = false
}
/**
* Move the clock the specified number of `milliseconds`. Any timers within the affected range of time will be called.
* @param ms {number} The number of milliseconds to move the clock.
*
* ```ts
* it('should move the clock', async () => {
* console.log(new Date()) // returns e.g. 1722560447102
*
* const clock = await browser.emulate('clock', { now: new Date(2021, 3, 14) })
* console.log(await browser.execute(() => new Date().getTime())) // returns 1618383600000
*
* await clock.tick(1000)
* console.log(await browser.execute(() => new Date().getTime())) // returns 1618383601000
* })
* ```
*
* @param {number} ms The number of milliseconds to move the clock.
* @returns {Promise<void>}
*/
async tick(ms: number) {
await this.#browser.execute((ms) => window.__clock.tick(ms), ms)
}
/**
* Change the system time to the new now. Now can be a timestamp, date object, or not passed in which defaults
* to 0. No timers will be called, nor will the time left before they trigger change.
*
* ```ts
* it('should set the system time', async () => {
* const clock = await browser.emulate('clock', { now: new Date(2021, 3, 14) })
* console.log(await browser.execute(() => new Date().getTime())) // returns 1618383600000
*
* await clock.setSystemTime(new Date(2011, 3, 15))
* console.log(await browser.execute(() => new Date().getTime())) // returns 1302850800000
* })
* ```
*
* @param date {Date|number} The new date to set the system time to.
* @returns {Promise<void>}
*/
async setSystemTime(date: number | Date) {
const serializableSystemTime = date instanceof Date ? date.getTime() : date
await this.#browser.execute((date) => window.__clock.setSystemTime(date), serializableSystemTime)
}
}
export type SupportedScopes = 'geolocation' | 'userAgent' | 'colorScheme' | 'onLine' | 'clock'
export type RestoreMap = Map<SupportedScopes, (() => Promise<any>)[]>
/**
* store all preload scripts in a map (per instance) so that we can easily remove them
*/
export const restoreFunctions = new Map<WebdriverIO.Browser, RestoreMap>()

View file

@ -24,6 +24,7 @@ export * from './browser/pause.js'
export * from './browser/react$$.js'
export * from './browser/react$.js'
export * from './browser/reloadSession.js'
export * from './browser/restore.js'
export * from './browser/savePDF.js'
export * from './browser/saveRecordingScreen.js'
export * from './browser/saveScreenshot.js'

View file

@ -58,7 +58,7 @@ export async function addInitScript<ReturnValue, InnerArguments extends any[]> (
const context = await this.getWindowHandle()
const result = await this.scriptAddPreloadScript({
functionDeclaration: `() => {
const closure = new Function('return ${script.toString()}')
const closure = new Function(\`return ${script.toString()}\`)
return closure()(${serializedParameters.join(', ')})
}`,
arguments: [],

View file

@ -1,21 +1,52 @@
type SupportedScopes = 'geolocation' | 'userAgent' | 'colorScheme' | 'onLine'
import type { FakeTimerInstallOpts } from '@sinonjs/fake-timers'
import { type SupportedScopes, restoreFunctions, ClockManager } from '../../clock.js'
type RestoreFunction = () => Promise<any>
type ColorScheme = 'light' | 'dark'
interface EmulationOptions {
geolocation: Partial<GeolocationCoordinates>
userAgent: string
colorScheme: 'light' | 'dark'
colorScheme: ColorScheme
onLine: boolean
clock?: FakeTimerInstallOpts
}
function storeRestoreFunction (browser: WebdriverIO.Browser, scope: SupportedScopes, fn: RestoreFunction) {
if (!restoreFunctions.has(browser)) {
restoreFunctions.set(browser, new Map())
}
const restoreFunctionsList = restoreFunctions.get(browser)?.get(scope)
const updatedList = restoreFunctionsList ? [...restoreFunctionsList, fn] : [fn]
restoreFunctions.get(browser)?.set(scope, updatedList)
}
export async function emulate(scope: 'clock', options?: FakeTimerInstallOpts): Promise<ClockManager>
export async function emulate(scope: 'geolocation', geolocation: Partial<GeolocationCoordinates>): Promise<RestoreFunction>
export async function emulate(scope: 'userAgent', userAgent: string): Promise<RestoreFunction>
export async function emulate(scope: 'colorScheme', colorScheme: ColorScheme): Promise<RestoreFunction>
export async function emulate(scope: 'onLine', state: boolean): Promise<RestoreFunction>
/**
* WebdriverIO allows you to emulate Web APIs using the `emulate` command. These Web APIs can then
* behave exactly as you specify it.
* behave exactly as you specify it. The following scopes are supported:
*
* - `geolocation`: Emulate the geolocation API
* - `userAgent`: Emulate the user agent
* - `colorScheme`: Emulate the color scheme
* - `onLine`: Emulate the online status
* - `clock`: Emulate the system clock
*
* The `emulate` command returns a function that can be called to reset the emulation. This is useful
* when you want to reset the emulation after a test or a suite of tests.
*
* Read more on this in the [Emulation](/docs/emulation) guidelines.
*
* :::info
*
* It is not possible to change the emulated value without reloading the page.
* Except for the `clock` scope it is not possible to change the emulated value without reloading the page.
*
* :::
*
@ -27,11 +58,57 @@ interface EmulationOptions {
*
* :::
*
* @param {string} scope feature of the browser you like to emulate, can be either `geolocation`, `userAgent`, `colorScheme` or `onLine`
* Based on the scope you can pass different options:
*
* | Scope | Options |
* |---------------|--------------------------------------------------|
* | `geolocation` | `{ latitude: number, longitude: number }` |
* | `userAgent` | `string` |
* | `colorScheme` | `'light' | 'dark'` |
* | `onLine` | `boolean` |
* | `clock` | `FakeTimerInstallOpts` |
*
* The `FakeTimerInstallOpts` object can have the following properties:
*
* ```ts
* interface FakeTimerInstallOpts {
* // Installs fake timers with the specified unix epoch
* // @default: 0
* now?: number | Date | undefined;
* // An array with names of global methods and APIs to fake. By default, WebdriverIO
* // does not replace `nextTick()` and `queueMicrotask()`. For instance,
* // `browser.emulate('clock', { toFake: ['setTimeout', 'nextTick'] })` will fake only
* // `setTimeout()` and `nextTick()`
* toFake?: FakeMethod[] | undefined;
* // The maximum number of timers that will be run when calling runAll() (default: 1000)
* loopLimit?: number | undefined;
* // Tells WebdriverIO to increment mocked time automatically based on the real system
* // time shift (e.g. the mocked time will be incremented by 20ms for every 20ms change
* // in the real system time)
* // @default false
* shouldAdvanceTime?: boolean | undefined;
* // Relevant only when using with shouldAdvanceTime: true. increment mocked time by
* // advanceTimeDelta ms every advanceTimeDelta ms change in the real system time
* // @default: 20
* advanceTimeDelta?: number | undefined;
* // Tells FakeTimers to clear 'native' (i.e. not fake) timers by delegating to their
* // respective handlers. These are not cleared by default, leading to potentially
* // unexpected behavior if timers existed prior to installing FakeTimers.
* // @default: false
* shouldClearNativeTimers?: boolean | undefined;
* }
* ```
*
* @param {string} scope feature of the browser you like to emulate, can be either `clock`, `geolocation`, `userAgent`, `colorScheme` or `onLine`
* @param {EmulationOptions} options emulation option for specific scope
* @example https://github.com/webdriverio/example-recipes/blob/9bff2baf8a0678c6886f8591d9fc8dea201895d3/emulate/example.js#L4-L18
* @example https://github.com/webdriverio/example-recipes/blob/9bff2baf8a0678c6886f8591d9fc8dea201895d3/emulate/example.js#L20-L36
* @returns `void`
* @returns {Function} a function to reset the emulation
*/
export async function emulate<Scope extends SupportedScopes> (
this: WebdriverIO.Browser,
@ -53,14 +130,16 @@ export async function emulate<Scope extends SupportedScopes> (
coords: ${JSON.stringify(options)},
timestamp: Date.now()
})`
await this.scriptAddPreloadScript({
const res = await this.scriptAddPreloadScript({
functionDeclaration: /*js*/`() => {
Object.defineProperty(navigator.geolocation, 'getCurrentPosition', {
value: (cbSuccess, cbError) => ${patchedFn}
})
}`
})
return
const resetFn = async () => this.scriptRemovePreloadScript({ script: res.script })
storeRestoreFunction(this, 'geolocation', resetFn)
return resetFn
}
if (scope === 'userAgent') {
@ -68,14 +147,25 @@ export async function emulate<Scope extends SupportedScopes> (
throw new Error(`Expected userAgent emulation options to be a string, received ${typeof options}`)
}
await this.scriptAddPreloadScript({
const res = await this.scriptAddPreloadScript({
functionDeclaration: /*js*/`() => {
Object.defineProperty(navigator, 'userAgent', {
value: ${JSON.stringify(options)}
})
}`
})
return
const resetFn = async () => {
return this.scriptRemovePreloadScript({ script: res.script })
}
storeRestoreFunction(this, 'userAgent', resetFn)
return resetFn
}
if (scope === 'clock') {
const clock = new ClockManager(this)
await clock.install(options as FakeTimerInstallOpts)
storeRestoreFunction(this, 'clock', clock.restore.bind(clock))
return clock
}
if (scope === 'colorScheme') {
@ -83,7 +173,7 @@ export async function emulate<Scope extends SupportedScopes> (
throw new Error(`Expected "colorScheme" emulation options to be either "light" or "dark", received "${options}"`)
}
await this.scriptAddPreloadScript({
const res = await this.scriptAddPreloadScript({
functionDeclaration: /*js*/`() => {
const originalMatchMedia = window.matchMedia
Object.defineProperty(window, 'matchMedia', {
@ -104,7 +194,9 @@ export async function emulate<Scope extends SupportedScopes> (
})
}`
})
return
const resetFn = async () => this.scriptRemovePreloadScript({ script: res.script })
storeRestoreFunction(this, 'colorScheme', resetFn)
return resetFn
}
if (scope === 'onLine') {
@ -112,15 +204,17 @@ export async function emulate<Scope extends SupportedScopes> (
throw new Error(`Expected "onLine" emulation options to be a boolean, received "${typeof options}"`)
}
await this.scriptAddPreloadScript({
const res = await this.scriptAddPreloadScript({
functionDeclaration: /*js*/`() => {
Object.defineProperty(navigator, 'onLine', {
value: ${options}
})
}`
})
return
const resetFn = async () => this.scriptRemovePreloadScript({ script: res.script })
storeRestoreFunction(this, 'onLine', resetFn)
return resetFn
}
return
throw new Error(`Invalid scope "${scope}", expected one of "geolocation", "userAgent", "colorScheme", "onLine" or "clock"`)
}

View file

@ -0,0 +1,49 @@
import { type SupportedScopes, restoreFunctions } from '../../clock.js'
/**
*
* This command restores specific or all emulated behaviors that were set using the `emulate` command.
*
* <example>
:restore.js
before(async () => {
await browser.emulate('geolocation', { latitude: 52.52, longitude: 13.405 })
await browser.emulate('userAgent', 'foobar')
await browser.emulate('colorScheme', 'dark')
await browser.emulate('onLine', false)
})
it('should restore all emulated behavior', async () => {
await browser.url('https://webdriver.io')
// test within an emulated environment...
})
after(async () => {
// restore all emulated behavior
await browser.restore()
// or only restore specific emulated behavior
// await browser.restore(['geolocation', 'userAgent'])
})
* </example>
*
* @alias browser.restore
* @type utility
*
*/
export async function restore (
this: WebdriverIO.Browser,
scopes?: SupportedScopes | SupportedScopes[]
) {
const scopeArray = !scopes || Array.isArray(scopes) ? scopes : [scopes]
const instanceRestoreFunctions = restoreFunctions.get(this)
if (!instanceRestoreFunctions) {
return
}
await Promise.all(Array.from(instanceRestoreFunctions.entries()).map(async ([scope, restoreFunctionsList]) => {
if (!scopeArray || scopeArray.includes(scope)) {
await Promise.all(restoreFunctionsList.map((fn) => fn()))
instanceRestoreFunctions.set(scope, [])
}
}))
}

View file

@ -0,0 +1,19 @@
/**
* Restore all overridden native functions. This is automatically called between tests, so should not
* generally be needed.
*
* <example>
:restore.js
console.log(new Date()) // returns e.g. 1722560447102
const clock = await browser.emulate('clock', { now: new Date(2021, 3, 14) })
console.log(await browser.execute(() => new Date().getTime())) // returns 1618383600000
await clock.restore()
console.log(await browser.execute(() => new Date().getTime())) // returns 1722560447102
* </example>
*
* @alias clock.restore
* @returns { Promise<void> }
*/
// actual implementation is located in packages/webdriverio/src/clock.ts

View file

@ -0,0 +1,18 @@
/**
* Change the system time to the new now. Now can be a timestamp, date object, or not passed in which defaults
* to 0. No timers will be called, nor will the time left before they trigger change.
*
* <example>
:setSystemTime.js
const clock = await browser.emulate('clock', { now: new Date(2021, 3, 14) })
console.log(await browser.execute(() => new Date().getTime())) // returns 1618383600000
await clock.setSystemTime(new Date(2011, 3, 15))
console.log(await browser.execute(() => new Date().getTime())) // returns 1302850800000
* </example>
*
* @alias clock.setSystemTime
* @param { Date | number } date The new date to set the system time to.
* @returns { Promise<void> }
*/
// actual implementation is located in packages/webdriverio/src/clock.ts

View file

@ -0,0 +1,18 @@
/**
* Move the clock the specified number of `milliseconds`. Any timers within the affected range of time will be called.
*
* <example>
:tick.js
const clock = await browser.emulate('clock', { now: new Date(2021, 3, 14) })
console.log(await browser.execute(() => new Date().getTime())) // returns 1618383600000
await clock.tick(1000)
console.log(await browser.execute(() => new Date().getTime())) // returns 1618383601000
* </example>
*
* @alias clock.tick
* @param { number } ms The number of milliseconds to move the clock.
* @returns { Promise<void> }
*/
// actual implementation is located in packages/webdriverio/src/clock.ts

View file

@ -2,11 +2,11 @@ import type { RectReturn } from '@wdio/protocols'
import { getElementRect } from '../../utils/index.js'
export type Size = Pick<RectReturn, 'width' | 'height'>;
export type Size = Pick<RectReturn, 'width' | 'height'>
export function getSize (this: WebdriverIO.Element): Promise<Size>;
export function getSize (this: WebdriverIO.Element, prop: keyof RectReturn): Promise<number>;
export function getSize (this: WebdriverIO.Element, prop?: keyof RectReturn): Promise<Size & number>;
export function getSize (this: WebdriverIO.Element): Promise<Size>
export function getSize (this: WebdriverIO.Element, prop: keyof RectReturn): Promise<number>
export function getSize (this: WebdriverIO.Element, prop?: keyof RectReturn): Promise<Size & number>
/**
*

View file

@ -1,88 +1,96 @@
import path from 'node:path'
import { expect, describe, it, vi } from 'vitest'
import { remote } from '../../../src/index.js'
vi.mock('fetch')
vi.mock('@wdio/logger', () => import(path.join(process.cwd(), '__mocks__', '@wdio/logger')))
const browser = await remote({
baseUrl: 'http://foobar.com',
capabilities: {
browserName: 'foobar'
}
})
describe('emulate', () => {
it('should fail if bidi is not supported', async () => {
const browser = await remote({
baseUrl: 'http://foobar.com',
capabilities: {
browserName: 'foobar'
}
})
// @ts-expect-error invalid argument
await expect(() => browser.emulate('geoLocation', {}))
.rejects.toThrow(/emulate command is only supported for Bidi/)
})
it('should allow to emulate geolocation', async () => {
const browser = await remote({
baseUrl: 'http://foobar.com',
capabilities: {
browserName: 'foobar'
}
})
const fakeScope = {
isBidi: true,
scriptAddPreloadScript: vi.fn(),
scriptAddPreloadScript: vi.fn().mockResolvedValue({ script: 'foobar' }),
scriptRemovePreloadScript: vi.fn(),
options: {
beforeCommand: vi.fn(),
afterCommand: vi.fn()
}
}
await expect(() => browser.emulate.call(fakeScope, 'geolocation')).rejects.toThrow(/Missing geolocation emulation options/)
await browser.emulate.call(fakeScope, 'geolocation', { latitude: 123, longitude: 456 })
} as any as WebdriverIO.Browser
fakeScope.emulate = browser.emulate.bind(fakeScope)
// @ts-expect-error missing argument
await expect(() => fakeScope.emulate('geolocation')).rejects.toThrow(/Missing geolocation emulation options/)
const restore = await fakeScope.emulate('geolocation', { latitude: 123, longitude: 456 })
expect(fakeScope.scriptAddPreloadScript).toBeCalledTimes(1)
expect(fakeScope.scriptAddPreloadScript).toBeCalledWith({
functionDeclaration: expect.stringContaining('Object.defineProperty(navigator.geolocation, \'getCurrentPosition\', {')
})
expect(restore).toBeInstanceOf(Function)
await restore()
expect(fakeScope.scriptRemovePreloadScript).toBeCalledTimes(1)
})
it('should allow to emulate userAgent', async () => {
const browser = await remote({
baseUrl: 'http://foobar.com',
capabilities: {
browserName: 'foobar'
}
})
const fakeScope = {
isBidi: true,
scriptAddPreloadScript: vi.fn(),
scriptAddPreloadScript: vi.fn().mockResolvedValue({ script: 'foobar' }),
scriptRemovePreloadScript: vi.fn(),
options: {
beforeCommand: vi.fn(),
afterCommand: vi.fn()
}
}
await expect(() => browser.emulate.call(fakeScope, 'userAgent', 123)).rejects.toThrow(/Expected userAgent emulation options to be a string/)
await browser.emulate.call(fakeScope, 'userAgent', 'foobar')
} as any as WebdriverIO.Browser
fakeScope.emulate = browser.emulate.bind(fakeScope)
// @ts-expect-error invalid argument
await expect(() => fakeScope.emulate('userAgent', 123)).rejects.toThrow(/Expected userAgent emulation options to be a string/)
const restore = await fakeScope.emulate('userAgent', 'foobar')
expect(fakeScope.scriptAddPreloadScript).toBeCalledTimes(1)
expect(fakeScope.scriptAddPreloadScript).toBeCalledWith({
functionDeclaration: expect.stringContaining('Object.defineProperty(navigator, \'userAgent\', {')
})
expect(restore).toBeInstanceOf(Function)
await restore()
expect(fakeScope.scriptRemovePreloadScript).toBeCalledTimes(1)
})
it('should allow to emulate colorScheme', async () => {
const browser = await remote({
baseUrl: 'http://foobar.com',
capabilities: {
browserName: 'foobar'
}
})
const fakeScope = {
isBidi: true,
scriptAddPreloadScript: vi.fn(),
scriptAddPreloadScript: vi.fn().mockResolvedValue({ script: 'foobar' }),
scriptRemovePreloadScript: vi.fn(),
options: {
beforeCommand: vi.fn(),
afterCommand: vi.fn()
}
}
await expect(() => browser.emulate.call(fakeScope, 'colorScheme', 123)).rejects.toThrow(/Expected "colorScheme" emulation options to be either "light" or "dark"/)
await browser.emulate.call(fakeScope, 'colorScheme', 'light')
} as any as WebdriverIO.Browser
fakeScope.emulate = browser.emulate.bind(fakeScope)
// @ts-expect-error invalid argument
await expect(() => fakeScope.emulate('colorScheme', 123)).rejects.toThrow(/Expected "colorScheme" emulation options to be either "light" or "dark"/)
const restore = await fakeScope.emulate('colorScheme', 'light')
expect(fakeScope.scriptAddPreloadScript).toBeCalledTimes(1)
expect(fakeScope.scriptAddPreloadScript).toBeCalledWith({
functionDeclaration: expect.stringContaining('Object.defineProperty(window, \'matchMedia\', {')
})
expect(restore).toBeInstanceOf(Function)
await restore()
expect(fakeScope.scriptRemovePreloadScript).toBeCalledTimes(1)
})
it('should allow to emulate onLine', async () => {
@ -94,17 +102,63 @@ describe('emulate', () => {
})
const fakeScope = {
isBidi: true,
scriptAddPreloadScript: vi.fn(),
scriptAddPreloadScript: vi.fn().mockResolvedValue({ script: 'foobar' }),
scriptRemovePreloadScript: vi.fn(),
options: {
beforeCommand: vi.fn(),
afterCommand: vi.fn()
}
}
await expect(() => browser.emulate.call(fakeScope, 'onLine', 123)).rejects.toThrow(/Expected "onLine" emulation options to be a boolean/)
await browser.emulate.call(fakeScope, 'onLine', false)
} as any as WebdriverIO.Browser
fakeScope.emulate = browser.emulate.bind(fakeScope)
// @ts-expect-error invalid
await expect(() => fakeScope.emulate('onLine', 123)).rejects.toThrow(/Expected "onLine" emulation options to be a boolean/)
const restore = await fakeScope.emulate('onLine', false)
expect(fakeScope.scriptAddPreloadScript).toBeCalledTimes(1)
expect(fakeScope.scriptAddPreloadScript).toBeCalledWith({
functionDeclaration: expect.stringContaining('Object.defineProperty(navigator, \'onLine\', {')
})
expect(restore).toBeInstanceOf(Function)
await restore()
expect(fakeScope.scriptRemovePreloadScript).toBeCalledTimes(1)
})
it('should allow to emulate the clock', async () => {
const now = new Date(2021, 3, 14)
const browser = await remote({
baseUrl: 'http://foobar.com',
capabilities: {
browserName: 'foobar'
}
})
const fakeScope = {
isBidi: true,
scriptAddPreloadScript: vi.fn().mockResolvedValue({ script: 'foobar' }),
scriptRemovePreloadScript: vi.fn(),
addInitScript: vi.fn(),
execute: vi.fn().mockResolvedValue({}),
options: {
beforeCommand: vi.fn(),
afterCommand: vi.fn()
},
} as any as WebdriverIO.Browser
fakeScope.emulate = browser.emulate.bind(fakeScope)
const clock = await fakeScope.emulate('clock', { now })
expect(fakeScope.execute).toBeCalledTimes(2)
expect(fakeScope.addInitScript).toBeCalledTimes(1)
expect(fakeScope.scriptAddPreloadScript).toBeCalledTimes(1)
expect(fakeScope.scriptAddPreloadScript).toBeCalledWith({
functionDeclaration: expect.stringContaining('function hijackMethod(target, method, clock) ')
})
expect(fakeScope.addInitScript).toBeCalledWith(
expect.any(Function),
expect.objectContaining({ now: now.getTime() })
)
expect(clock.restore).toBeInstanceOf(Function)
await clock.restore()
expect(fakeScope.scriptRemovePreloadScript).toBeCalledTimes(1)
})
})

View file

@ -0,0 +1,69 @@
import path from 'node:path'
import { vi, describe, it, expect, beforeEach } from 'vitest'
import { remote } from '../../../src/index.js'
vi.mock('@wdio/logger', () => import(path.join(process.cwd(), '__mocks__', '@wdio/logger')))
const browserA = await remote({
baseUrl: 'http://foobar.com',
capabilities: {
browserName: 'foobar'
}
})
const browserB = await remote({
baseUrl: 'http://foobar.com',
capabilities: {
browserName: 'foobar'
}
})
const fakeScope = {
isBidi: true,
scriptAddPreloadScript: vi.fn().mockResolvedValue({ script: 'foobar' }),
scriptRemovePreloadScript: vi.fn(),
addInitScript: vi.fn(),
execute: vi.fn().mockResolvedValue({}),
options: {
beforeCommand: vi.fn(),
afterCommand: vi.fn()
}
} as any as WebdriverIO.Browser
const scopeBrowserA = {
...fakeScope,
emulate: browserA.emulate.bind(browserA)
}
const scopeBrowserB = {
...fakeScope,
emulate: browserB.emulate.bind(browserB)
}
describe('restore', () => {
beforeEach(() => {
vi.mocked(fakeScope.scriptRemovePreloadScript).mockClear()
})
it('should restore all emulated behavior', async () => {
await browserA.emulate.call(scopeBrowserA, 'geolocation', { latitude: 52.52, longitude: 13.405 })
await browserA.emulate.call(scopeBrowserA, 'userAgent', 'foobar')
await browserA.emulate.call(scopeBrowserA, 'colorScheme', 'dark')
await browserB.emulate.call(scopeBrowserB, 'onLine', false)
await browserB.restore.call(scopeBrowserB)
expect(fakeScope.scriptRemovePreloadScript).toBeCalledTimes(1)
await browserA.restore.call(scopeBrowserA)
expect(fakeScope.scriptRemovePreloadScript).toBeCalledTimes(4)
})
it('should restore specific emulated behavior', async () => {
await browserA.emulate.call(scopeBrowserA, 'geolocation', { latitude: 52.52, longitude: 13.405 })
await browserA.emulate.call(scopeBrowserA, 'userAgent', 'foobar')
await browserA.emulate.call(scopeBrowserA, 'colorScheme', 'dark')
await browserA.emulate.call(scopeBrowserA, 'onLine', false)
await browserA.restore.call(scopeBrowserA, ['geolocation', 'userAgent'])
expect(fakeScope.scriptRemovePreloadScript).toBeCalledTimes(2)
})
})

File diff suppressed because it is too large Load diff

View file

@ -1406,6 +1406,9 @@ importers:
'@types/node':
specifier: ^20.11.30
version: 20.14.2
'@types/sinonjs__fake-timers':
specifier: ^8.1.5
version: 8.1.5
'@wdio/config':
specifier: workspace:*
version: link:../wdio-config
@ -1482,6 +1485,9 @@ importers:
specifier: workspace:*
version: link:../webdriver
devDependencies:
'@sinonjs/fake-timers':
specifier: ^11.2.2
version: 11.2.2
'@types/archiver':
specifier: ^6.0.2
version: 6.0.2
@ -4337,6 +4343,12 @@ packages:
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
engines: {node: '>=18'}
'@sinonjs/commons@3.0.1':
resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==}
'@sinonjs/fake-timers@11.2.2':
resolution: {integrity: sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw==}
'@slorber/react-ideal-image@0.0.12':
resolution: {integrity: sha512-u8KiDTEkMA7/KAeA5ywg/P7YG4zuKhWtswfVZDH8R8HXgQsFcHIYU2WaQnGuK/Du7Wdj90I+SdFmajSGFRvoKA==}
engines: {node: '>= 8.9.0', npm: '> 3'}
@ -5240,6 +5252,9 @@ packages:
'@types/shelljs@0.8.15':
resolution: {integrity: sha512-vzmnCHl6hViPu9GNLQJ+DZFd6BQI2DBTUeOvYHqkWQLMfKAAQYMb/xAmZkTogZI/vqXHCWkqDRymDI5p0QTi5Q==}
'@types/sinonjs__fake-timers@8.1.5':
resolution: {integrity: sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ==}
'@types/sockjs@0.3.36':
resolution: {integrity: sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q==}
@ -17195,6 +17210,14 @@ snapshots:
'@sindresorhus/merge-streams@4.0.0': {}
'@sinonjs/commons@3.0.1':
dependencies:
type-detect: 4.0.8
'@sinonjs/fake-timers@11.2.2':
dependencies:
'@sinonjs/commons': 3.0.1
'@slorber/react-ideal-image@0.0.12(prop-types@15.8.1)(react-waypoint@10.3.0(react@18.3.1))(react@18.3.1)':
dependencies:
prop-types: 15.8.1
@ -18372,6 +18395,8 @@ snapshots:
'@types/glob': 7.2.0
'@types/node': 20.14.8
'@types/sinonjs__fake-timers@8.1.5': {}
'@types/sockjs@0.3.36':
dependencies:
'@types/node': 20.14.10

View file

@ -28,6 +28,7 @@ export async function generateWdioDocs (sidebars: any) {
element: ['api/element', fs.readdirSync(path.join(COMMAND_DIR, 'element'))],
mock: ['api/mock', fs.readdirSync(path.join(COMMAND_DIR, 'mock'))],
dialog: ['api/dialog', fs.readdirSync(path.join(COMMAND_DIR, 'dialog'))],
clock: ['api/clock', fs.readdirSync(path.join(COMMAND_DIR, 'clock'))],
}
const apiDocs: NavbarItem[] = []

View file

@ -36,6 +36,11 @@ This protocol command is embedded in the following convenient method<%= it.alter
$(selector).<%= it.command %>(<%= it.paramString %>)
<% } else if (it.isMockScope) { %>
mock.<%= it.command %>(<%= it.paramString %>)
<% } else if (it.isDialogScope) { %>
await dialog.<%= it.command %>(<%= it.paramString %>)
<% } else if (it.isClockScope) { %>
const clock = await browser.emulate('clock', { ... })
await clock.<%= it.command %>(<%= it.paramString %>)
<% } else { %>
<%= it.isMobile ? 'driver' : 'browser' %>.<%= it.command %>(<%= it.paramString %>)
<% } %>

View file

@ -220,7 +220,9 @@ export default function (docfile: any) {
originalId: `api/${scope}/${name}`,
isElementScope : scope === 'element',
isNetworkScope : scope === 'network',
isMockScope : scope === 'mock'
isMockScope : scope === 'mock',
isDialogScope : scope === 'dialog',
isClockScope : scope === 'clock',
}
return commandDescription

View file

@ -526,6 +526,18 @@ async function bar() {
const multiElementError = $$('selector').getElement()
// @ts-expect-error
const multiElementError2 = multiElements.getElement()
// Emulate tests
let restore = await browser.emulate('geolocation', { latitude: 1, longitude: 2 })
await restore()
restore = await browser.emulate('userAgent', 'foobar')
restore = await browser.emulate('onLine', true)
// @ts-expect-error invalid param
restore = await browser.emulate('onLine', 'dark')
// @ts-expect-error invalid scope
restore = await browser.emulate('foobar')
const clock = await browser.emulate('clock', { now: new Date(2021, 3, 14) })
await clock.restore()
}
function testSevereServiceError_noParameters() {

40
website/docs/api/Clock.md Normal file
View file

@ -0,0 +1,40 @@
---
id: clock
title: The Clock Object
---
You can modify the browser system clock using the [`emulate`](/docs/emulation) command. It overrides native global functions related to time allowing them to be controlled synchronously via `clock.tick()` or the yielded clock object. This includes controlling:
- `setTimeout`
- `clearTimeout`
- `setInterval`
- `clearInterval`
- `Date Objects`
The clock starts at the unix epoch (timestamp of 0). This means that when you instantiate new Date in your application, it will have a time of January 1st, 1970 if you don't pass any other options to the `emulate` command.
## Example
When calling `browser.emulate('clock', { ... })` it will immediately overwrite the global functions for the current page as well as all following pages, e.g.:
```ts
const clock = await browser.emulate('clock', { now: new Date(1989, 7, 4) })
console.log(await browser.execute(() => (new Date()).toString()))
// returns "Fri Aug 04 1989 00:00:00 GMT-0700 (Pacific Daylight Time)"
await browser.url('https://webdriverio')
console.log(await browser.execute(() => (new Date()).toString()))
// returns "Fri Aug 04 1989 00:00:00 GMT-0700 (Pacific Daylight Time)"
await clock.restore()
console.log(await browser.execute(() => (new Date()).toString()))
// returns "Thu Aug 01 2024 17:59:59 GMT-0700 (Pacific Daylight Time)"
await browser.url('http://guinea-pig.webdriver.io/pointer.html')
console.log(await browser.execute(() => (new Date()).toString()))
// returns "Thu Aug 01 2024 17:59:59 GMT-0700 (Pacific Daylight Time)"
```
You can modify the system time by calling [`setSystemTime`](/) or [`tick`](/).

View file

@ -0,0 +1,26 @@
---
id: dialog
title: The Dialog Object
---
Dialog objects are dispatched by [`browser`](/docs/api/browser) via the `browser.on('dialog')` event.
An example of using the Dialog object:
```ts
import { browser } from '@wdio/globals'
await browser.url('https://webdriver.io')
browser.on('dialog', async (dialog) => {
console.log(dialog.message()) // outputs: "Hello Dialog"
await dialog.dismiss()
})
await page.evaluate(() => alert('Hello Dialog'))
```
:::note
Dialogs are dismissed automatically, unless there is a `page.on('dialog')` listener. When listener is present, it must either [`dialog.accept()`](/docs/api/dialog/accept) or [`dialog.dismiss()`](/docs/api/dialog/dismiss) the dialog - otherwise the page will freeze waiting for the dialog, and actions like click will never finish.
:::