Skip to main content

Deeplink Testing

The service provides the ability to test custom protocol handlers and deeplinks in your Electron application using the browser.electron.triggerDeeplink() method. This feature automatically handles platform-specific differences, particularly on Windows where deeplinks would normally launch a new instance instead of reaching the test instance.

Overview

Deeplink testing allows you to verify that your Electron application correctly handles custom protocol URLs (e.g., myapp://action?param=value). This is essential when your app registers as a protocol handler and needs to respond to URLs opened from external sources like web browsers, emails, or other applications.

Why is it Needed?

Testing protocol handlers presents unique challenges:

  • Windows Issue: On Windows, triggering a deeplink normally launches a new app instance instead of routing to the running test instance. This happens because the test instance and the externally-triggered instance use different user data directories.
  • Test Automation: You need a programmatic way to trigger deeplinks without manual intervention.
  • Cross-Platform Testing: Different platforms use different mechanisms to trigger protocol handlers.

When Should You Use It?

Use browser.electron.triggerDeeplink() when you need to:

  • Test that your app correctly handles custom protocol URLs
  • Verify deeplink parameter parsing and routing logic
  • Ensure single-instance behavior works correctly
  • Test protocol handler registration and activation
  • Validate deeplink-driven workflows in your application

Basic Usage

Simple Example

describe('Protocol Handler Tests', () => {
it('should handle custom protocol deeplinks', async () => {
// Trigger the deeplink
await browser.electron.triggerDeeplink('myapp://open?file=test.txt');

// Wait for app to process it
await browser.waitUntil(async () => {
const openedFile = await browser.electron.execute(() => {
return globalThis.lastOpenedFile;
});
return openedFile === 'test.txt';
}, {
timeout: 5000,
timeoutMsg: 'App did not handle the deeplink'
});
});
});

Complex URL Parameters

The method preserves all URL parameters, including complex query strings:

it('should preserve query parameters', async () => {
await browser.electron.triggerDeeplink(
'myapp://action?param1=value1&param2=value2&array[]=a&array[]=b'
);

const receivedParams = await browser.electron.execute(() => {
return globalThis.lastDeeplinkParams;
});

expect(receivedParams.param1).toBe('value1');
expect(receivedParams.param2).toBe('value2');
expect(receivedParams.array).toEqual(['a', 'b']);
});

Error Handling

it('should reject invalid protocols', async () => {
await expect(
browser.electron.triggerDeeplink('https://example.com')
).rejects.toThrow('Invalid deeplink protocol');
});

it('should reject malformed URLs', async () => {
await expect(
browser.electron.triggerDeeplink('not a url')
).rejects.toThrow('Invalid deeplink URL');
});

Platform Behavior

The service handles platform-specific differences automatically:

Windows

Behavior:

  • Uses cmd /c start command to trigger the deeplink
  • Automatically appends the test instance's userData directory as a query parameter
  • Cannot use script-based apps (appEntryPoint) - requires packaged binary

URL Modification:

// Input URL
'myapp://test?foo=bar'

// URL triggered on Windows (userData appended automatically)
'myapp://test?foo=bar&userData=/tmp/electron-test'

Why This is Needed:

On Windows, when a protocol URL is opened, the OS launches the registered application binary. Without the userData parameter, this creates a new instance with a different user data directory, preventing Electron's single-instance lock from working correctly. By appending the userData parameter, your app can use the same directory as the test instance, allowing the single-instance lock to route the deeplink to the test instance.

macOS

Behavior:

  • Uses open command to trigger the deeplink
  • No URL modification needed (OS handles single-instance automatically)
  • No special configuration required

URL Modification:

// URL passed unchanged
'myapp://test?foo=bar'

Linux

Behavior:

  • Uses gio open command to trigger the deeplink
  • Automatically appends the test instance's userData directory as a query parameter (like Windows)
  • Cannot use script-based apps (appEntryPoint) - requires packaged binary

URL Modification:

// Input URL
'myapp://test?foo=bar'

// URL triggered on Linux (userData appended automatically)
'myapp://test?foo=bar&userData=/tmp/electron-test'

Why This is Needed:

Similar to Windows, Linux requires the userData parameter to ensure the deeplink-triggered instance uses the same user data directory as the test instance, enabling Electron's single-instance lock to route the deeplink correctly.

Setup Requirements

1. Service Configuration

Windows & Linux Configuration

On Windows and Linux, you must use a packaged binary (not appEntryPoint). Script-based apps cannot register protocol handlers at the OS level.

wdio.conf.ts

export const config = {
capabilities: [
{
browserName: 'electron',
'wdio:electronServiceOptions': {
// Use packaged binary (auto-detected or explicit)
appBinaryPath: './dist/win-unpacked/MyApp.exe',

// Optional but recommended: Explicit user data directory
appArgs: ['--user-data-dir=/tmp/test-user-data']
}
}
]
};

Important Notes:

  • appEntryPoint will NOT work for protocol handler testing on Windows/Linux
  • You must use appBinaryPath or let the service auto-detect your binary
  • The service will warn you if you're using appEntryPoint with protocol handlers
  • See Service Configuration for help finding your app binary path

macOS Configuration

macOS works with both packaged binaries and script-based apps:

wdio.conf.ts

export const config = {
capabilities: [
{
browserName: 'electron',
'wdio:electronServiceOptions': {
// Either works on macOS
appBinaryPath: './dist/mac/MyApp.app/Contents/MacOS/MyApp',
// OR
appEntryPoint: './dist/main.js'
}
}
]
};

2. Protocol Handler Registration

Your app must register as a protocol handler. This is typically done in your main process:

import { app } from 'electron';

// Register protocol handler
if (process.defaultApp) {
// Development: Include path to main file
app.setAsDefaultProtocolClient('myapp', process.execPath, [
path.resolve(process.argv[1])
]);
} else {
// Production: No additional arguments needed
app.setAsDefaultProtocolClient('myapp');
}

Note: Replace 'myapp' with your custom protocol scheme.

3. Single Instance Lock

Your app must implement single-instance lock to receive deeplinks:

import { app } from 'electron';

const gotTheLock = app.requestSingleInstanceLock();

if (!gotTheLock) {
// Another instance is running, quit this one
app.quit();
} else {
// This is the primary instance, handle second-instance events
app.on('second-instance', (event, commandLine, workingDirectory) => {
// Focus window if minimized
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}

// Handle the deeplink from commandLine
const url = commandLine.find(arg => arg.startsWith('myapp://'));
if (url) {
handleDeeplink(url);
}
});
}

App Implementation

Complete Example (All Platforms)

Here's a complete implementation that works on Windows, macOS, and Linux:

main.ts

import { app, BrowserWindow } from 'electron';
import path from 'path';

// ===== WINDOWS & LINUX: Parse userData from deeplink (MUST be before app.ready) =====
if (process.platform === 'win32' || process.platform === 'linux') {
const url = process.argv.find(arg => arg.startsWith('myapp://'));
if (url) {
try {
const parsed = new URL(url);
const userDataPath = parsed.searchParams.get('userData');
if (userDataPath) {
// Set user data directory to match test instance
app.setPath('userData', userDataPath);
}
} catch (error) {
console.error('Failed to parse deeplink URL:', error);
}
}
}

// ===== Single Instance Lock =====
const gotTheLock = app.requestSingleInstanceLock();

if (!gotTheLock) {
app.quit();
} else {
app.on('second-instance', (event, commandLine, workingDirectory) => {
// Focus the main window if it exists
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}

// Find and handle deeplink from command line
const url = commandLine.find(arg => arg.startsWith('myapp://'));
if (url) {
handleDeeplink(url);
}
});

// Standard app initialization
app.whenReady().then(createWindow);
}

// ===== Protocol Handler Registration =====
if (process.defaultApp) {
app.setAsDefaultProtocolClient('myapp', process.execPath, [
path.resolve(process.argv[1])
]);
} else {
app.setAsDefaultProtocolClient('myapp');
}

// ===== Application Setup =====
let mainWindow: BrowserWindow | null = null;

function createWindow() {
mainWindow = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
nodeIntegration: false,
contextIsolation: true
}
});

mainWindow.loadFile('index.html');

// Handle deeplink on macOS (open-url event)
app.on('open-url', (event, url) => {
event.preventDefault();
handleDeeplink(url);
});

// Handle initial deeplink on Windows/Linux (from argv)
const url = process.argv.find(arg => arg.startsWith('myapp://'));
if (url) {
handleDeeplink(url);
}
}

// ===== Deeplink Handler =====
function handleDeeplink(url: string) {
console.log('Received deeplink:', url);

try {
const parsed = new URL(url);

// IMPORTANT: Remove userData parameter before processing
// (This parameter is only for Windows single-instance routing)
parsed.searchParams.delete('userData');

const cleanUrl = parsed.toString();

// Store for test verification (optional)
if (!globalThis.receivedDeeplinks) {
globalThis.receivedDeeplinks = [];
}
globalThis.receivedDeeplinks.push(cleanUrl);

// Your actual deeplink handling logic here
const action = parsed.hostname; // e.g., 'open' from 'myapp://open'
const params = Object.fromEntries(parsed.searchParams);

switch (action) {
case 'open':
// Handle 'myapp://open?file=...'
if (params.file) {
openFile(params.file);
}
break;

case 'action':
// Handle 'myapp://action?...'
performAction(params);
break;

default:
console.warn('Unknown deeplink action:', action);
}

// Notify renderer process if needed
if (mainWindow) {
mainWindow.webContents.send('deeplink-received', cleanUrl);
}
} catch (error) {
console.error('Failed to parse deeplink:', error);
}
}

function openFile(filePath: string) {
console.log('Opening file:', filePath);
// Your file opening logic
}

function performAction(params: Record<string, string>) {
console.log('Performing action with params:', params);
// Your action logic
}

Common Issues

Symptom: On Windows or Linux, triggering a deeplink creates a new application instance instead of routing to the test instance.

Cause: The test instance and the deeplink-triggered instance are using different user data directories, preventing Electron's single-instance lock from working.

Solution:

  1. Ensure you're using a packaged binary (not appEntryPoint) in your WDIO configuration
  2. Verify your app parses the userData parameter on Windows and Linux:
if (process.platform === 'win32' || process.platform === 'linux') {
const url = process.argv.find(arg => arg.startsWith('myapp://'));
if (url) {
const parsed = new URL(url);
const userDataPath = parsed.searchParams.get('userData');
if (userDataPath) {
app.setPath('userData', userDataPath);
}
}
}
  1. Make sure this code runs before app.whenReady() or any other app initialization

For more details, see the Common Issues guide.

Warning: "Using appEntryPoint with protocol handlers"

Symptom: You see a warning in your test logs about using appEntryPoint with protocol handlers on Windows or Linux.

Cause: Protocol handlers on Windows and Linux require a registered executable binary at the OS level. Script-based apps (appEntryPoint) cannot register as protocol handlers.

Solution: Use appBinaryPath (or let the service auto-detect it) instead of appEntryPoint:

// Before (doesn't work for protocol handlers on Windows/Linux)
'wdio:electronServiceOptions': {
appEntryPoint: './dist/main.js'
}

// After (works correctly)
'wdio:electronServiceOptions': {
appBinaryPath: './dist/linux-unpacked/MyApp'
// OR let the service auto-detect your binary
}

Warning: "No user data directory detected"

Symptom: You see a warning about missing user data directory.

Cause: The service couldn't detect a user data directory from your app configuration.

Solution: Explicitly set the user data directory in your app args:

'wdio:electronServiceOptions': {
appBinaryPath: './dist/win-unpacked/MyApp.exe',
appArgs: ['--user-data-dir=/tmp/my-test-user-data']
}

Symptom: Error: "Invalid deeplink protocol: https. Expected a custom protocol."

Cause: You're trying to use triggerDeeplink() with http/https/file protocols, which aren't custom protocols.

Solution: Only use custom protocol schemes:

// Correct - custom protocol
await browser.electron.triggerDeeplink('myapp://action');

// Incorrect - web protocol
await browser.electron.triggerDeeplink('https://example.com'); // Throws error

// Incorrect - file protocol
await browser.electron.triggerDeeplink('file:///path/to/file'); // Throws error

Symptom: The deeplink is triggered but your app doesn't receive it.

Possible Causes and Solutions:

  1. Protocol not registered:

    • Verify app.setAsDefaultProtocolClient() is called
    • Check your app's package.json has correct protocol configuration
  2. Missing second-instance handler:

    • Ensure you've implemented app.on('second-instance', ...) handler
    • Verify the handler is checking for your protocol in commandLine
  3. macOS open-url handler missing:

    • Add app.on('open-url', ...) handler for macOS
    • Call event.preventDefault() in the handler
  4. Deeplink parsed incorrectly:

    • Check console logs to see if the URL is being received
    • Verify URL parsing logic handles your URL format

Timing Issues

Symptom: Tests fail intermittently because the app hasn't processed the deeplink yet.

Solution: Always use waitUntil to wait for the app to process the deeplink:

await browser.electron.triggerDeeplink('myapp://action');

// Wait for app to process
await browser.waitUntil(async () => {
const processed = await browser.electron.execute(() => {
return globalThis.deeplinkProcessed;
});
return processed === true;
}, {
timeout: 5000,
timeoutMsg: 'App did not process the deeplink within 5 seconds'
});

Welcome! How can I help?

WebdriverIO AI Copilot