Skip to content
Draft
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
608b0af
Draft first working multi-remote case
dprevost-LMI Dec 30, 2025
cf8088b
useful command to check everything (to revert)
dprevost-LMI Dec 30, 2025
6fd69aa
Working multiple targets and failures
dprevost-LMI Dec 30, 2025
e70bd6b
Working with multiple provided values
dprevost-LMI Dec 30, 2025
2b5b5e5
Add comment on future plan
dprevost-LMI Dec 30, 2025
24e7578
Working case of optimize waitUntil running per instance + failure msg
dprevost-LMI Dec 31, 2025
2846f34
working consolidation of waitUntil + formatFailureMessage
dprevost-LMI Dec 31, 2025
dbce283
Add doc for supported case for alpha version + some future plan
dprevost-LMI Dec 31, 2025
938f87c
code review
dprevost-LMI Dec 31, 2025
665109c
Add multi-remote alternative doc with Parametrized Tests
dprevost-LMI Dec 31, 2025
60d8aa5
Add Direct Instance Access alternative + fix english grammar
dprevost-LMI Dec 31, 2025
617a0aa
Code review
dprevost-LMI Jan 1, 2026
ea00149
Add multi-remote typing support
dprevost-LMI Jan 1, 2026
8fd88dd
Add missing unit tests for waitUntilResult
dprevost-LMI Jan 1, 2026
fc617b8
fix linting
dprevost-LMI Jan 1, 2026
ce71bd9
add unit test for formatMessage
dprevost-LMI Jan 1, 2026
e4e69ab
Add UT for multiRemoteUtil
dprevost-LMI Jan 1, 2026
8da4813
Code review
dprevost-LMI Jan 1, 2026
82c7caf
fix rebranching
dprevost-LMI Jan 2, 2026
d0bb3ad
Revert undesired formating changes
dprevost-LMI Jan 2, 2026
27c1dcd
revert undesired changes
dprevost-LMI Jan 2, 2026
bf8e852
Code review
dprevost-LMI Jan 2, 2026
594ad71
revert more undesired changes
dprevost-LMI Jan 2, 2026
870b248
Final code review
dprevost-LMI Jan 2, 2026
3fa95d5
Fix rebase
dprevost-LMI Jan 5, 2026
1fd463b
Add missing typing test for toBeDisplayed
dprevost-LMI Jan 5, 2026
8f91bf3
Add doc and future task for multi-remote
dprevost-LMI Jan 5, 2026
a848e2d
Apply suggestions from code review
dprevost-LMI Jan 5, 2026
3fcc76d
fix: backport isNot ambiguous failure message from PR #1987
dprevost-LMI Jan 5, 2026
20eefac
fix: Stop altering arg variable + more robust and clear concatenation
dprevost-LMI Jan 5, 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
155 changes: 155 additions & 0 deletions docs/MultiRemote.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# MultiRemote Support (Alpha)

Multi-remote support is in active development.

## Usage

By default, multi-remote matchers fetch data (e.g., `getTitle`) from all remotes, simplifying tests where browsers share the same behavior.

Use the typed global constants:
```ts
import { multiremotebrowser as multiRemoteBrowser } from '@wdio/globals'
...
await expect(multiRemoteBrowser).toHaveTitle('...')
```
Note: `multiRemoteBrowser` is used in examples pending a planned rename.


Assuming the following WebdriverIO multi-remote configuration:
```ts
export const config: WebdriverIO.MultiremoteConfig = {
...
capabilities: {
myChromeBrowser: {
capabilities: {
browserName: 'chrome',
'goog:chromeOptions': { args: ['--headless'] }
}
},
myFirefoxBrowser: {
capabilities: {
browserName: 'firefox',
'moz:firefoxOptions': { args: ['-headless'] }
}
}
},
...
}
```

And an `it` test like:
```ts
import { multiremotebrowser as multiRemoteBrowser } from '@wdio/globals'

it('should have title "My Site Title"', async function () {
await multiRemoteBrowser.url('https://mysite.com')

// ... assertions
})
```


## Single Expected Value
To test all remotes against the same value, pass a single expected value.
```ts
await expect(multiRemoteBrowser).toHaveTitle('My Site Title')
```

## Multiple Expected Values
For differing remotes, pass an array of expected values.
- Note: Values must match the configuration order.
```ts
await expect(multiRemoteBrowser).toHaveTitle(['My Chrome Site Title', 'My Firefox Site Title'])
```

## **NOT IMPLEMENTED** Per Remote Expected Value
To test specific remotes, map instance names to expected values.

```ts
// Test both defined remotes with specific values
await expect(multiRemoteBrowser).toHaveTitle({
'myChromeBrowser' : 'My Chrome Site Title',
'myFirefoxBrowser' : 'My Firefox Site Title'
})
```

To assert a single remote and skip others:
```ts
await expect(multiRemoteBrowser).toHaveTitle({
'myFirefoxBrowser' : 'My Firefox Site Title'
})
```

To assert all remotes with a default value, overriding specific ones:
```ts
await expect(multiRemoteBrowser).toHaveTitle({
default : 'My Default Site Title',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does default mean here?

Copy link
Contributor Author

@dprevost-LMI dprevost-LMI Jan 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would provide a default value to assert on all remotes. So instead of enumerating them all when you only have one exception, you can still offer one value for all and another for the exception.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if someone calls their browser default? Also are there many occurrences where you have to provide an exception for a single browser? How about we settle on:

await expect(multiRemoteBrowser).toHaveTitle("value for all")
// or
await expect(multiRemoteBrowser).toHaveTitle({
  browserA: "value A",
  browserB: "value B"
})

And see what the community responds.

Copy link
Contributor Author

@dprevost-LMI dprevost-LMI Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if someone calls their browser default?

Yep, that was a concern I had, still thinking about it.

Also, are there many occurrences where you have to provide an exception for a single browser?

Excellent question. The idea was to provide "flexibility"

How about we settle on

I like it, baby steps and more incremental features, I agree.
However, does your proposal also scope out the array approach?

'myFirefoxBrowser' : 'My Firefox Site Title'
})
```

## Limitations
- Options (e.g., `StringOptions`) apply globally.
- Alpha support is limited to the `toHaveTitle` browser matcher.
- Element matchers are planned.
- Assertions currently throw on the first error. Future updates will report thrown errors as failures, and will only throw if all remotes fail.
- SoftAssertions, snapshot services and network matchers might come after.

## Alternatives

Since multi-remote instances are standard browsers, you can also assert by iterating over the instance list.

### Parameterized Tests
Using the parameterized feature of your test framework, you can iterate over the multi-remote instances.

Mocha Parameterized Example
```ts
describe('Multiremote test', async () => {
multiRemoteBrowser.instances.forEach(function (instance) {
describe(`Test ${instance}`, function () {
it('should have title "My Site Title"', async function () {
const browser = multiRemoteBrowser.getInstance(instance)
await browser.url('https://mysite.com')

await expect(browser).toHaveTitle("My Site Title")
})
})
})
})
```
### Direct Instance Access (TypeScript)
By extending the WebdriverIO `namespace` in TypeScript (see [documentation](https://webdriver.io/docs/multiremote/#extending-typescript-types)), you can directly access each instance and use `expect` on them.

```ts
it('should have title per browsers', async () => {
await multiRemoteBrowser.url('https://mysite.com')

await expect(multiRemoteBrowser.myChromeBrowser).toHaveTitle('My Chrome Site Title')
await expect(multiRemoteBrowser.myFirefoxBrowser).toHaveTitle('My Firefox Site Title')
})
```

Required configuration:

File `type.d.ts`
```ts
declare namespace WebdriverIO {
interface MultiRemoteBrowser {
myChromeBrowser: WebdriverIO.Browser
myFirefoxBrowser: WebdriverIO.Browser
}
}
```

In `tsconfig.json`
```json
{
"compilerOptions": {
...
},
"include": [
...
"type.d.ts"
]
}
```
59 changes: 43 additions & 16 deletions src/matchers/browser/toHaveTitle.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,66 @@
import { waitUntil, enhanceError, compareText } from '../../utils.js'
import { compareText, waitUntilResultSucceed } from '../../utils.js'
import { DEFAULT_OPTIONS } from '../../constants.js'
import type { MaybeArray } from '../../util/multiRemoteUtil.js'
import { mapExpectedValueWithInstances } from '../../util/multiRemoteUtil.js'
import { formatFailureMessage } from '../../util/formatMessage.js'

type ExpectedValueType = string | RegExp | WdioAsymmetricMatcher<string>

export async function toHaveTitle(
this: ExpectWebdriverIO.MatcherContext,
browsers: WebdriverIO.MultiRemoteBrowser,
expectedValues: MaybeArray<ExpectedValueType>,
options?: ExpectWebdriverIO.StringOptions,
): Promise<ExpectWebdriverIO.AssertionResult>
export async function toHaveTitle(
this: ExpectWebdriverIO.MatcherContext,
browser: WebdriverIO.Browser,
expectedValue: string | RegExp | WdioAsymmetricMatcher<string>,
options: ExpectWebdriverIO.StringOptions = DEFAULT_OPTIONS
expectedValue: ExpectedValueType,
options?: ExpectWebdriverIO.StringOptions,
): Promise<ExpectWebdriverIO.AssertionResult>
export async function toHaveTitle(
this: ExpectWebdriverIO.MatcherContext,
browser: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser,
expectedValue: MaybeArray<ExpectedValueType>,
options: ExpectWebdriverIO.StringOptions = DEFAULT_OPTIONS,
) {
const isNot = this.isNot
const { expectation = 'title', verb = 'have' } = this
const { expectation = 'title', verb = 'have', isNot } = this
const context = { expectation, verb, isNot, isMultiRemote: browser.isMultiremote }

await options.beforeAssertion?.({
matcherName: 'toHaveTitle',
expectedValue,
options,
})

let actual
const pass = await waitUntil(async () => {
actual = await browser.getTitle()
const browsersWithExpected = mapExpectedValueWithInstances(browser, expectedValue)

const conditions = Object.entries(browsersWithExpected).map(([instanceName, { browser, expectedValue: expected }]) => async () => {
const actual = await browser.getTitle()

return compareText(actual, expectedValue, options).result
}, isNot, options)
const result = compareText(actual, expected, options)
result.instance = instanceName
return result
})

const conditionsResults = await waitUntilResultSucceed(
conditions,
isNot,
options,
)

const message = enhanceError('window', expectedValue, actual, this, verb, expectation, '', options)
const result: ExpectWebdriverIO.AssertionResult = {
pass,
message: () => message
const message = formatFailureMessage('window', conditionsResults.results, context, '', options)
const assertionResult: ExpectWebdriverIO.AssertionResult = {
pass: conditionsResults.pass,
message: () => message,
}

await options.afterAssertion?.({
matcherName: 'toHaveTitle',
expectedValue,
options,
result
result: assertionResult,
})

return result
return assertionResult
}
78 changes: 73 additions & 5 deletions src/util/formatMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { printDiffOrStringify, printExpected, printReceived } from 'jest-matcher
import { equals } from '../jasmineUtils.js'
import type { WdioElements } from '../types.js'
import { isElementArray } from './elementsUtil.js'
import type { CompareResult } from '../utils.js'

const EXPECTED_LABEL = 'Expected'
const RECEIVED_LABEL = 'Received'
Expand Down Expand Up @@ -39,18 +40,19 @@ export const getSelectors = (el: WebdriverIO.Element | WdioElements) => {
return selectors.reverse().join('.')
}

export const not = (isNot: boolean): string => {
return `${isNot ? 'not ' : ''}`
}
const not = (isNot: boolean): string => `${isNot ? 'not ' : ''}`

const startSpace = (word = ''): string | undefined => word ? ` ${word}` : word

export const enhanceError = (
subject: string | WebdriverIO.Element | WdioElements,
expected: unknown,
actual: unknown,
context: { isNot: boolean },
context: { isNot?: boolean },
verb: string,
expectation: string,
arg2 = '', {
arg2 = '',
{
message = '',
containing = false
}): string => {
Expand Down Expand Up @@ -85,10 +87,76 @@ export const enhanceError = (
arg2 = ` ${arg2}`
}

/**
* Example of below message:
* Expect window to have title
*
* Expected: "some Title text"
* Received: "some Wrong Title text"
*/
const msg = `${message}Expect ${subject} ${not(isNot)}to ${verb}${expectation}${arg2}${contain}\n\n${diffString}`
return msg
}

/**
* Formats failure message for multiple compare results
* TODO multi-remote support: Replace enhanceError with this one everywhere
*/
export const formatFailureMessage = (
subject: string | WebdriverIO.Element | WebdriverIO.ElementArray,
compareResults: CompareResult<string, string | RegExp | WdioAsymmetricMatcher<string>>[],
context: ExpectWebdriverIO.MatcherContext & { useNotInLabel?: boolean },
expectedValueArgument2 = '',
{ message = '', containing = false } = {}): string => {

const { isNot = false, expectation, useNotInLabel = true, verb } = context

subject = typeof subject === 'string' ? subject : getSelectors(subject)

const contain = containing ? 'containing' : ''
const customMessage = message ? `${message}\n` : ''

const label = {
expected: isNot && useNotInLabel ? 'Expected [not]' : 'Expected',
received: isNot && useNotInLabel ? 'Received ' : 'Received'
}

const failedResults = compareResults.filter(({ result }) => result === isNot)

let msg = ''
for (const failResult of failedResults) {
const { actual, expected, instance: instanceName } = failResult

// Using `printDiffOrStringify()` with equals values output `Received: serializes to the same string`, so we need to tweak.
const diffString = equals(actual, expected) ?`\
${label.expected}: ${printExpected(expected)}
${label.received}: ${printReceived(actual)}`
: printDiffOrStringify(expected, actual, label.expected, label.received, true)

const mulitRemoteContext = context.isMultiRemote ? `for remote "${instanceName}"` : ''

/**
* Example of below message (custom message + multi-remote + isNot case):
* ```
* My custom error message
* Expect window not to have title for remote "browserA"
*
* Expected not: "some Title text"
* Received: "some Wrong Title text"
*
* ```
*/
msg += `\
${customMessage}Expect ${subject} ${not(isNot)}to${startSpace(verb)}${startSpace(expectation)}${startSpace(expectedValueArgument2)}${startSpace(contain)}${startSpace(mulitRemoteContext)}

${diffString}

`
}

return msg.trim()
}

export const enhanceErrorBe = (
subject: string | WebdriverIO.Element | WebdriverIO.ElementArray,
pass: boolean,
Expand Down
36 changes: 36 additions & 0 deletions src/util/multiRemoteUtil.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { Browser } from 'webdriverio'

export const toArray = <T>(value: T | T[] | MaybeArray<T>): T[] => (Array.isArray(value) ? value : [value])

export type MaybeArray<T> = T | T[]

export const isMultiRemote = (browser: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser): browser is WebdriverIO.MultiRemoteBrowser => {
return (browser as WebdriverIO.MultiRemoteBrowser).isMultiremote === true
}

type BrowserWithExpected<T> = Record<string, {
browser: Browser;
expectedValue: T;
}>

export const mapExpectedValueWithInstances = <T>(browsers: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser, expectedValues: T | MaybeArray<T>): BrowserWithExpected<T> => {
if (isMultiRemote(browsers)) {
if (Array.isArray(expectedValues)) {
if (expectedValues.length !== browsers.instances.length) {
throw new Error(`Expected values length (${expectedValues.length}) does not match number of browser instances (${browsers.instances.length}) in multi-remote setup.`)
}
}
// TODO multi-remote support: add support for object like { default: 'title', browserA: 'titleA', browserB: 'titleB' } later

const browsersWithExpected = browsers.instances.reduce((acc: BrowserWithExpected<T>, instance, index) => {
const browser = browsers.getInstance(instance)
const expectedValue: T = Array.isArray(expectedValues) ? expectedValues[index] : expectedValues
acc[instance] = { browser, expectedValue }
return acc
}, {})
return browsersWithExpected
}

// TODO multi-remote support: using default could clash if someone use name default, to review later
return { default: { browser: browsers, expectedValue: expectedValues as T } }
}
Loading