Browser mode lets you test your Dioxus frontend UI in plain Chrome against a running dev server — no Dioxus binary, no driver. The Dioxus invoke API is intercepted at the JavaScript boundary in the renderer, so you can mock individual commands and assert on call arguments just like in native mode.
Browser mode is a frontend-only test mode. Your frontend code runs for real in Chrome; the Dioxus Rust backend is replaced by mocks you define per command. Same WDIO API, same frontend code path — the only thing that changes is what's on the other end of invoke(...).
In normal (native) mode the service launches your compiled Dioxus app, drives it via the configured driver provider, and communicates with the backend through the bridge. Browser mode replaces all of that with a standard Chrome session: it sets browserName to 'chrome', navigates to your dev server URL, and injects a lightweight script that patches the invoke API so your Dioxus commands can be intercepted in tests.
- No build step needed — point the service at a dev server and start testing immediately.
- Fast feedback — no Dioxus startup, no Rust compilation, no driver negotiation.
- Standard browser devtools — Chrome DevTools and HMR work as normal during development.
Browser mode is the right choice when your tests are renderer-focused: asserting UI state, verifying that components call the correct Dioxus commands with the right arguments, or checking that the renderer handles mock responses correctly.
It is not suitable when your tests need to:
- Call
browser.dioxus.execute()to run code with access to the bridge - Test window management with
browser.dioxus.switchWindow()orbrowser.dioxus.listWindows() - Use
browser.dioxus.triggerDeeplink() - Assert on real command round-trips to a running Rust backend
For those scenarios use native mode (the default).
Browser mode requires a running dev server that serves your frontend code.
Or let the service start and stop it for you with the optional devServer option (see Auto-managing the dev server below) — then you don't need to start it yourself.
Set mode: 'browser' and provide devServerUrl in your WDIO configuration. No appBinaryPath, dioxus:options, or driver config is needed.
wdio.conf.ts
export const config = {
services: ['@wdio/dioxus-service'],
capabilities: [
{
browserName: 'dioxus',
'wdio:dioxusServiceOptions': {
mode: 'browser',
devServerUrl: 'http://localhost:8080',
},
},
],
};You can also set mode and devServerUrl at the global service level:
export const config = {
services: [
[
'@wdio/dioxus-service',
{
mode: 'browser',
devServerUrl: 'http://localhost:8080',
},
],
],
capabilities: [
{ browserName: 'dioxus' },
],
};Capability-level options take precedence over service-level ones. All capabilities in a session must use the same mode; mixing 'native' and 'browser' across capabilities throws a SevereServiceError at startup.
By default you start the dev server yourself. The optional devServer service option makes the service spawn it in onPrepare, wait until devServerUrl is reachable, and tear it down on completion (and on a startup failure). It takes three forms:
// 1. A shell command (the common case)
devServer: 'pnpm dev'
// 2. An object — command plus cwd/env/timeout, and reuse control
devServer: {
command: 'pnpm dev',
cwd: './app',
env: { NODE_ENV: 'test' },
timeoutMs: 60_000, // readiness budget (default 60s, 120s in CI)
reuseExistingServer: true, // default: reuse a running server locally, always spawn in CI
}
// 3. A function — start it programmatically and return how to close it.
// No subprocess/quoting/child-kill involved; the returned `url` supplies/overrides devServerUrl.
devServer: async () => {
const server = await createServer();
await server.listen();
return { url: server.resolvedUrls.local[0], close: () => server.close() };
}Notes:
devServeris a service-level option (one dev server per run) and only applies in browser mode.- With
reuseExistingServer(default off in CI, on locally), ifdevServerUrlis already reachable the spawn is skipped — handy when you already havepnpm devrunning during local iteration. - The command form spawns in a shell and tears down the whole process tree (so
pnpm dev → vite → esbuildgrandchildren don't orphan); the function form avoids subprocesses entirely. - The same option is available on the Tauri, Electron, and Electrobun services.
When the session starts, the service injects a script into the page that:
- Creates
window.__wdio_mocks__— a registry of per-command mock functions. - Patches the Dioxus invoke API to look up
window.__wdio_mocks__[command]and call it; throws if the command has no registered mock.
The injection script runs again after every browser.url() navigation because a page load wipes window state.
const mockReadFile = await browser.dioxus.mock('read_file');
await mockReadFile.mockResolvedValue('mocked file content');After triggering the relevant UI action, call update() to sync call data from the browser-side spy to the outer mock object, then assert:
await $('button#load-file').click();
await mockReadFile.update();
expect(mockReadFile).toHaveBeenCalledTimes(1);
expect(mockReadFile.mock.calls[0]).toEqual([{ path: '/some/file' }]);Element commands (click, doubleClick, setValue, clearValue) trigger update() automatically on all active mocks.
// Return a fixed value
await mockReadFile.mockReturnValue('file content');
// Resolve a promise (for async commands)
await mockReadFile.mockResolvedValue('file content');
// Use a function for dynamic responses
await mockReadFile.mockImplementation((args) => {
return `content of ${args.path}`;
});
// Respond differently on first call, then fall back
await mockReadFile.mockResolvedValueOnce('first call content');
await mockReadFile.mockResolvedValue('default content');await mockReadFile.mockRestore();Events are not supported in either mode — Dioxus has no native event bus, so there is no browser.dioxus.emitEvent() (see @wdio/native-types). This is a service-wide limitation, not specific to browser mode.
Calling browser.dioxus.mock(command) multiple times for the same command is safe, but the service always fully resets the existing mock (via mockReset()) before returning it — both call history and any previously-set implementation are cleared on every call. Set the implementation in beforeEach rather than relying on beforeAll setup persisting.
describe('File panel', () => {
let mockReadFile: DioxusMock;
beforeEach(async () => {
mockReadFile = await browser.dioxus.mock('read_file');
await mockReadFile.mockResolvedValue('default content');
});
it('displays file content', async () => {
await $('button#load-file').click();
await mockReadFile.update();
expect(mockReadFile).toHaveBeenCalledTimes(1);
});
});browser.url() is patched by the service to re-run the IPC injection script after every navigation. This re-creates window.__wdio_mocks__ (as an empty object). Existing mock handles remain valid as JS objects, but their browser-side entries are gone. To recover, call mockRestore() first and then mock(command) to re-create the browser-side entry.
| Feature | Browser Mode |
|---|---|
browser.dioxus.execute() |
Throws — no Dioxus backend or bridge |
browser.dioxus.triggerDeeplink() |
Throws — no Dioxus process |
browser.dioxus.switchWindow() |
Throws — multi-window requires a native Dioxus app |
browser.dioxus.listWindows() |
Throws — same reason as switchWindow() |
Backend log capture (captureBackendLogs) |
Not available — no Rust process |
Frontend log capture (captureFrontendLogs) |
Available — Chrome session, standard console capture |
Each named multiremote instance gets its own isolated mock registry.
export const config = {
services: [['@wdio/dioxus-service', { mode: 'browser' }]],
capabilities: {
app1: {
capabilities: {
browserName: 'dioxus',
'wdio:dioxusServiceOptions': { devServerUrl: 'http://localhost:8080' },
},
},
app2: {
capabilities: {
browserName: 'dioxus',
'wdio:dioxusServiceOptions': { devServerUrl: 'http://localhost:8080' },
},
},
},
};const mock1 = await browser.getInstance('app1').dioxus.mock('read_file');
const mock2 = await browser.getInstance('app2').dioxus.mock('read_file');
await mock1.mockResolvedValue('content from app1');
await mock2.mockResolvedValue('content from app2');Your renderer called invoke(command) before a mock was registered. Call browser.dioxus.mock(command) before the code path that triggers the command.
The browser-side entry was wiped by navigation. Call mock.mockRestore() first to delete the worker-side entry, then browser.dioxus.mock(command) to re-create both sides.
The service throws a SevereServiceError if devServerUrl is missing or not a valid URL. A connection-refused error from Chrome means the dev server is not running — start it before launching the test suite.