Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
8d5a011
fix(wdio): support v9 wdio switchFrame and switchWindow
straker Mar 20, 2026
18baf4f
fix @wdio/global proxy
straker Mar 20, 2026
3b48901
update @wdio/globals test to better reflect its API (proxies)
straker Mar 20, 2026
8309803
test title
straker Mar 20, 2026
ccb9698
preserve comment
straker Mar 20, 2026
2bd4462
move to wdiov9
straker Mar 20, 2026
d3b32f7
support @wdio/globals proxy in wrapper functions
straker Mar 20, 2026
5e4a0d5
loosen type
straker Mar 20, 2026
ebc9fee
fix types
straker Mar 23, 2026
0a38256
fix lint issue
straker Mar 24, 2026
a675070
fix(wdio): fix parent frame switching and skip devtools tests on v9
scottmries Apr 2, 2026
6352033
Merge branch 'develop' into v9-wdio
scottmries Apr 2, 2026
9e65411
fix(wdio): resolve webdriverio version without relying on package.jso…
scottmries Apr 2, 2026
1e3caa4
fix(wdio): fix BiDi mode frame navigation for WDIO v9
scottmries Apr 3, 2026
229f04d
fix(webdriverio): resolve BiDi frame context issues in WDIO v9
scottmries Apr 3, 2026
17e4cd5
fix(webdriverio): update esmTest to use webdriver protocol for WDIO v9
scottmries Apr 3, 2026
94a20db
store correct topWindow
scottmries Apr 6, 2026
6b37db4
fix(webdriverio): use dynamic ephemeral port in esmTest and inherit s…
Copilot Apr 6, 2026
27d6373
fix(webdriverio): use dynamic port in esmTest instead of fixed port
scottmries Apr 6, 2026
a5fd340
test(webdriverio): add unit tests for clientSwitchFrame v9 BiDi and v…
Copilot Apr 6, 2026
a7ce0c4
fix(webdriverio): use dynamic port in tests instead of hard-coded port
scottmries Apr 6, 2026
1634b10
refactor(webdriverio): extract shared test utilities into testUtils.js
scottmries Apr 6, 2026
78c9878
chore(webdriverio): clean up
scottmries Apr 6, 2026
8d7a126
fix(webdriverio): replace `any` casts with `in` checks in clientSwitc…
Copilot Apr 7, 2026
50db5d8
fix(webdriverio): remove remaining `as any` casts from src files
Copilot Apr 7, 2026
568a210
chore(webdriverio): clean up
scottmries Apr 7, 2026
a7c9d43
chore(webdriverio): clean up
scottmries Apr 7, 2026
93dde35
fix(webdriverio): use browser-driver-manager chromedriver in esmTest …
scottmries Apr 7, 2026
dbe20fc
fix(webdriverio): pin chromedriver to ^146 to match CI Chrome version
scottmries Apr 7, 2026
086e8d0
Merge branch 'develop' into v9-wdio
scottmries Apr 9, 2026
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
824 changes: 266 additions & 558 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion packages/webdriverio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"devDependencies": {
"@types/chai": "^4.3.3",
"@types/chromedriver": "^81.0.1",
"chromedriver": "^146",
"@types/cssesc": "^3.0.0",
"@types/express": "^5.0.3",
"@types/mocha": "^10.0.6",
Expand All @@ -70,7 +71,7 @@
"rimraf": "^6.0.1",
"source-map-support": "^0.5.21",
"tsup": "^8.0.1",
"webdriverio": "^8.8.2"
"webdriverio": "^9.27.0"
},
"peerDependencies": {
"webdriverio": "^5 || ^6 || ^7 || ^8 || ^9"
Expand Down
103 changes: 68 additions & 35 deletions packages/webdriverio/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
axeFinishRun,
axeRunLegacy,
configureAllowedOrigins,
clientSwitchFrame,
clientSwitchWindow,
FRAME_LOAD_TIMEOUT
} from './utils';
import { getFilename } from 'cross-dirname';
Expand All @@ -36,8 +38,7 @@ async function loadAxePath() {
if (typeof require === 'function' && typeof require.resolve === 'function') {
axeCorePath = require.resolve('axe-core');
} else {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { createRequire } = (await import('node:module')) as any;
const { createRequire } = await import('node:module');
// `getFilename` is needed because esm's `import.meta.url` is illegal syntax in cjs
const filename = pathToFileURL(getFilename()).toString();

Expand Down Expand Up @@ -205,9 +206,23 @@ export default class AxeBuilder {
* Injects `axe-core` into all frames.
*/
private async inject(
browsingContext: WdioElement | null = null
browsingContext: WdioElement | null = null,
browsingContextId: string | null = null
): Promise<void> {
await this.setBrowsingContext(browsingContext);
// Navigate to the target browsing context and capture its BiDi context ID.
// In WDIO v9 BiDi mode, switchFrame returns the browsing context ID string,
// which we use later to safely re-enter this frame after deep injection.
// In Classic WebDriver mode, switchFrame returns undefined and we fall back
// to re-entering via the original element reference.
if (browsingContext !== null) {
const result = await clientSwitchFrame(this.client, browsingContext);
if (typeof result === 'string') {
browsingContextId = result;
}
} else {
Comment thread
scottmries marked this conversation as resolved.
await clientSwitchFrame(this.client, null);
}

const runPartialSupported = await axeSourceInject(
this.client,
this.axeSource
Expand All @@ -226,11 +241,30 @@ export default class AxeBuilder {

for (const iframe of iframes) {
try {
if (!(await iframe.isExisting())) {
const exists = await iframe.isExisting();
if (!exists) {
continue;
}
await this.inject(iframe);
await this.client.switchToParentFrame();
// After injecting into iframe (and its descendants), navigate back to
// this level. switchFrame(null) reliably resets to the top-level context.
// Then re-enter this frame using its BiDi context ID (WDIO v9 BiDi) or
// its element reference (Classic WebDriver).
//
// We use the context ID rather than the element reference because in WDIO
// v9 BiDi mode, Chrome may assign new document IDs to intermediate frame
// contexts after a deep switchFrame(null). An element's SharedId encodes
// the document ID at query time; if the document ID has since changed, the
// SharedId is stale and Chrome rejects it with "no such node". Passing a
// context ID string instead causes WDIO to re-query fresh element
// references via browsingContextLocateNodes, bypassing the stale-ID issue.
await clientSwitchFrame(this.client, null);
if (browsingContextId !== null && 'switchFrame' in this.client) {
// browsingContextId is only set on v9 BiDi clients, so switchFrame is available.
await this.client.switchFrame(browsingContextId);
} else if (browsingContext !== null) {
await clientSwitchFrame(this.client, browsingContext);
}
} catch (error) {
logOrRethrowError(error);
}
Expand All @@ -253,15 +287,15 @@ export default class AxeBuilder {
// ensure we fail quickly if an iframe cannot be loaded (instead of waiting
// the default length of 30 seconds)
const { pageLoad } = await this.client.getTimeouts();
(this.client as WebdriverIO.Browser).setTimeout({
this.client.setTimeout({
pageLoad: FRAME_LOAD_TIMEOUT
});

let partials: PartialResults | null;
try {
partials = await this.runPartialRecursive(context);
} finally {
(this.client as WebdriverIO.Browser).setTimeout({
this.client.setTimeout({
pageLoad
});
}
Expand Down Expand Up @@ -303,29 +337,19 @@ export default class AxeBuilder {
return selector;
}

/**
* Set browsing context - when `null` sets top level page as context
* - https://webdriver.io/docs/api/webdriver.html#switchtoframe
*/
private async setBrowsingContext(
id: null | WdioElement | WdioBrowser = null
): Promise<void> {
if (id) {
await this.client.switchToFrame(id);
} else {
await this.client.switchToParentFrame();
}
}

/**
* Get partial results from the current context and its child frames
* @param {ContextObject} context
*/

private async runPartialRecursive(
context: SerialContextObject,
frameStack: WdioElement[] = []
frameStack: WdioElement[] = [],
topWindow?: string
): Promise<PartialResults> {
if (topWindow === undefined) {
topWindow = await this.client.getWindowHandle();
}
const frameContexts = await axeGetFrameContext(this.client, context);
const partials: PartialResults = [
await axeRunPartial(this.client, context, this.option)
Expand All @@ -335,26 +359,35 @@ export default class AxeBuilder {
try {
const frame = await this.client.$(frameSelector);
assert(frame, `Expect frame of "${frameSelector}" to be defined`);
await this.client.switchToFrame(frame);
await clientSwitchFrame(this.client, frame);
await axeSourceInject(this.client, this.script);
partials.push(
...(await this.runPartialRecursive(frameContext, [
...frameStack,
frame
]))
...(await this.runPartialRecursive(
frameContext,
[...frameStack, frame],
topWindow
))
);
} catch {
const [topWindow] = await this.client.getWindowHandles();
await this.client.switchToWindow(topWindow);
await clientSwitchWindow(this.client, topWindow);

for (const frameElm of frameStack) {
await this.client.switchToFrame(frameElm);
await clientSwitchFrame(this.client, frameElm);
}

partials.push(null);
}
}
await this.client.switchToParentFrame();
// Navigate back to the parent context by switching to the top-level window
// (via getWindowHandles + switchToWindow, which correctly sets the BiDi
// context) then re-traversing the frame stack up to (but not including)
// the last frame. This avoids the WDIO v9 BiDi race condition where
// switchToParentFrame synchronously resets #currentContext before the async
// parent lookup resolves, causing subsequent BiDi calls to run in wrong context.
await clientSwitchWindow(this.client, topWindow);
for (let i = 0; i < frameStack.length - 1; i++) {
await clientSwitchFrame(this.client, frameStack[i]);
}
return partials;
}

Expand All @@ -368,8 +401,8 @@ export default class AxeBuilder {
);

try {
await client.switchToWindow(newWindow.handle);
await (client as WebdriverIO.Browser).url('data:text/html,');
await clientSwitchWindow(client, newWindow.handle);
await client.url('data:text/html,');
} catch (error) {
throw new Error(
`switchToWindow failed. Are you using updated browser drivers? \nDriver reported:\n${
Expand All @@ -381,7 +414,7 @@ export default class AxeBuilder {
const res = await axeFinishRun(client, axeSource, partials, option);
// Cleanup
await client.closeWindow();
await client.switchToWindow(win);
await clientSwitchWindow(client, win);

return res;
}
Expand Down
85 changes: 55 additions & 30 deletions packages/webdriverio/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,60 @@
import type { AxeResults, BaseSelector } from 'axe-core';
import * as axe from 'axe-core';
import { type Browser, type Element } from 'webdriverio';

/*
This type allows both webdriverio v8 and <=v7 Browser types
to work in the same codebase. The types are incompatible with
each other, but are compatible with the functions that we use.
Every new feature that we use from the Browser type will need
to be added to the Pick list
*/
export type WdioBrowser =
| Browser
| Pick<
WebdriverIO.Browser,
| '$$'
| '$'
| 'switchToFrame'
| 'switchToParentFrame'
| 'getWindowHandles'
| 'getWindowHandle'
| 'switchToWindow'
| 'createWindow'
| 'url'
| 'getTimeouts'
| 'setTimeout'
| 'closeWindow'
| 'executeAsync'
| 'execute'
>;

export type WdioElement = Element | WebdriverIO.Element;

export interface WdioElement {
isExisting(): Promise<boolean>;
}

// Shared methods present in all supported WDIO versions. Every new feature that
// we use from the Browser type will need to be added to the list.
// Hand-written rather than Pick<WebdriverIO.Browser, ...> because:
// - WebdriverIO.Browser.$$ returns ChainablePromiseArray, whose awaited type
// doesn't expose .concat(), breaking usage in index.ts.
// - Several picked methods carry a `this: Browser` context constraint that
// TypeScript enforces even through our narrower union type
interface WdioBrowserBase {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
$$(selector: string): any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
$(selector: string): any;

execute(
script: string | ((...args: unknown[]) => unknown),
...args: unknown[]
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): Promise<any>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
executeAsync(script: string, ...args: unknown[]): Promise<any>;
getTimeouts(): Promise<{ pageLoad?: number }>;
setTimeout(options: { pageLoad?: number }): Promise<void>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
url(url: string): Promise<any>;
getWindowHandles(): Promise<string[]>;
getWindowHandle(): Promise<string>;
createWindow(type: 'tab' | 'window'): Promise<{ handle: string }>;
closeWindow(): Promise<void>;
}

// WDIO v5–v8: frame/window navigation via the legacy API.
interface WdioBrowserLegacy extends WdioBrowserBase {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
switchToFrame(element: any): Promise<any>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
switchToParentFrame(): Promise<any>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
switchToWindow(handle: any): Promise<any>;
}

// WDIO v9: frame/window navigation via the new API.
interface WdioBrowserV9 extends WdioBrowserBase {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
switchFrame(element: any): Promise<any>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
switchWindow(matcher: any): Promise<any>;
}

// A WDIO browser object from any supported version (v5–v9).
export type WdioBrowser = WdioBrowserLegacy | WdioBrowserV9;

export type CallbackFunction = (
error: string | null,
Expand Down
Loading
Loading