fix(wdio-xvfb): autoXvfb should disable xvfb completely (#14700)

* feat(wdio-xvfb): add `enable`, remove CI check, update types

* chore: update lockfile

* docs(wdio-xvfb): update for new options

* test(wdio-xvfb): update units

* docs(wdio-xvfb): verbiage tweaks

* fix(wdio-local-runner): explicitly disable xvfb when autoXvfb is false

* docs(website): add page on headless / xvfb to testrunner

* test(wdio-local-runner): add tests for new options

* test(e2e): update for new behaviour

* chore(wdio-xvfb): revert some type changes

* chore(wdio-xvfb): fix type error

* test(wdio-xvfb): add comments

* test(wdio-xvfb): fix units and type issues

* feat(wdio-xvfb): improve auto install handling

* chore(wdio-local-runner): update type

* chore(types): update `xvfbAutoInstall` option for advanced control

* test(wdio-xvfb): update units for new behaviour

* test(wdio-local-runner): add tests for passing xvfbAutoInstall

* docs(wdio-xvfb): update for new xvfbAutoInstall

* docs(website): update for new `xvfbAutoInstall`

* refactor(wdio-xvfb): fix logic and clean up

* test(wdio-xvfb): fix units

* test(wdio-xvfb): fix windows-specific test failures

* test(e2e): fix base xvfb tests

* test(wdio-xvfb): add a couple more object format units

* test(e2e): add advanced config tests

* refactor(e2e): use marker files to test custom commands

* docs(website): add more detail

* test(e2e): dynamically assign mode

* feat(wdio-xvfb): array command escaping

* fix(e2e): update command

* docs(website): add missing xvfb options

* chore(wdio-local-runner): remove unnecessary `any`

* refactor(wdio-xvfb): pull apart options

* docs(wdio-xvfb): update for new options

* docs(website): update for new options

* refactor(types): rework xvfb options

* chore(wdio-local-runner): pass through new options

* test(e2e): update for new options, skip non-linux platforms

* test(wdio-xvfb): update units

* test(wdio-local-runner): update units

* fix(e2e): remove unnecessary `forceInstall`

* refactor(e2e): simplify tests

* fix(e2e): change `autoInstallMode` default

* docs(wdio-xvfb): change `autoInstallMode` default

* docs(website): update `autoInstallMode` default

* test(wdio-xvfb): update for `autoInstallMode` default change
This commit is contained in:
goosewobbler 2025-08-24 05:12:18 +01:00 committed by GitHub
parent df3ec33741
commit 3ac2341c00
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1137 additions and 415 deletions

View file

@ -3,155 +3,42 @@ import { execSync } from 'node:child_process'
import { XvfbManager } from '@wdio/xvfb'
describe('xvfb fresh installation', () => {
describe('autoInstall disabled', () => {
let xvfbManager: XvfbManager
it('should install xvfb using detected package manager', async function(this: Mocha.Context) {
this.timeout(300000) // 5 minutes for real installation
beforeEach(() => {
xvfbManager = new XvfbManager()
})
const manager = new XvfbManager({ autoInstall: true })
it('should not install or wrap when autoInstall is disabled', async () => {
// Ensure Linux in CI baseline
expect(process.platform).toBe('linux')
expect(process.env.CI).toBeDefined()
// Should initially not be available in base image
expect(manager.shouldRun()).toBe(true)
// Sanity: xvfb-run should not be present in this base image
let xvfbRunExists = false
try {
execSync('which xvfb-run', { stdio: 'ignore' })
xvfbRunExists = true
} catch {
// intentionally ignore - absence is expected
}
expect(xvfbRunExists).toBe(false)
// Real installation attempt
const result = await manager.init()
expect(result).toBe(true)
// Init should skip installation and return false
const initResult = await xvfbManager.init()
expect(initResult).toBe(false)
// Verify createWorkerProcess falls back to regular fork (not xvfb-run)
const { ProcessFactory } = await import('@wdio/xvfb')
const processFactory = new ProcessFactory(xvfbManager)
const mockProcess = await processFactory.createWorkerProcess(
'/mock/path/run.js',
['--test'],
{
cwd: process.cwd(),
env: process.env as Record<string, string>,
execArgv: [],
stdio: ['inherit', 'pipe', 'pipe', 'ipc']
}
)
// Should not have been spawned via 'xvfb-run'
expect(mockProcess.spawnfile).not.toBe('xvfb-run')
mockProcess.kill('SIGTERM')
})
it('should skip install even with headless capabilities when autoInstall is disabled', async () => {
// Headless Chrome capabilities
const caps: WebdriverIO.Capabilities = {
'goog:chromeOptions': {
args: ['--headless=new']
}
}
const result = await xvfbManager.init(caps)
expect(result).toBe(false)
})
// Verify xvfb-run is now available
const xvfbRunPath = execSync('which xvfb-run', { encoding: 'utf8' }).trim()
expect(xvfbRunPath).toContain('xvfb-run')
})
describe('autoInstall enabled', () => {
let xvfbManager: XvfbManager
it('should verify xvfb works after installation', async function(this: Mocha.Context) {
this.timeout(300000)
beforeEach(() => {
xvfbManager = new XvfbManager({ autoInstall: true })
})
const manager = new XvfbManager({ autoInstall: true })
it('should detect missing xvfb and install it automatically when opted-in', async function(this: Mocha.Context) {
// Set a longer timeout for this test since it involves package installation
this.timeout(300000) // 5 minutes
// Install and initialize
const result = await manager.init()
expect(result).toBe(true)
// Verify we're running on Linux in CI
expect(process.platform).toBe('linux')
expect(process.env.CI).toBeDefined()
// Verify xvfb.shouldRun() returns true
expect(xvfbManager.shouldRun()).toBe(true)
// Verify xvfb-run is initially not available
let xvfbRunExists = false
try {
execSync('which xvfb-run', { stdio: 'ignore' })
xvfbRunExists = true
} catch {
// Expected - xvfb-run should not exist initially
}
expect(xvfbRunExists).toBe(false)
// Initialize xvfb - this should trigger installation
const initResult = await xvfbManager.init()
expect(initResult).toBe(true)
// Verify xvfb-run is now available
const xvfbRunPath = execSync('which xvfb-run', { encoding: 'utf8' }).trim()
expect(xvfbRunPath).toContain('xvfb-run')
expect(xvfbRunPath.length).toBeGreaterThan(0)
// Verify xvfb-run can execute successfully
const testOutput = execSync('xvfb-run --help', { encoding: 'utf8' })
expect(testOutput).toContain('xvfb-run')
})
it('should work with WebDriverIO local runner integration', async () => {
// Ensure xvfb is initialized
await xvfbManager.init()
// Import ProcessFactory and test its behavior
const { ProcessFactory } = await import('@wdio/xvfb')
const processFactory = new ProcessFactory(xvfbManager)
// Create a mock worker process
const mockProcess = await processFactory.createWorkerProcess(
'/mock/path/run.js',
['--test'],
{
cwd: process.cwd(),
env: process.env as Record<string, string>,
execArgv: [],
stdio: ['inherit', 'pipe', 'pipe', 'ipc']
}
)
// Verify the process was created
expect(mockProcess).toBeDefined()
expect(typeof mockProcess.on).toBe('function')
expect(typeof mockProcess.kill).toBe('function')
// Clean up
mockProcess.kill('SIGTERM')
})
// Test that xvfb-run can execute a simple command
try {
const testResult = execSync('xvfb-run --auto-servernum -- echo "installation verified"', {
encoding: 'utf8',
timeout: 10000
})
expect(testResult.trim()).toBe('installation verified')
} catch (error) {
throw new Error(`xvfb-run execution failed after installation: ${error}`)
}
})
it('should handle installation failures gracefully', async () => {
// Create a manager with an unsupported package manager, forcing installation
const failingManager = new XvfbManager({
packageManager: 'unsupported-manager',
forceInstall: true
})
// Should throw error for unsupported package manager
await expect(failingManager.init()).rejects.toThrow('Unsupported package manager: unsupported-manager')
})
afterEach(async () => {
// Skip process cleanup to prevent SIGTERM
await new Promise(resolve => setTimeout(resolve, 100))
})
after(() => {
// Ensure clean exit
setTimeout(() => {
process.exit(0)
}, 1000)
})
})

View file

@ -9,12 +9,10 @@ describe('xvfb existing installation', () => {
xvfbManager = new XvfbManager()
})
it('should detect existing xvfb installation', async () => {
// Verify we're running on Linux in CI
expect(process.platform).toBe('linux')
expect(process.env.CI).toBeDefined()
it('should detect and use pre-installed xvfb', async function(this: Mocha.Context) {
this.timeout(300000) // 5 minutes
// Verify xvfb.shouldRun() returns true
// Should detect existing xvfb installation
expect(xvfbManager.shouldRun()).toBe(true)
// Verify xvfb-run is available (pre-installed in this container)
@ -40,60 +38,13 @@ describe('xvfb existing installation', () => {
}
})
it('should still detect existing xvfb when autoInstall is disabled', async () => {
// reuse the default manager (autoInstall not set)
expect(xvfbManager.shouldRun()).toBe(true)
const initResult = await xvfbManager.init()
expect(initResult).toBe(true)
})
it('should execute xvfb-run commands with pre-installed xvfb', async function(this: Mocha.Context) {
this.timeout(300000)
it('should integrate correctly with ProcessFactory', async () => {
// Initialize xvfb manager
await xvfbManager.init()
// Import and test ProcessFactory integration
const { ProcessFactory } = await import('@wdio/xvfb')
const processFactory = new ProcessFactory(xvfbManager)
// Verify that shouldRun() returns true (indicating xvfb should be used)
expect(xvfbManager.shouldRun()).toBe(true)
// Create a test process - this should use the spawn path with xvfb-run
const mockProcess = await processFactory.createWorkerProcess(
'/mock/path/run.js',
['--test'],
{
cwd: process.cwd(),
env: process.env as Record<string, string>,
execArgv: [],
stdio: ['inherit', 'pipe', 'pipe', 'ipc']
}
)
// Verify the mock process was created correctly
expect(mockProcess).toBeDefined()
expect(typeof mockProcess.on).toBe('function')
expect(typeof mockProcess.kill).toBe('function')
// Optional: stronger assertion that xvfb-run is in use
// Not all platforms expose spawnfile consistently, so check argv0 if available
const procMeta = mockProcess as unknown as { spawnfile?: string, argv0?: string }
const argv0 = procMeta.spawnfile || procMeta.argv0
if (typeof argv0 === 'string') {
expect(argv0.includes('xvfb-run') || argv0 === 'xvfb-run').toBe(true)
}
// Clean up
mockProcess.kill('SIGTERM')
})
it('should handle xvfb-run command execution', async () => {
// Initialize manager with existing xvfb
await xvfbManager.init()
// Test that we can execute a simple command under xvfb-run
// Actually execute xvfb-run command
try {
// Execute a basic command that should work under xvfb
const result = execSync('xvfb-run --auto-servernum -- echo "test successful"', {
encoding: 'utf8',
timeout: 10000
@ -104,21 +55,6 @@ describe('xvfb existing installation', () => {
}
})
it('should work on different platforms appropriately', async () => {
// Test platform detection logic
if (process.platform === 'linux') {
// On Linux, should always return true in CI
expect(xvfbManager.shouldRun()).toBe(true)
} else {
// On non-Linux platforms, should return false
expect(xvfbManager.shouldRun()).toBe(false)
// Init should return false on non-Linux
const result = await xvfbManager.init()
expect(result).toBe(false)
}
})
afterEach(async () => {
// Skip process cleanup to prevent SIGTERM
await new Promise(resolve => setTimeout(resolve, 100))

View file

@ -13,6 +13,7 @@ export type { WorkerInstance }
export interface RunArgs extends Workers.WorkerRunPayload {
command: string
args: Workers.WorkerMessageArgs
cid: string
}
export default class LocalRunner {
@ -29,7 +30,10 @@ export default class LocalRunner {
) {
// Initialize XvfbManager
this.xvfbManager = new XvfbManager({
enabled: this.config.autoXvfb !== false,
autoInstall: this.config.xvfbAutoInstall,
autoInstallMode: this.config.xvfbAutoInstallMode,
autoInstallCommand: this.config.xvfbAutoInstallCommand,
xvfbMaxRetries: this.config.xvfbMaxRetries,
xvfbRetryDelay: this.config.xvfbRetryDelay
})

View file

@ -33,12 +33,71 @@ vi.mock('@wdio/xvfb', () => {
}
})
test("should pass xvfbAutoInstall:'sudo' to XvfbManager", async () => {
const xvfb = await import('@wdio/xvfb')
const runner = new LocalRunner(
{} as never,
{
xvfbAutoInstall: 'sudo',
autoXvfb: true,
xvfbAutoInstallMode: undefined,
xvfbAutoInstallCommand: undefined
} as any
)
await runner.run({
cid: 'auto-3',
command: 'run',
configFile: '/path/to/wdio.conf.js',
args: {},
caps: {},
specs: ['/foo/bar.test.js'],
execArgv: [],
retries: 0,
})
expect(vi.mocked(xvfb.XvfbManager)).toHaveBeenCalledWith(
expect.objectContaining({ autoInstall: 'sudo', autoInstallMode: undefined })
)
})
test('should pass object-form xvfbAutoInstall to XvfbManager', async () => {
const xvfb = await import('@wdio/xvfb')
const runner = new LocalRunner(
{} as never,
{
xvfbAutoInstall: { mode: 'sudo', command: 'echo install' },
autoXvfb: true,
xvfbAutoInstallMode: undefined,
xvfbAutoInstallCommand: undefined
} as any
)
await runner.run({
cid: 'auto-4',
command: 'run',
configFile: '/path/to/wdio.conf.js',
args: {},
caps: {},
specs: ['/foo/bar.test.js'],
execArgv: [],
retries: 0,
})
expect(vi.mocked(xvfb.XvfbManager)).toHaveBeenCalledWith(
expect.objectContaining({ autoInstall: { mode: 'sudo', command: 'echo install' }, autoInstallMode: undefined, autoInstallCommand: undefined })
)
})
test('should fork a new process', async () => {
const runner = new LocalRunner(
undefined as never,
{} as never,
{
outputDir: '/foo/bar',
runnerEnv: { FORCE_COLOR: 1 },
autoXvfb: true,
xvfbAutoInstall: undefined,
xvfbAutoInstallMode: undefined,
xvfbAutoInstallCommand: undefined
} as any
)
const worker = await runner.run({
@ -72,10 +131,14 @@ test('should fork a new process', async () => {
test('should shut down worker processes', async () => {
const runner = new LocalRunner(
undefined as never,
{} as never,
{
outputDir: '/foo/bar',
runnerEnv: { FORCE_COLOR: 1 },
autoXvfb: true,
xvfbAutoInstall: undefined,
xvfbAutoInstallMode: undefined,
xvfbAutoInstallCommand: undefined
} as any
)
const worker1 = await runner.run({
@ -128,10 +191,14 @@ test('should shut down worker processes', async () => {
test('should avoid shutting down if worker is not busy', async () => {
const runner = new LocalRunner(
undefined as never,
{} as never,
{
outputDir: '/foo/bar',
runnerEnv: { FORCE_COLOR: 1 },
autoXvfb: true,
xvfbAutoInstall: undefined,
xvfbAutoInstallMode: undefined,
xvfbAutoInstallCommand: undefined
} as any
)
@ -154,11 +221,15 @@ test('should avoid shutting down if worker is not busy', async () => {
test('should shut down worker processes in watch mode - regular', async () => {
const runner = new LocalRunner(
undefined as never,
{} as never,
{
outputDir: '/foo/bar',
runnerEnv: { FORCE_COLOR: 1 },
watch: true,
autoXvfb: true,
xvfbAutoInstall: undefined,
xvfbAutoInstallMode: undefined,
xvfbAutoInstallCommand: undefined
} as any
)
@ -200,11 +271,15 @@ test('should shut down worker processes in watch mode - regular', async () => {
test('should shut down worker processes in watch mode - mutliremote', async () => {
const runner = new LocalRunner(
undefined as never,
{} as never,
{
outputDir: '/foo/bar',
runnerEnv: { FORCE_COLOR: 1 },
watch: true,
autoXvfb: true,
xvfbAutoInstall: undefined,
xvfbAutoInstallMode: undefined,
xvfbAutoInstallCommand: undefined
} as any
)
@ -248,12 +323,17 @@ test('should shut down worker processes in watch mode - mutliremote', async () =
})
test('should avoid shutting down if worker is not busy', async () => {
const runner = new LocalRunner(undefined as never, {} as any)
const runner = new LocalRunner({} as never, {
autoXvfb: true,
xvfbAutoInstall: undefined,
xvfbAutoInstallMode: undefined,
xvfbAutoInstallCommand: undefined
} as any)
expect(await runner.initialize()).toBe(undefined)
})
test('should initialize xvfb lazily during first run when needed', async () => {
const runner = new LocalRunner(undefined as never, {} as any)
const runner = new LocalRunner({} as never, { autoXvfb: true } as any)
// Mock the xvfbManager instance that was created in constructor
const mockInit = vi.fn().mockResolvedValue(true)
@ -279,7 +359,7 @@ test('should initialize xvfb lazily during first run when needed', async () => {
})
test('should not initialize xvfb during run when not needed', async () => {
const runner = new LocalRunner(undefined as never, {} as any)
const runner = new LocalRunner({} as never, { autoXvfb: true } as any)
// Mock the xvfbManager instance that was created in constructor
const mockInit = vi.fn().mockResolvedValue(false)
@ -305,7 +385,7 @@ test('should not initialize xvfb during run when not needed', async () => {
})
test('should handle xvfb initialization failure gracefully', async () => {
const runner = new LocalRunner(undefined as never, {} as any)
const runner = new LocalRunner({} as never, { autoXvfb: true } as any)
// Mock the xvfbManager instance that was created in constructor
const mockInit = vi.fn().mockResolvedValue(true)
@ -326,7 +406,7 @@ test('should handle xvfb initialization failure gracefully', async () => {
})
test('should only initialize xvfb once across multiple runs', async () => {
const runner = new LocalRunner(undefined as never, {} as any)
const runner = new LocalRunner({} as never, { autoXvfb: true } as any)
// Mock the xvfbManager instance that was created in constructor
const mockInit = vi.fn().mockResolvedValue(true)
@ -354,17 +434,21 @@ test('should only initialize xvfb once across multiple runs', async () => {
specs: ['/foo/bar2.test.js'],
execArgv: [],
retries: 0,
})
} as any)
expect(mockInit).toHaveBeenCalledTimes(1)
})
test('should handle xvfb operations with existing workers', async () => {
const runner = new LocalRunner(
undefined as never,
{} as never,
{
outputDir: '/foo/bar',
runnerEnv: { FORCE_COLOR: 1 },
autoXvfb: true,
xvfbAutoInstall: undefined,
xvfbAutoInstallMode: undefined,
xvfbAutoInstallCommand: undefined
} as any
)
@ -397,8 +481,10 @@ test('should handle xvfb operations with existing workers', async () => {
test('should skip xvfb initialization when disabled in config', async () => {
const runner = new LocalRunner(
undefined as never,
{ autoXvfb: false } as any
{} as never,
{
autoXvfb: false
} as any
)
// Mock the xvfbManager instance that was created in constructor
@ -418,3 +504,106 @@ test('should skip xvfb initialization when disabled in config', async () => {
expect(mockInit).not.toHaveBeenCalled()
})
test('should pass xvfbAutoInstall:true to XvfbManager', async () => {
const xvfb = await import('@wdio/xvfb')
const runner = new LocalRunner(
{} as never,
{
xvfbAutoInstall: true,
autoXvfb: true
} as any
)
// Trigger lazy init to ensure constructor ran
await runner.run({
cid: 'auto-1',
command: 'run',
configFile: '/path/to/wdio.conf.js',
args: {},
caps: {},
specs: ['/foo/bar.test.js'],
execArgv: [],
retries: 0,
})
expect(vi.mocked(xvfb.XvfbManager)).toHaveBeenCalledWith(
expect.objectContaining({ autoInstall: true })
)
})
test('should pass xvfbAutoInstall:false to XvfbManager', async () => {
const xvfb = await import('@wdio/xvfb')
const runner = new LocalRunner(
{} as never,
{
xvfbAutoInstall: false,
autoXvfb: true
} as any
)
await runner.run({
cid: 'auto-2',
command: 'run',
configFile: '/path/to/wdio.conf.js',
args: {},
caps: {},
specs: ['/foo/bar.test.js'],
execArgv: [],
retries: 0,
})
expect(vi.mocked(xvfb.XvfbManager)).toHaveBeenCalledWith(
expect.objectContaining({ autoInstall: false })
)
})
test('should pass enabled:true to XvfbManager by default', async () => {
const xvfb = await import('@wdio/xvfb')
const runner = new LocalRunner(
{} as never,
{
autoXvfb: true
} as any
)
await runner.run({
cid: 'en-1',
command: 'run',
configFile: '/path/to/wdio.conf.js',
args: {},
caps: {},
specs: ['/foo/bar.test.js'],
execArgv: [],
retries: 0,
})
expect(vi.mocked(xvfb.XvfbManager)).toHaveBeenCalledWith(
expect.objectContaining({ enabled: true })
)
})
test('should pass enabled:false to XvfbManager when autoXvfb is false', async () => {
const xvfb = await import('@wdio/xvfb')
const runner = new LocalRunner(
{} as never,
{
autoXvfb: false
} as any
)
await runner.run({
cid: 'en-2',
command: 'run',
configFile: '/path/to/wdio.conf.js',
args: {},
caps: {},
specs: ['/foo/bar.test.js'],
execArgv: [],
retries: 0,
})
expect(vi.mocked(xvfb.XvfbManager)).toHaveBeenCalledWith(
expect.objectContaining({ enabled: false })
)
})

View file

@ -334,11 +334,23 @@ export interface Testrunner extends Hooks, WebdriverIO, WebdriverIO.HookFunction
*/
autoXvfb?: boolean
/**
* Enable automatic installation of Xvfb packages on Linux if `xvfb-run` is missing.
* Enable automatic installation of `xvfb-run` on Linux if missing.
* When false, the runner will warn and continue without installing.
* @default false
*/
xvfbAutoInstall?: boolean
/**
* Mode for automatic installation when xvfbAutoInstall is true.
* - 'root': install only if running as root (no sudo)
* - 'sudo': install if root or via non-interactive sudo (`sudo -n`) if available
* @default 'root'
*/
xvfbAutoInstallMode?: 'root' | 'sudo'
/**
* Custom command to use for installation instead of built-in package manager detection.
* When provided, this command is executed as-is and overrides the built-in installation logic.
*/
xvfbAutoInstallCommand?: string | string[]
/**
* Number of retry attempts for xvfb process failures.
* @default 3

View file

@ -44,7 +44,11 @@ if (ready) {
```ts
interface XvfbOptions {
enabled?: boolean; // Authoritative usage toggle (default: true). If false, never uses Xvfb
force?: boolean; // Force Xvfb even on non-Linux systems (for testing)
autoInstall?: boolean; // Enable automatic installation of xvfb-run if missing (default: false)
autoInstallMode?: 'root' | 'sudo'; // Installation mode when autoInstall is true (default: 'sudo')
autoInstallCommand?: string | string[]; // Custom installation command (overrides built-in package manager detection)
xvfbMaxRetries?: number; // Number of retry attempts for xvfb failures (default: 3)
xvfbRetryDelay?: number; // Base delay between retries in milliseconds (default: 1000)
}
@ -96,13 +100,12 @@ const result = await manager.executeWithRetry(async () => {
The utility automatically detects when Xvfb is needed:
- ✅ Linux systems without a DISPLAY environment variable
- ✅ Linux systems in CI environments (uses `is-ci` package for detection)
- ✅ Linux systems when headless browser flags are detected:
- **Chrome/Chromium**: `--headless`, `--headless=new`, `--headless=old`
- **Firefox**: `--headless`, `-headless`
- **Edge** (Chromium-based): `--headless`, `--headless=new`, `--headless=old`
- ❌ Non-Linux systems (unless `force: true`)
- ❌ Linux systems with existing DISPLAY (unless in CI or headless flags detected)
- ❌ Linux systems with existing DISPLAY (unless headless flags are detected)
## Features
@ -172,7 +175,7 @@ The utility automatically detects the system's package manager and installs xvfb
| **`apt`** | `apt-get` | Ubuntu, Debian, Pop!_OS, Mint, Elementary, Zorin, etc. | `xvfb` |
| **`dnf`** | `dnf` | Fedora, Rocky Linux, AlmaLinux, Nobara, Bazzite, etc. | `xorg-x11-server-Xvfb` |
| **`yum`** | `yum` | CentOS, RHEL (legacy) | `xorg-x11-server-Xvfb` |
| **`zypper`** | `zypper` | openSUSE, SUSE Linux Enterprise | `xorg-x11-server-Xvfb xvfb-run` |
| **`zypper`** | `zypper` | openSUSE, SUSE Linux Enterprise | `xvfb-run` |
| **`pacman`** | `pacman` | Arch Linux, Manjaro, EndeavourOS, CachyOS, etc. | `xorg-server-xvfb` |
| **`apk`** | `apk` | Alpine Linux, PostmarketOS | `xvfb-run` |
| **`xbps`** | `xbps-install` | Void Linux | `xvfb` |
@ -231,14 +234,16 @@ export const config = {
#### Advanced WDIO Configuration
You can customize xvfb behavior and retry settings:
You can customize xvfb behavior and retry settings in your WDIO config file:
```js
// wdio.conf.js - Custom xvfb configuration
export const config = {
// Xvfb configuration options (all optional)
autoXvfb: true, // Enable automatic xvfb (default: true)
xvfbAutoInstall: false, // Enable auto-install of xvfb if missing (default: false)
autoXvfb: true, // Authoritative usage toggle (default: true). If false, never uses Xvfb
xvfbAutoInstall: false, // Enable automatic installation of xvfb-run if missing (default: false)
xvfbAutoInstallMode: 'sudo', // Installation mode: 'root' or 'sudo' (default: 'sudo')
xvfbAutoInstallCommand: undefined, // Custom installation command (optional)
xvfbMaxRetries: 5, // Max retry attempts for xvfb failures (default: 3)
xvfbRetryDelay: 2000, // Base delay between retries in ms (default: 1000)
@ -251,10 +256,14 @@ export const config = {
};
```
**Configuration Options:**
**WDIO Testrunner Configuration Options:**
- **`autoXvfb`** *(boolean, default: true)*: Enable/disable automatic xvfb initialization
- **`xvfbAutoInstall`** *(boolean, default: false)*: Install `xvfb` packages automatically if `xvfb-run` is missing
- **`autoXvfb`** *(boolean, default: true)*: Authoritative usage toggle – if `false`, Xvfb is never used
- **`xvfbAutoInstall`** *(boolean, default: false)*: Enable automatic installation when `xvfb-run` is missing
- **`xvfbAutoInstallMode`** *('root' | 'sudo', default: 'sudo')*: Installation mode
- 'root': install only if running as root (no sudo)
- 'sudo': allow non-interactive sudo (`sudo -n`) when not root; skip if sudo not present
- **`xvfbAutoInstallCommand`** *(string | string[], optional)*: Custom installation command that overrides built-in package manager detection
- **`xvfbMaxRetries`** *(number, default: 3)*: Number of retry attempts when xvfb process fails
- **`xvfbRetryDelay`** *(number, default: 1000)*: Base delay between retries in milliseconds. Uses progressive delay (delay × attempt number)

View file

@ -38,11 +38,9 @@
},
"typeScriptVersion": "3.8.3",
"dependencies": {
"@wdio/logger": "workspace:*",
"is-ci": "^4.1.0"
"@wdio/logger": "workspace:*"
},
"devDependencies": {
"@types/is-ci": "^3.0.4",
"@types/node": "^20.0.0",
"@wdio/types": "workspace:*"
},

View file

@ -1,11 +1,14 @@
import { promisify } from 'node:util'
import { exec } from 'node:child_process'
import os from 'node:os'
import isCI from 'is-ci'
import logger from '@wdio/logger'
import type { Capabilities } from '@wdio/types'
export interface XvfbOptions {
/**
* Explicitly enable / disable Xvfb usage. If false, `shouldRun()` returns false.
*/
enabled?: boolean;
/**
* Force Xvfb to run even on non-Linux systems (for testing)
*/
@ -19,9 +22,22 @@ export interface XvfbOptions {
*/
forceInstall?: boolean;
/**
* Enable automatic installation of Xvfb packages if `xvfb-run` is missing (default: false)
* Enable automatic installation of `xvfb-run` if missing.
* @default false
*/
autoInstall?: boolean;
/**
* Mode for automatic installation when autoInstall is true.
* - 'root': install only if running as root (no sudo)
* - 'sudo': install if root or via non-interactive sudo (`sudo -n`) if available
* @default 'sudo'
*/
autoInstallMode?: 'root' | 'sudo';
/**
* Custom command to use for installation instead of built-in package manager detection.
* When provided, this command is executed as-is and overrides the built-in installation logic.
*/
autoInstallCommand?: string | string[];
/**
* Number of retry attempts for xvfb process failures (default: 3)
*/
@ -36,29 +52,38 @@ export interface XvfbOptions {
const execAsync = promisify(exec)
export class XvfbManager {
private force: boolean
private packageManagerOverride?: string
private forceInstall: boolean
private autoInstall: boolean
private maxRetries: number
private retryDelay: number
private log: ReturnType<typeof logger>
#force: boolean
#packageManagerOverride?: string
#forceInstall: boolean
#autoInstallSetting: boolean
#autoInstallMode: 'root' | 'sudo'
#autoInstallCommand?: string | string[]
#enabled: boolean
#maxRetries: number
#retryDelay: number
#log: ReturnType<typeof logger>
constructor(options: XvfbOptions = {}) {
this.force = options.force ?? false
this.packageManagerOverride = options.packageManager
this.forceInstall = options.forceInstall ?? false
this.autoInstall = options.autoInstall ?? false
this.maxRetries = options.xvfbMaxRetries ?? 3
this.retryDelay = options.xvfbRetryDelay ?? 1000
this.log = logger('@wdio/xvfb')
this.#force = options.force ?? false
this.#packageManagerOverride = options.packageManager
this.#forceInstall = options.forceInstall ?? false
this.#autoInstallSetting = options.autoInstall ?? false
this.#autoInstallMode = options.autoInstallMode ?? 'sudo'
this.#autoInstallCommand = options.autoInstallCommand
this.#enabled = options.enabled ?? true
this.#maxRetries = options.xvfbMaxRetries ?? 3
this.#retryDelay = options.xvfbRetryDelay ?? 1000
this.#log = logger('@wdio/xvfb')
}
/**
* Check if Xvfb should run on this system
*/
shouldRun(capabilities?: Capabilities.ResolvedTestrunnerCapabilities): boolean {
if (this.force) {
if (!this.#enabled) {
return false
}
if (this.#force) {
return true
}
@ -67,12 +92,12 @@ export class XvfbManager {
return false
}
// Check if we're in a headless environment (no DISPLAY set or in CI)
// Check if we're in a headless environment (no DISPLAY set)
const hasDisplay = process.env.DISPLAY
const inHeadlessEnvironment = !hasDisplay || isCI
const inHeadlessEnvironment = !hasDisplay
// Force XVFB if headless Chrome flags are detected
const hasHeadlessFlag = this.detectHeadlessMode(capabilities)
const hasHeadlessFlag = this.#detectHeadlessMode(capabilities as unknown as WebdriverIO.Config['capabilities'])
return inHeadlessEnvironment || hasHeadlessFlag
}
@ -82,25 +107,25 @@ export class XvfbManager {
* @returns Promise<boolean> - true if xvfb-run is ready, false if not needed
*/
public async init(capabilities?: Capabilities.ResolvedTestrunnerCapabilities): Promise<boolean> {
this.log.info('XvfbManager.init() called')
this.#log.info('XvfbManager.init() called')
if (!this.shouldRun(capabilities)) {
this.log.info('Xvfb not needed on current platform')
this.#log.info('Xvfb not needed on current platform')
return false
}
this.log.info('Xvfb should run, checking if setup is needed')
this.#log.info('Xvfb should run, checking if setup is needed')
try {
const isReady = await this.ensureXvfbRunAvailable()
const isReady = await this.#ensureXvfbRunAvailable()
if (isReady) {
this.log.info('xvfb-run is ready for use')
this.#log.info('xvfb-run is ready for use')
return true
}
this.log.warn('xvfb-run not available; continuing without virtual display')
this.#log.warn('xvfb-run not available; continuing without virtual display')
return false
} catch (error) {
this.log.error('Failed to setup xvfb-run:', error)
this.#log.error('Failed to setup xvfb-run:', error)
throw error
}
}
@ -108,47 +133,51 @@ export class XvfbManager {
/**
* Ensure xvfb-run is available, installing if necessary
*/
private async ensureXvfbRunAvailable(): Promise<boolean> {
this.log.info('Checking if xvfb-run is available...')
async #ensureXvfbRunAvailable(): Promise<boolean> {
this.#log.info('Checking if xvfb-run is available...')
if (!this.forceInstall) {
if (!this.#forceInstall) {
try {
// Check if xvfb-run is already available
await execAsync('which xvfb-run')
this.log.info('xvfb-run found in PATH')
this.#log.info('xvfb-run found in PATH')
return true
} catch {
if (!this.autoInstall) {
this.log.warn(
if (!this.#autoInstallSetting) {
this.#log.warn(
"xvfb-run not found. Skipping automatic installation. To enable auto-install, set 'xvfbAutoInstall: true' in your WDIO config."
)
this.log.warn(
this.#log.warn(
"Hint: you can also install it manually via your distro's package manager (e.g., 'sudo apt-get install xvfb', 'sudo dnf install xorg-x11-server-Xvfb')."
)
return false
}
this.log.info('xvfb-run not found, installing xvfb packages (xvfbAutoInstall enabled)...')
this.#log.info('xvfb-run not found, installing xvfb packages (xvfbAutoInstall enabled)...')
}
} else {
this.log.info('Force install enabled, skipping availability check')
this.#log.info('Force install enabled, skipping availability check')
}
// Install packages that include xvfb-run
this.log.info('Starting xvfb package installation...')
await this.installXvfbPackages()
this.log.info('Package installation completed')
this.#log.info('Starting xvfb package installation...')
const attempted = await this.#installXvfbPackages()
if (!attempted) {
this.#log.warn('Insufficient privileges to install xvfb packages automatically. Please install manually.')
return false
}
this.#log.info('Package installation completed')
// Verify xvfb-run is now available (skip if force install to allow error testing)
if (!this.forceInstall) {
this.log.info('Verifying xvfb-run installation...')
if (!this.#forceInstall) {
this.#log.info('Verifying xvfb-run installation...')
try {
const { stdout } = await execAsync('which xvfb-run')
this.log.info(
this.#log.info(
`Successfully installed xvfb-run at: ${stdout.trim()}`
)
return true
} catch (error) {
this.log.error('Failed to install xvfb-run:', error)
this.#log.error('Failed to install xvfb-run:', error)
throw new Error(
"xvfb-run is not available after installation. Please install it manually using your distribution's package manager."
)
@ -160,7 +189,7 @@ export class XvfbManager {
/**
* Detect if headless mode is enabled in Chrome/Chromium capabilities
*/
private detectHeadlessMode(capabilities?: Capabilities.ResolvedTestrunnerCapabilities): boolean {
#detectHeadlessMode(capabilities?: WebdriverIO.Config['capabilities']): boolean {
if (!capabilities) {
return false
}
@ -224,19 +253,19 @@ export class XvfbManager {
// Check Chrome options
if (this.hasHeadlessFlag(caps['goog:chromeOptions'], ['--headless'])) {
this.log.info('Detected headless Chrome flag, forcing XVFB usage')
this.#log.info('Detected headless Chrome flag, forcing XVFB usage')
return true
}
// Check Edge options (Chromium-based, uses same flags as Chrome)
if (this.hasHeadlessFlag(caps['ms:edgeOptions'], ['--headless'])) {
this.log.info('Detected headless Edge flag, forcing XVFB usage')
this.#log.info('Detected headless Edge flag, forcing XVFB usage')
return true
}
// Check Firefox options
if (this.hasHeadlessFlag(caps['moz:firefoxOptions'], ['--headless', '-headless'])) {
this.log.info('Detected headless Firefox flag, forcing XVFB usage')
this.#log.info('Detected headless Firefox flag, forcing XVFB usage')
return true
}
@ -263,8 +292,8 @@ export class XvfbManager {
protected async detectPackageManager(): Promise<string> {
// Use override if provided (for testing)
if (this.packageManagerOverride) {
return this.packageManagerOverride
if (this.#packageManagerOverride) {
return this.#packageManagerOverride
}
const packageManagers = [
@ -289,40 +318,124 @@ export class XvfbManager {
return 'unknown'
}
private async installXvfbPackages(): Promise<void> {
this.log.info('Detecting package manager...')
const packageManager = await this.detectPackageManager()
this.log.info(`Detected package manager: ${packageManager}`)
#prefixSudoNonInteractive(cmd: string): string {
// Prefix each sub-command (split by &&) with sudo -n
return cmd
.split('&&')
.map((part) => {
const p = part.trim()
if (p.length === 0) {
return p
}
return `sudo -n ${p}`
})
.join(' && ')
}
const installCommands: Record<string, string> = {
apt: 'sudo apt-get update -qq && sudo apt-get install -y xvfb',
dnf: 'sudo dnf makecache && sudo dnf install -y xorg-x11-server-Xvfb',
yum: 'sudo yum makecache && sudo yum install -y xorg-x11-server-Xvfb',
zypper: 'sudo zypper refresh && sudo zypper install -y xvfb-run',
pacman: 'sudo pacman -Sy --noconfirm xorg-server-xvfb',
apk: 'sudo apk update && sudo apk add --no-cache xvfb-run',
xbps: 'sudo xbps-install -Sy xvfb-run',
#shellEscapeArray(args: string[]): string {
// Properly escape shell arguments and join them
return args
.map(arg => {
// If arg contains spaces or special characters, wrap in quotes
if (/[\\$`"'\s]/.test(arg)) {
// Escape existing quotes and wrap in double quotes
return `"${arg.replace(/["\\$`]/g, '\\$&')}"`
}
return arg
})
.join(' ')
}
#getInstallMode(): 'root' | 'sudo' {
const mode = this.#autoInstallMode
return mode
}
#shouldSkipAutoInstall(isRoot: boolean, mode: 'root' | 'sudo', hasSudo: boolean, hasCustomCommand: boolean): boolean {
if (hasCustomCommand) {
return false
}
const command = installCommands[packageManager]
if (!command) {
if (!isRoot && mode !== 'sudo') {
this.#log.warn('Not running as root and sudo mode disabled; skipping auto-install')
return true
}
if (!isRoot && mode === 'sudo' && !hasSudo) {
this.#log.warn('Not running as root and sudo is not available; skipping auto-install')
return true
}
return false
}
async #installXvfbPackages(): Promise<boolean> {
// Determine privileges and desired mode
const isRoot = typeof process.getuid === 'function' ? process.getuid() === 0 : false
const mode = this.#getInstallMode()
const hasCustomCommand = Boolean(this.#autoInstallCommand)
let hasSudo = false
if (!isRoot && mode === 'sudo') {
try {
await execAsync('which sudo')
hasSudo = true
} catch {
hasSudo = false
}
}
if (this.#shouldSkipAutoInstall(isRoot, mode, hasSudo, hasCustomCommand)) {
return false
}
const command = await this.#getInstallCommand(isRoot, hasSudo)
this.#log.info(`Installing xvfb packages using command: ${command}`)
try {
this.#log.info('Starting package installation command execution...')
await execAsync(command, { timeout: 240000 }) // 4 minute timeout
this.#log.info(
'Package installation command completed successfully'
)
return true
} catch (error) {
this.#log.error('Package installation command failed:', error)
throw error
}
}
async #getInstallCommand(isRoot: boolean, hasSudo: boolean): Promise<string> {
const customInstallCommand = this.#autoInstallCommand
? (Array.isArray(this.#autoInstallCommand) ? this.#shellEscapeArray(this.#autoInstallCommand) : this.#autoInstallCommand)
: undefined
if (customInstallCommand) {
return customInstallCommand
}
this.#log.info('Detecting package manager...')
const packageManager = await this.detectPackageManager()
this.#log.info(`Detected package manager: ${packageManager}`)
const installCommands: Record<string, string> = {
apt: 'DEBIAN_FRONTEND=noninteractive apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y xvfb',
dnf: 'dnf -y makecache && dnf -y install xorg-x11-server-Xvfb',
yum: 'yum -y makecache && yum -y install xorg-x11-server-Xvfb',
zypper: 'zypper --non-interactive refresh && zypper --non-interactive install -y xvfb-run',
pacman: 'pacman -Sy --noconfirm xorg-server-xvfb',
apk: 'apk update && apk add --no-cache xvfb-run',
xbps: 'xbps-install -Sy xvfb-run',
}
if (!installCommands[packageManager]) {
throw new Error(
`Unsupported package manager: ${packageManager}. Please install Xvfb manually.`
)
}
this.log.info(`Installing xvfb packages using command: ${command}`)
try {
this.log.info('Starting package installation command execution...')
await execAsync(command, { timeout: 240000 }) // 4 minute timeout
this.log.info(
'Package installation command completed successfully'
)
} catch (error) {
this.log.error('Package installation command failed:', error)
throw error
}
return !isRoot && hasSudo ? this.#prefixSudoNonInteractive(installCommands[packageManager]) : installCommands[packageManager]
}
/**
@ -334,22 +447,22 @@ export class XvfbManager {
): Promise<T> {
let lastError: Error | unknown = null
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
for (let attempt = 1; attempt <= this.#maxRetries; attempt++) {
try {
if (attempt === 1) {
this.log.info(`🚀 Executing ${context}`)
this.#log.info(`🚀 Executing ${context}`)
} else {
this.log.info(`🔄 Retry attempt ${attempt}/${this.maxRetries}: ${context}`)
this.#log.info(`🔄 Retry attempt ${attempt}/${this.#maxRetries}: ${context}`)
}
const result = await commandFn()
if (attempt > 1) {
this.log.info(`✅ Success on attempt ${attempt}/${this.maxRetries}`)
this.#log.info(`✅ Success on attempt ${attempt}/${this.#maxRetries}`)
}
return result
} catch (error: unknown) {
this.log.info(`❌ Attempt ${attempt}/${this.maxRetries} failed: ${error}`)
this.#log.info(`❌ Attempt ${attempt}/${this.#maxRetries} failed: ${error}`)
lastError = error
// Check if this is an xvfb-related error that we should retry
@ -357,16 +470,16 @@ export class XvfbManager {
const isXvfbError = this.isXvfbError(errorMessage)
if (!isXvfbError) {
this.log.info('Non-xvfb error detected, not retrying')
this.#log.info('Non-xvfb error detected, not retrying')
throw error
}
if (attempt < this.maxRetries) {
const delay = this.retryDelay * attempt
this.log.info(`⏳ Waiting ${delay}ms before retry...`)
if (attempt < this.#maxRetries) {
const delay = this.#retryDelay * attempt
this.#log.info(`⏳ Waiting ${delay}ms before retry...`)
await new Promise(resolve => setTimeout(resolve, delay))
} else {
this.log.info(`❌ All ${this.maxRetries} attempts failed`)
this.#log.info(`❌ All ${this.#maxRetries} attempts failed`)
}
}
}

View file

@ -83,7 +83,7 @@ describe('ProcessFactory', () => {
cwd: '/working/dir',
env: { NODE_ENV: 'test' },
execArgv: ['--inspect'],
stdio: ['inherit', 'pipe', 'pipe', 'ipc'] as const
stdio: ['inherit', 'pipe', 'pipe', 'ipc'] as ('inherit' | 'pipe' | 'ignore' | 'ipc')[]
}
describe('when xvfb should not run', () => {
@ -105,9 +105,20 @@ describe('ProcessFactory', () => {
expect(result).toBe(mockChildProcess)
})
it('should not wrap with xvfb-run when explicitly disabled', async () => {
mockXvfbManager.shouldRun.mockReturnValue(false) // disabled -> shouldRun false
// even if xvfb-run exists, we shouldn't check for it when shouldRun is false
mockExecSync.mockReturnValue('/usr/bin/xvfb-run')
await processFactory.createWorkerProcess(scriptPath, args, options)
// we still call execSync('which xvfb-run') before deciding, so we only assert we didn't spawn xvfb-run
expect(mockSpawn).not.toHaveBeenCalled()
expect(mockFork).toHaveBeenCalled()
})
it('should use fork with default execArgv when not provided', async () => {
const optionsWithoutExecArgv = { ...options }
delete optionsWithoutExecArgv.execArgv
const { execArgv: _ignored, ...optionsWithoutExecArgv } = options
await processFactory.createWorkerProcess(scriptPath, args, optionsWithoutExecArgv)

View file

@ -92,15 +92,15 @@ describe('XvfbManager', () => {
expect(manager.shouldRun()).toBe(true)
})
it('should return true on Linux in CI environment', () => {
it('should return false on Linux when DISPLAY is set', () => {
mockPlatform.mockReturnValue('linux')
process.env.DISPLAY = ':0'
mockIsCI.value = true
expect(manager.shouldRun()).toBe(true)
expect(manager.shouldRun()).toBe(false)
})
it('should return false on Linux with existing DISPLAY (not in CI)', () => {
it('should return false on Linux with existing DISPLAY', () => {
mockPlatform.mockReturnValue('linux')
process.env.DISPLAY = ':0'
mockIsCI.value = false
@ -108,6 +108,14 @@ describe('XvfbManager', () => {
expect(manager.shouldRun()).toBe(false)
})
it('should return false when disabled via enabled:false', () => {
const disabledManager = new XvfbManager({ enabled: false })
mockPlatform.mockReturnValue('linux')
delete process.env.DISPLAY
expect(disabledManager.shouldRun()).toBe(false)
})
it('should return true when Chrome headless flag is detected', () => {
mockPlatform.mockReturnValue('linux')
process.env.DISPLAY = ':0'
@ -117,7 +125,7 @@ describe('XvfbManager', () => {
'goog:chromeOptions': {
args: ['--headless']
}
}
} as unknown as WebdriverIO.Config['capabilities']
expect(manager.shouldRun(capabilities)).toBe(true)
})
@ -131,7 +139,7 @@ describe('XvfbManager', () => {
'goog:chromeOptions': {
args: ['--headless=new']
}
}
} as unknown as WebdriverIO.Config['capabilities']
expect(manager.shouldRun(capabilities)).toBe(true)
})
@ -145,7 +153,7 @@ describe('XvfbManager', () => {
'goog:chromeOptions': {
args: ['--headless=old']
}
}
} as unknown as WebdriverIO.Config['capabilities']
expect(manager.shouldRun(capabilities)).toBe(true)
})
@ -159,7 +167,7 @@ describe('XvfbManager', () => {
'moz:firefoxOptions': {
args: ['--headless']
}
}
} as unknown as WebdriverIO.Config['capabilities']
expect(manager.shouldRun(capabilities)).toBe(true)
})
@ -173,7 +181,7 @@ describe('XvfbManager', () => {
'moz:firefoxOptions': {
args: ['-headless']
}
}
} as unknown as WebdriverIO.Config['capabilities']
expect(manager.shouldRun(capabilities)).toBe(true)
})
@ -198,7 +206,7 @@ describe('XvfbManager', () => {
}
}
}
}
} as unknown as WebdriverIO.Config['capabilities']
expect(manager.shouldRun(capabilities)).toBe(true)
})
@ -212,7 +220,7 @@ describe('XvfbManager', () => {
'goog:chromeOptions': {
args: ['--disable-gpu']
}
}
} as unknown as WebdriverIO.Config['capabilities']
expect(manager.shouldRun(capabilities)).toBe(false)
})
@ -224,7 +232,7 @@ describe('XvfbManager', () => {
const capabilities = {
'goog:chromeOptions': {}
}
} as unknown as WebdriverIO.Config['capabilities']
expect(manager.shouldRun(capabilities)).toBe(false)
})
@ -234,7 +242,7 @@ describe('XvfbManager', () => {
process.env.DISPLAY = ':0'
mockIsCI.value = false
expect(manager.shouldRun({})).toBe(false)
expect(manager.shouldRun(undefined)).toBe(false)
})
it('should handle undefined capabilities', () => {
@ -254,7 +262,7 @@ describe('XvfbManager', () => {
'ms:edgeOptions': {
args: ['--headless']
}
}
} as unknown as WebdriverIO.Config['capabilities']
expect(manager.shouldRun(capabilities)).toBe(true)
})
@ -291,26 +299,38 @@ describe('XvfbManager', () => {
'goog:chromeOptions': {
args: ['--headless']
}
}
} as unknown as WebdriverIO.Config['capabilities']
const result = await manager.init(capabilities)
expect(result).toBe(true)
})
it('should return false and skip setup when disabled via enabled:false', async () => {
const disabledManager = new XvfbManager({ enabled: false })
mockPlatform.mockReturnValue('linux')
delete process.env.DISPLAY
const result = await disabledManager.init()
expect(result).toBe(false)
expect(mockExecAsync).not.toHaveBeenCalled()
})
describe('autoInstall', () => {
it('should install xvfb when xvfb-run is not available and autoInstall is enabled', async () => {
// Mock xvfb-run not found first, then found after installation
it('should install xvfb with sudo -n when allowed and available (non-root, apt)', async () => {
// Sequence: which xvfb-run -> which apt-get -> which sudo -> run install -> which xvfb-run (verify)
mockExecAsync
.mockRejectedValueOnce(new Error('Command not found'))
.mockResolvedValueOnce({ stdout: '', stderr: '' })
.mockResolvedValueOnce({ stdout: '/usr/bin/which', stderr: '' })
.mockResolvedValueOnce({ stdout: 'apt-get install success', stderr: '' })
.mockResolvedValueOnce({ stdout: '/usr/bin/xvfb-run\n', stderr: '' })
.mockRejectedValueOnce(new Error('Command not found')) // which xvfb-run (initial)
.mockResolvedValueOnce({ stdout: '/usr/bin/apt-get', stderr: '' }) // which apt-get
.mockResolvedValueOnce({ stdout: '/usr/bin/sudo', stderr: '' }) // which sudo
.mockResolvedValueOnce({ stdout: 'installation success', stderr: '' }) // install
.mockResolvedValueOnce({ stdout: '/usr/bin/xvfb-run\n', stderr: '' }) // which xvfb-run (verify)
const manager = new XvfbManager({ autoInstall: true })
// Mock getuid to return non-root (1000) - works on all platforms
;(process as any).getuid = vi.fn().mockReturnValue(1000)
const manager = new XvfbManager({ autoInstall: true, autoInstallMode: 'sudo' })
// Mock platform and environment
mockPlatform.mockReturnValue('linux')
delete process.env.DISPLAY
@ -319,8 +339,9 @@ describe('XvfbManager', () => {
expect(result).toBe(true)
expect(mockExecAsync).toHaveBeenCalledWith('which xvfb-run')
expect(mockExecAsync).toHaveBeenCalledWith('which apt-get')
expect(mockExecAsync).toHaveBeenCalledWith('which sudo')
expect(mockExecAsync).toHaveBeenCalledWith(
'sudo apt-get update -qq && sudo apt-get install -y xvfb',
'sudo -n DEBIAN_FRONTEND=noninteractive apt-get update -qq && sudo -n DEBIAN_FRONTEND=noninteractive apt-get install -y xvfb',
{ timeout: 240000 }
)
})
@ -356,7 +377,7 @@ describe('XvfbManager', () => {
mockPlatform.mockReturnValue('linux')
})
it('should detect Ubuntu distribution', async () => {
it('should detect Ubuntu distribution and install without sudo when running as root', async () => {
// Mock xvfb-run not found, then package manager detection
mockExecAsync
.mockRejectedValueOnce(new Error('Command not found'))
@ -364,6 +385,9 @@ describe('XvfbManager', () => {
.mockResolvedValueOnce({ stdout: 'installation success', stderr: '' })
.mockResolvedValueOnce({ stdout: '/usr/bin/xvfb-run\n', stderr: '' })
// Mock getuid to return root (0) - works on all platforms
;(process as any).getuid = vi.fn().mockReturnValue(0)
const manager = new XvfbManager({ autoInstall: true })
delete process.env.DISPLAY
@ -371,7 +395,7 @@ describe('XvfbManager', () => {
expect(mockExecAsync).toHaveBeenCalledWith('which apt-get')
expect(mockExecAsync).toHaveBeenCalledWith(
'sudo apt-get update -qq && sudo apt-get install -y xvfb',
'DEBIAN_FRONTEND=noninteractive apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y xvfb',
{ timeout: 240000 }
)
})
@ -385,6 +409,9 @@ describe('XvfbManager', () => {
.mockResolvedValueOnce({ stdout: 'installation success', stderr: '' })
.mockResolvedValueOnce({ stdout: '/usr/bin/xvfb-run\n', stderr: '' })
// Mock getuid to return root (0) - works on all platforms
;(process as any).getuid = vi.fn().mockReturnValue(0)
const manager = new XvfbManager({ autoInstall: true })
delete process.env.DISPLAY
@ -392,7 +419,7 @@ describe('XvfbManager', () => {
expect(mockExecAsync).toHaveBeenCalledWith('which dnf')
expect(mockExecAsync).toHaveBeenCalledWith(
'sudo dnf makecache && sudo dnf install -y xorg-x11-server-Xvfb',
'dnf -y makecache && dnf -y install xorg-x11-server-Xvfb',
{ timeout: 240000 }
)
})
@ -409,6 +436,9 @@ describe('XvfbManager', () => {
.mockResolvedValueOnce({ stdout: 'installation success', stderr: '' })
.mockResolvedValueOnce({ stdout: '/usr/bin/xvfb-run\n', stderr: '' })
// Mock getuid to return root (0) - works on all platforms
;(process as any).getuid = vi.fn().mockReturnValue(0)
const manager = new XvfbManager({ autoInstall: true })
delete process.env.DISPLAY
@ -416,34 +446,50 @@ describe('XvfbManager', () => {
expect(mockExecAsync).toHaveBeenCalledWith('which pacman')
expect(mockExecAsync).toHaveBeenCalledWith(
'sudo pacman -Sy --noconfirm xorg-server-xvfb',
'pacman -Sy --noconfirm xorg-server-xvfb',
{ timeout: 240000 }
)
})
it('should detect dnf when apt-get is not available', async () => {
// Mock xvfb-run not found, then package manager detection
// Mock xvfb-run not found, sudo available, then package manager detection
mockExecAsync
.mockRejectedValueOnce(new Error('Command not found'))
.mockRejectedValueOnce(new Error('apt-get not found'))
.mockResolvedValueOnce({ stdout: '/usr/bin/dnf', stderr: '' })
.mockResolvedValueOnce({ stdout: 'installation success', stderr: '' })
.mockResolvedValueOnce({ stdout: '/usr/bin/xvfb-run\n', stderr: '' })
.mockRejectedValueOnce(new Error('Command not found')) // which xvfb-run
.mockResolvedValueOnce({ stdout: '/usr/bin/sudo', stderr: '' }) // which sudo
.mockRejectedValueOnce(new Error('apt-get not found')) // which apt-get
.mockResolvedValueOnce({ stdout: '/usr/bin/dnf', stderr: '' }) // which dnf
.mockResolvedValueOnce({ stdout: 'installation success', stderr: '' }) // dnf install
.mockResolvedValueOnce({ stdout: '/usr/bin/xvfb-run\n', stderr: '' }) // verify which xvfb-run
const manager = new XvfbManager({ autoInstall: true })
// Mock getuid to return non-root (1000) - works on all platforms
;(process as any).getuid = vi.fn().mockReturnValue(1000)
const manager = new XvfbManager({ autoInstall: true, autoInstallMode: 'sudo' })
delete process.env.DISPLAY
await manager.init()
expect(mockExecAsync).toHaveBeenCalledWith('which xvfb-run')
expect(mockExecAsync).toHaveBeenCalledWith('which apt-get')
expect(mockExecAsync).toHaveBeenCalledWith('which dnf')
expect(mockExecAsync).toHaveBeenCalledWith('which sudo')
})
it('should handle unsupported package managers gracefully', async () => {
// Mock all package managers as not found
// Mock xvfb-run not found, then all package managers as not found
mockExecAsync
.mockRejectedValueOnce(new Error('Command not found'))
.mockRejectedValue(new Error('Package manager not found'))
.mockRejectedValueOnce(new Error('Command not found')) // which xvfb-run
.mockRejectedValueOnce(new Error('apt-get not found')) // which apt-get
.mockRejectedValueOnce(new Error('dnf not found')) // which dnf
.mockRejectedValueOnce(new Error('yum not found')) // which yum
.mockRejectedValueOnce(new Error('zypper not found')) // which zypper
.mockRejectedValueOnce(new Error('pacman not found')) // which pacman
.mockRejectedValueOnce(new Error('apk not found')) // which apk
.mockRejectedValueOnce(new Error('xbps-install not found')) // which xbps-install
// Mock as root so installation is attempted
// Mock getuid to return root (0) - works on all platforms
;(process as any).getuid = vi.fn().mockReturnValue(0)
const manager = new XvfbManager({ autoInstall: true })
delete process.env.DISPLAY
@ -453,6 +499,248 @@ describe('XvfbManager', () => {
)
})
})
it("should skip install in 'sudo' mode when sudo is not present (non-root)", async () => {
// Mock xvfb-run not found, then sudo not found (should skip installation)
mockExecAsync
.mockRejectedValueOnce(new Error('Command not found')) // which xvfb-run
.mockRejectedValueOnce(new Error('sudo not found')) // which sudo
// Mock getuid to return non-root (1000) - works on all platforms
;(process as any).getuid = vi.fn().mockReturnValue(1000)
const manager = new XvfbManager({ autoInstall: true, autoInstallMode: 'sudo' })
mockPlatform.mockReturnValue('linux')
delete process.env.DISPLAY
const result = await manager.init()
expect(result).toBe(false)
expect(mockExecAsync).toHaveBeenCalledWith('which xvfb-run')
expect(mockExecAsync).toHaveBeenCalledWith('which sudo')
// Should not check package managers since installation is skipped
expect(mockExecAsync).not.toHaveBeenCalledWith('which apt-get')
// Should not attempt installation
expect(mockExecAsync).not.toHaveBeenCalledWith('DEBIAN_FRONTEND=noninteractive apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y xvfb')
})
it('should treat empty object form as root-only: installs when root', async () => {
mockExecAsync
.mockRejectedValueOnce(new Error('Command not found')) // which xvfb-run
.mockResolvedValueOnce({ stdout: '/usr/bin/apt-get', stderr: '' }) // which apt-get
.mockResolvedValueOnce({ stdout: 'installation success', stderr: '' }) // install
.mockResolvedValueOnce({ stdout: '/usr/bin/xvfb-run\n', stderr: '' }) // verify
// Mock getuid to return root (0) - works on all platforms
;(process as any).getuid = vi.fn().mockReturnValue(0)
const manager = new XvfbManager({ autoInstall: true, autoInstallMode: 'root' })
mockPlatform.mockReturnValue('linux')
delete process.env.DISPLAY
const result = await manager.init()
expect(result).toBe(true)
expect(mockExecAsync).toHaveBeenCalledWith(
'DEBIAN_FRONTEND=noninteractive apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y xvfb',
{ timeout: 240000 }
)
})
it('should treat empty object form as root-only: skips when not root', async () => {
mockExecAsync
.mockRejectedValueOnce(new Error('Command not found')) // which xvfb-run
.mockResolvedValueOnce({ stdout: '/usr/bin/apt-get', stderr: '' }) // which apt-get
// Mock getuid to return non-root (1000) - works on all platforms
;(process as any).getuid = vi.fn().mockReturnValue(1000)
const manager = new XvfbManager({ autoInstall: true, autoInstallMode: 'root' })
mockPlatform.mockReturnValue('linux')
delete process.env.DISPLAY
const result = await manager.init()
expect(result).toBe(false)
// no sudo check, no install
expect(mockExecAsync).not.toHaveBeenCalledWith('which sudo')
})
it("should not use sudo prefix when in 'sudo' mode but running as root", async () => {
mockExecAsync
.mockRejectedValueOnce(new Error('Command not found')) // which xvfb-run
.mockResolvedValueOnce({ stdout: '/usr/bin/apt-get', stderr: '' }) // which apt-get
.mockResolvedValueOnce({ stdout: 'installation success', stderr: '' }) // install
.mockResolvedValueOnce({ stdout: '/usr/bin/xvfb-run\n', stderr: '' }) // verify
// Mock getuid to return root (0) - works on all platforms
;(process as any).getuid = vi.fn().mockReturnValue(0)
const manager = new XvfbManager({ autoInstall: true, autoInstallMode: 'sudo' })
mockPlatform.mockReturnValue('linux')
delete process.env.DISPLAY
const result = await manager.init()
expect(result).toBe(true)
expect(mockExecAsync).toHaveBeenCalledWith(
'DEBIAN_FRONTEND=noninteractive apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y xvfb',
{ timeout: 240000 }
)
})
it("should check for sudo even with custom command in 'sudo' mode (non-root)", async () => {
mockExecAsync
.mockRejectedValueOnce(new Error('Command not found')) // which xvfb-run
.mockResolvedValueOnce({ stdout: '/usr/bin/apt-get', stderr: '' }) // PM detection
.mockResolvedValueOnce({ stdout: '/usr/bin/sudo', stderr: '' }) // which sudo
.mockResolvedValueOnce({ stdout: 'ok', stderr: '' }) // run custom
.mockResolvedValueOnce({ stdout: '/usr/bin/xvfb-run\n', stderr: '' }) // verify
// Mock getuid to return non-root (1000) - works on all platforms
;(process as any).getuid = vi.fn().mockReturnValue(1000)
const manager = new XvfbManager({ autoInstall: true, autoInstallMode: 'sudo', autoInstallCommand: 'echo install' })
mockPlatform.mockReturnValue('linux')
delete process.env.DISPLAY
const result = await manager.init()
expect(result).toBe(true)
expect(mockExecAsync).toHaveBeenCalledWith('which sudo')
expect(mockExecAsync).toHaveBeenCalledWith('echo install', { timeout: 240000 })
})
it('should handle zypper install flags (root)', async () => {
mockExecAsync
.mockRejectedValueOnce(new Error('Command not found')) // which xvfb-run
.mockRejectedValueOnce(new Error('apt-get not found'))
.mockRejectedValueOnce(new Error('dnf not found'))
.mockRejectedValueOnce(new Error('yum not found'))
.mockResolvedValueOnce({ stdout: '/usr/bin/zypper', stderr: '' }) // zypper
.mockResolvedValueOnce({ stdout: 'installation success', stderr: '' })
.mockResolvedValueOnce({ stdout: '/usr/bin/xvfb-run\n', stderr: '' })
// Mock getuid to return root (0) - works on all platforms
;(process as any).getuid = vi.fn().mockReturnValue(0)
const manager = new XvfbManager({ autoInstall: true })
mockPlatform.mockReturnValue('linux')
delete process.env.DISPLAY
await manager.init()
expect(mockExecAsync).toHaveBeenCalledWith(
'zypper --non-interactive refresh && zypper --non-interactive install -y xvfb-run',
{ timeout: 240000 }
)
})
it('should skip availability check when forceInstall is true and perform install', async () => {
mockExecAsync
.mockResolvedValueOnce({ stdout: '/usr/bin/apt-get', stderr: '' }) // which apt-get
.mockResolvedValueOnce({ stdout: 'installation success', stderr: '' })
// no post-verify since forceInstall skips it
// Mock getuid to return root (0) - works on all platforms
;(process as any).getuid = vi.fn().mockReturnValue(0)
const manager = new XvfbManager({ autoInstall: true, forceInstall: true })
mockPlatform.mockReturnValue('linux')
delete process.env.DISPLAY
const result = await manager.init()
expect(result).toBe(true)
// ensure no initial which xvfb-run call
expect(mockExecAsync).not.toHaveBeenCalledWith('which xvfb-run')
// but install was performed
expect(mockExecAsync).toHaveBeenCalledWith(
'DEBIAN_FRONTEND=noninteractive apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y xvfb',
{ timeout: 240000 }
)
})
it('should skip installation when not root and sudo is not allowed', async () => {
mockExecAsync
.mockRejectedValueOnce(new Error('Command not found')) // which xvfb-run (initial)
// Mock getuid to return non-root (1000) - works on all platforms
;(process as any).getuid = vi.fn().mockReturnValue(1000)
const manager = new XvfbManager({ autoInstall: true, autoInstallMode: 'root' })
mockPlatform.mockReturnValue('linux')
delete process.env.DISPLAY
const result = await manager.init()
expect(result).toBe(false)
expect(mockExecAsync).toHaveBeenCalledWith('which xvfb-run')
// should not call package manager detection since installation is skipped
expect(mockExecAsync).not.toHaveBeenCalledWith('which apt-get')
// should not check sudo
expect(mockExecAsync).not.toHaveBeenCalledWith('which sudo')
})
it('should use custom install command as-is (no sudo prefix)', async () => {
mockExecAsync
.mockRejectedValueOnce(new Error('Command not found')) // which xvfb-run (initial)
.mockResolvedValueOnce({ stdout: '/usr/bin/apt-get', stderr: '' }) // PM detection still runs
.mockResolvedValueOnce({ stdout: 'custom ok', stderr: '' }) // install
.mockResolvedValueOnce({ stdout: '/usr/bin/xvfb-run\n', stderr: '' }) // verify
// Mock getuid to return non-root (1000) - works on all platforms
;(process as any).getuid = vi.fn().mockReturnValue(1000)
const manager = new XvfbManager({ autoInstall: true, autoInstallMode: 'sudo', autoInstallCommand: 'my-custom-install' })
mockPlatform.mockReturnValue('linux')
delete process.env.DISPLAY
const result = await manager.init()
expect(result).toBe(true)
expect(mockExecAsync).toHaveBeenCalledWith(
'my-custom-install',
{ timeout: 240000 }
)
})
it('should handle object format with array commands', async () => {
mockExecAsync
.mockRejectedValueOnce(new Error('Command not found')) // which xvfb-run
.mockResolvedValueOnce({ stdout: '/usr/bin/sudo', stderr: '' }) // which sudo
.mockResolvedValueOnce({ stdout: 'array command ok', stderr: '' }) // install
.mockResolvedValueOnce({ stdout: '/usr/bin/xvfb-run\n', stderr: '' }) // verify
// Mock getuid to return non-root (1000) - works on all platforms
;(process as any).getuid = vi.fn().mockReturnValue(1000)
const manager = new XvfbManager({
autoInstall: true,
autoInstallMode: 'sudo',
autoInstallCommand: ['custom', 'install', 'command']
})
mockPlatform.mockReturnValue('linux')
delete process.env.DISPLAY
const result = await manager.init()
expect(result).toBe(true)
expect(mockExecAsync).toHaveBeenCalledWith(
'custom install command',
{ timeout: 240000 }
)
})
it('should handle object format with mode only (defaults to sudo behavior)', async () => {
mockExecAsync
.mockRejectedValueOnce(new Error('Command not found')) // which xvfb-run
.mockResolvedValueOnce({ stdout: '/usr/bin/sudo', stderr: '' }) // which sudo
.mockRejectedValueOnce(new Error('apt-get not found')) // which apt-get
.mockResolvedValueOnce({ stdout: '/usr/bin/dnf', stderr: '' }) // which dnf
.mockResolvedValueOnce({ stdout: 'dnf install ok', stderr: '' }) // install
.mockResolvedValueOnce({ stdout: '/usr/bin/xvfb-run\n', stderr: '' }) // verify
// Mock getuid to return non-root (1000) - works on all platforms
;(process as any).getuid = vi.fn().mockReturnValue(1000)
const manager = new XvfbManager({ autoInstall: true, autoInstallMode: 'sudo' })
mockPlatform.mockReturnValue('linux')
delete process.env.DISPLAY
const result = await manager.init()
expect(result).toBe(true)
expect(mockExecAsync).toHaveBeenCalledWith('which sudo')
})
})
})

View file

@ -446,7 +446,7 @@ importers:
version: 5.2.4(vite@5.4.19(@types/node@24.0.11)(terser@5.43.1))(vue@3.5.17(typescript@5.8.3))
expect-webdriverio:
specifier: ^5.3.4
version: 5.3.4(@wdio/globals@9.17.0)(@wdio/logger@9.18.0)(webdriverio@9.18.4(puppeteer-core@24.12.1))
version: 5.3.4(@wdio/globals@9.17.0)(@wdio/logger@9.18.0)(webdriverio@9.19.0(puppeteer-core@24.12.1))
mocha:
specifier: ^10.2.0
version: 10.8.2
@ -1009,7 +1009,7 @@ importers:
version: link:../wdio-logger
'@wdio/runner':
specifier: ^9.16.2
version: 9.16.2(expect-webdriverio@5.3.4(@wdio/globals@packages+wdio-globals)(@wdio/logger@packages+wdio-logger)(webdriverio@9.16.2(puppeteer-core@24.12.1)))(webdriverio@9.16.2(puppeteer-core@24.12.1))
version: 9.16.2(expect-webdriverio@5.3.4(@wdio/globals@packages+wdio-globals)(@wdio/logger@packages+wdio-logger)(webdriverio@9.18.4(puppeteer-core@24.12.1)))(webdriverio@9.18.4(puppeteer-core@24.12.1))
'@wdio/types':
specifier: workspace:*
version: link:../wdio-types
@ -1144,7 +1144,7 @@ importers:
version: 4.0.0
expect-webdriverio:
specifier: ^5.3.4
version: 5.3.4(@wdio/globals@9.17.0)(@wdio/logger@packages+wdio-logger)(webdriverio@9.18.4(puppeteer-core@24.12.1))
version: 5.3.4(@wdio/globals@9.17.0)(@wdio/logger@packages+wdio-logger)(webdriverio@9.19.0(puppeteer-core@24.12.1))
split2:
specifier: ^4.1.0
version: 4.2.0
@ -1500,13 +1500,7 @@ importers:
'@wdio/logger':
specifier: workspace:*
version: link:../wdio-logger
is-ci:
specifier: ^4.1.0
version: 4.1.0
devDependencies:
'@types/is-ci':
specifier: ^3.0.4
version: 3.0.4
'@types/node':
specifier: ^20.0.0
version: 20.19.5
@ -5686,9 +5680,6 @@ packages:
'@types/ip@1.1.3':
resolution: {integrity: sha512-64waoJgkXFTYnCYDUWgSATJ/dXEBanVkaP5d4Sbk7P6U7cTTMhxVyROTckc6JKdwCrgnAjZMn0k3177aQxtDEA==}
'@types/is-ci@3.0.4':
resolution: {integrity: sha512-AkCYCmwlXeuH89DagDCzvCAyltI2v9lh3U3DqSg/GrBYoReAaWwxfXCqMx9UV5MajLZ4ZFwZzV4cABGIxk2XRw==}
'@types/is-glob@4.0.4':
resolution: {integrity: sha512-3mFBtIPQ0TQetKRDe94g8YrxJZxdMillMGegyv6zRBXvq4peRRhf2wLZ/Dl53emtTsC29dQQBwYvovS20yXpiQ==}
@ -6122,6 +6113,10 @@ packages:
resolution: {integrity: sha512-fN+Z7SkKjb0u3UUMSxMN4d+CCZQKZhm/tx3eX7Rv+3T78LtpOjlesBYQ7Ax3tQ3tp8hgEo+CoOXU0jHEYubFrg==}
engines: {node: '>=18.20.0'}
'@wdio/config@9.19.0':
resolution: {integrity: sha512-LMhcI1xj4hfx//IW7WdicbMFhtwkxxaLVbxGS6EVh/yMaQLYZpLD4drJUYQPjy5Rk3DMWIB2OpH+z7f+WDxf4w==}
engines: {node: '>=18.20.0'}
'@wdio/dot-reporter@9.16.2':
resolution: {integrity: sha512-JzFegviZdpzgvt8w8uwI0pyJguIuJzfzlkkyWz1WUoqtilH4yrf5IYKzObnm3peh7iQ/y2J1SSeAjKr0Hr5xTg==}
engines: {node: '>=18.20.0'}
@ -6173,6 +6168,10 @@ packages:
resolution: {integrity: sha512-P86FvM/4XQGpJKwlC2RKF3I21TglPvPOozJGG9HoL0Jmt6jRF20ggO/nRTxU0XiWkRdqESUTmfA87bdCO4GRkQ==}
engines: {node: '>=18.20.0'}
'@wdio/types@9.19.0':
resolution: {integrity: sha512-prBgfxawncE5NDQdLJlXhoNsC+YPcHGls/fT2mCo0mwYV9zUyTBfLSt+Eo6l+mtvRGAkmMtn5LuQogUWwYHEDg==}
engines: {node: '>=18.20.0'}
'@wdio/utils@9.16.2':
resolution: {integrity: sha512-bsRdEUXUTYvznXH/Z+p6HDzHSjMI6I6bnu8WXWTeDDDyqybWK5D8cbZvs8A/kMmGXoz1GZkSBHxy4Z5NTg8OQg==}
engines: {node: '>=18.20.0'}
@ -6181,6 +6180,10 @@ packages:
resolution: {integrity: sha512-M+QH05FUw25aFXZfjb+V16ydKoURgV61zeZrMjQdW2aAiks3F5iiI9pgqYT5kr1kHZcMy8gawGqQQ+RVfKYscQ==}
engines: {node: '>=18.20.0'}
'@wdio/utils@9.19.0':
resolution: {integrity: sha512-AHcxB1zSVxpe4Ure4MMnWAu2nh7kDVfZT0X4XOINVrQwLxjXSSy05iJ0589BrovqK0tV9lUNn1Rm//5gIiVD4Q==}
engines: {node: '>=18.20.0'}
'@webassemblyjs/ast@1.14.1':
resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==}
@ -9360,10 +9363,6 @@ packages:
resolution: {integrity: sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==}
hasBin: true
is-ci@4.1.0:
resolution: {integrity: sha512-Ab9bQDQ11lWootZUI5qxgN2ZXwxNI5hTwnsvOc1wyxQ7zQ8OkEDw79mI0+9jI3x432NfwbVRru+3noJfXF6lSQ==}
hasBin: true
is-core-module@2.16.1:
resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==}
engines: {node: '>= 0.4'}
@ -13811,6 +13810,10 @@ packages:
resolution: {integrity: sha512-07lC4FLj45lHJo0FvLjUp5qkjzEGWJWKGsxLoe9rQ2Fg88iYsqgr9JfSj8qxHpazBaBd+77+ZtpmMZ2X2D1Zuw==}
engines: {node: '>=18.20.0'}
webdriver@9.19.0:
resolution: {integrity: sha512-o5ZUvy1IFrlkVuf9hnlwQQnTMVSkEurXQkREb9XXSfcGdzValQejw7vmIrROaPVTgmLcnCq17cNzvirRWkBmgw==}
engines: {node: '>=18.20.0'}
webdriverio@9.16.2:
resolution: {integrity: sha512-aRcfBZyY+OFqz2DI0ZYmMahGlH3h/clAXXOQSFN5QfrHG4Cjuo5xy3lq4tVfszjEJ813+wwC4HJLbgDmMrPXkA==}
engines: {node: '>=18.20.0'}
@ -13829,6 +13832,15 @@ packages:
puppeteer-core:
optional: true
webdriverio@9.19.0:
resolution: {integrity: sha512-4CZ6/1e9tNmLx8C6cK0mwXknFrcafXT0ZwnyMP/ujdXFMfIb7NrQQ3L0kkA/Nq/D+EcpKvFRmOsxnm6FOV9ZIA==}
engines: {node: '>=18.20.0'}
peerDependencies:
puppeteer-core: '>=22.x || <=24.x'
peerDependenciesMeta:
puppeteer-core:
optional: true
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
@ -15949,7 +15961,7 @@ snapshots:
'@cucumber/ci-environment': 10.0.1
'@cucumber/cucumber-expressions': 17.1.0
'@cucumber/gherkin': 28.0.0
'@cucumber/gherkin-streams': 5.0.1(@cucumber/gherkin@28.0.0)(@cucumber/message-streams@4.0.1(@cucumber/messages@24.1.0))(@cucumber/messages@24.1.0)
'@cucumber/gherkin-streams': 5.0.1(@cucumber/gherkin@28.0.0)(@cucumber/message-streams@4.0.1(@cucumber/messages@26.0.1))(@cucumber/messages@24.1.0)
'@cucumber/gherkin-utils': 9.0.0
'@cucumber/html-formatter': 21.6.0(@cucumber/messages@24.1.0)
'@cucumber/message-streams': 4.0.1(@cucumber/messages@24.1.0)
@ -15989,7 +16001,7 @@ snapshots:
yaml: 2.8.0
yup: 1.2.0
'@cucumber/gherkin-streams@5.0.1(@cucumber/gherkin@28.0.0)(@cucumber/message-streams@4.0.1(@cucumber/messages@24.1.0))(@cucumber/messages@24.1.0)':
'@cucumber/gherkin-streams@5.0.1(@cucumber/gherkin@28.0.0)(@cucumber/message-streams@4.0.1(@cucumber/messages@26.0.1))(@cucumber/messages@24.1.0)':
dependencies:
'@cucumber/gherkin': 28.0.0
'@cucumber/message-streams': 4.0.1(@cucumber/messages@24.1.0)
@ -19362,10 +19374,6 @@ snapshots:
dependencies:
'@types/node': 20.19.5
'@types/is-ci@3.0.4':
dependencies:
ci-info: 3.9.0
'@types/is-glob@4.0.4': {}
'@types/istanbul-lib-coverage@2.0.6': {}
@ -19960,6 +19968,18 @@ snapshots:
- bare-buffer
- supports-color
'@wdio/config@9.19.0':
dependencies:
'@wdio/logger': 9.18.0
'@wdio/types': 9.19.0
'@wdio/utils': 9.19.0
deepmerge-ts: 7.1.5
glob: 10.4.5
import-meta-resolve: 4.1.0
transitivePeerDependencies:
- bare-buffer
- supports-color
'@wdio/dot-reporter@9.16.2':
dependencies:
'@wdio/reporter': 9.16.2
@ -19982,10 +20002,10 @@ snapshots:
- supports-color
- typescript
'@wdio/globals@9.16.2(expect-webdriverio@5.3.4(@wdio/globals@packages+wdio-globals)(@wdio/logger@packages+wdio-logger)(webdriverio@9.16.2(puppeteer-core@24.12.1)))(webdriverio@9.16.2(puppeteer-core@24.12.1))':
'@wdio/globals@9.16.2(expect-webdriverio@5.3.4(@wdio/globals@packages+wdio-globals)(@wdio/logger@packages+wdio-logger)(webdriverio@9.18.4(puppeteer-core@24.12.1)))(webdriverio@9.18.4(puppeteer-core@24.12.1))':
dependencies:
expect-webdriverio: 5.3.4(@wdio/globals@packages+wdio-globals)(@wdio/logger@packages+wdio-logger)(webdriverio@9.16.2(puppeteer-core@24.12.1))
webdriverio: 9.16.2(puppeteer-core@24.12.1)
expect-webdriverio: 5.3.4(@wdio/globals@packages+wdio-globals)(@wdio/logger@packages+wdio-logger)(webdriverio@9.18.4(puppeteer-core@24.12.1))
webdriverio: 9.18.4(puppeteer-core@24.12.1)
'@wdio/globals@9.16.2(expect-webdriverio@5.3.4(@wdio/globals@packages+wdio-globals)(@wdio/logger@packages+wdio-logger)(webdriverio@packages+webdriverio))(webdriverio@9.16.2(puppeteer-core@24.12.1))':
dependencies:
@ -19997,10 +20017,10 @@ snapshots:
expect-webdriverio: 5.3.4(@wdio/globals@9.17.0)(@wdio/logger@9.18.0)(webdriverio@9.16.2(puppeteer-core@24.12.1))
webdriverio: 9.16.2(puppeteer-core@24.12.1)
'@wdio/globals@9.17.0(expect-webdriverio@5.3.4)(webdriverio@9.18.4(puppeteer-core@24.12.1))':
'@wdio/globals@9.17.0(expect-webdriverio@5.3.4)(webdriverio@9.19.0(puppeteer-core@24.12.1))':
dependencies:
expect-webdriverio: 5.3.4(@wdio/globals@9.17.0)(@wdio/logger@9.18.0)(webdriverio@9.18.4(puppeteer-core@24.12.1))
webdriverio: 9.18.4(puppeteer-core@24.12.1)
expect-webdriverio: 5.3.4(@wdio/globals@9.17.0)(@wdio/logger@9.18.0)(webdriverio@9.19.0(puppeteer-core@24.12.1))
webdriverio: 9.19.0(puppeteer-core@24.12.1)
'@wdio/logger@9.16.2':
dependencies:
@ -20031,19 +20051,19 @@ snapshots:
diff: 7.0.0
object-inspect: 1.13.4
'@wdio/runner@9.16.2(expect-webdriverio@5.3.4(@wdio/globals@packages+wdio-globals)(@wdio/logger@packages+wdio-logger)(webdriverio@9.16.2(puppeteer-core@24.12.1)))(webdriverio@9.16.2(puppeteer-core@24.12.1))':
'@wdio/runner@9.16.2(expect-webdriverio@5.3.4(@wdio/globals@packages+wdio-globals)(@wdio/logger@packages+wdio-logger)(webdriverio@9.18.4(puppeteer-core@24.12.1)))(webdriverio@9.18.4(puppeteer-core@24.12.1))':
dependencies:
'@types/node': 20.19.5
'@wdio/config': 9.16.2
'@wdio/dot-reporter': 9.16.2
'@wdio/globals': 9.16.2(expect-webdriverio@5.3.4(@wdio/globals@packages+wdio-globals)(@wdio/logger@packages+wdio-logger)(webdriverio@9.16.2(puppeteer-core@24.12.1)))(webdriverio@9.16.2(puppeteer-core@24.12.1))
'@wdio/globals': 9.16.2(expect-webdriverio@5.3.4(@wdio/globals@packages+wdio-globals)(@wdio/logger@packages+wdio-logger)(webdriverio@9.18.4(puppeteer-core@24.12.1)))(webdriverio@9.18.4(puppeteer-core@24.12.1))
'@wdio/logger': 9.16.2
'@wdio/types': 9.16.2
'@wdio/utils': 9.16.2
deepmerge-ts: 7.1.5
expect-webdriverio: 5.3.4(@wdio/globals@packages+wdio-globals)(@wdio/logger@packages+wdio-logger)(webdriverio@9.16.2(puppeteer-core@24.12.1))
expect-webdriverio: 5.3.4(@wdio/globals@packages+wdio-globals)(@wdio/logger@packages+wdio-logger)(webdriverio@9.18.4(puppeteer-core@24.12.1))
webdriver: 9.16.2
webdriverio: 9.16.2(puppeteer-core@24.12.1)
webdriverio: 9.18.4(puppeteer-core@24.12.1)
transitivePeerDependencies:
- bare-buffer
- bufferutil
@ -20054,6 +20074,10 @@ snapshots:
dependencies:
'@types/node': 20.19.10
'@wdio/types@9.19.0':
dependencies:
'@types/node': 20.19.10
'@wdio/utils@9.16.2':
dependencies:
'@puppeteer/browsers': 2.10.5
@ -20093,6 +20117,26 @@ snapshots:
- bare-buffer
- supports-color
'@wdio/utils@9.19.0':
dependencies:
'@puppeteer/browsers': 2.10.6
'@wdio/logger': 9.18.0
'@wdio/types': 9.19.0
decamelize: 6.0.0
deepmerge-ts: 7.1.5
edgedriver: 6.1.2
geckodriver: 5.0.0
get-port: 7.1.0
import-meta-resolve: 4.1.0
locate-app: 2.5.0
mitt: 3.0.1
safaridriver: 1.0.0
split2: 4.2.0
wait-port: 1.1.0
transitivePeerDependencies:
- bare-buffer
- supports-color
'@webassemblyjs/ast@1.14.1':
dependencies:
'@webassemblyjs/helper-numbers': 1.13.2
@ -22591,25 +22635,25 @@ snapshots:
lodash.isequal: 4.5.0
webdriverio: 9.16.2(puppeteer-core@24.12.1)
expect-webdriverio@5.3.4(@wdio/globals@9.17.0)(@wdio/logger@9.18.0)(webdriverio@9.18.4(puppeteer-core@24.12.1)):
expect-webdriverio@5.3.4(@wdio/globals@9.17.0)(@wdio/logger@9.18.0)(webdriverio@9.19.0(puppeteer-core@24.12.1)):
dependencies:
'@vitest/snapshot': 3.2.4
'@wdio/globals': 9.17.0(expect-webdriverio@5.3.4)(webdriverio@9.18.4(puppeteer-core@24.12.1))
'@wdio/globals': 9.17.0(expect-webdriverio@5.3.4)(webdriverio@9.19.0(puppeteer-core@24.12.1))
'@wdio/logger': 9.18.0
expect: 30.0.4
jest-matcher-utils: 30.0.3
lodash.isequal: 4.5.0
webdriverio: 9.18.4(puppeteer-core@24.12.1)
webdriverio: 9.19.0(puppeteer-core@24.12.1)
expect-webdriverio@5.3.4(@wdio/globals@9.17.0)(@wdio/logger@packages+wdio-logger)(webdriverio@9.18.4(puppeteer-core@24.12.1)):
expect-webdriverio@5.3.4(@wdio/globals@9.17.0)(@wdio/logger@packages+wdio-logger)(webdriverio@9.19.0(puppeteer-core@24.12.1)):
dependencies:
'@vitest/snapshot': 3.2.4
'@wdio/globals': 9.17.0(expect-webdriverio@5.3.4)(webdriverio@9.18.4(puppeteer-core@24.12.1))
'@wdio/globals': 9.17.0(expect-webdriverio@5.3.4)(webdriverio@9.19.0(puppeteer-core@24.12.1))
'@wdio/logger': link:packages/wdio-logger
expect: 30.0.4
jest-matcher-utils: 30.0.3
lodash.isequal: 4.5.0
webdriverio: 9.18.4(puppeteer-core@24.12.1)
webdriverio: 9.19.0(puppeteer-core@24.12.1)
expect-webdriverio@5.3.4(@wdio/globals@packages+wdio-globals)(@wdio/logger@9.18.0)(webdriverio@packages+webdriverio):
dependencies:
@ -22621,16 +22665,6 @@ snapshots:
lodash.isequal: 4.5.0
webdriverio: link:packages/webdriverio
expect-webdriverio@5.3.4(@wdio/globals@packages+wdio-globals)(@wdio/logger@packages+wdio-logger)(webdriverio@9.16.2(puppeteer-core@24.12.1)):
dependencies:
'@vitest/snapshot': 3.2.4
'@wdio/globals': link:packages/wdio-globals
'@wdio/logger': link:packages/wdio-logger
expect: 30.0.4
jest-matcher-utils: 30.0.3
lodash.isequal: 4.5.0
webdriverio: 9.16.2(puppeteer-core@24.12.1)
expect-webdriverio@5.3.4(@wdio/globals@packages+wdio-globals)(@wdio/logger@packages+wdio-logger)(webdriverio@9.18.4(puppeteer-core@24.12.1)):
dependencies:
'@vitest/snapshot': 3.2.4
@ -23956,10 +23990,6 @@ snapshots:
dependencies:
ci-info: 3.9.0
is-ci@4.1.0:
dependencies:
ci-info: 4.3.0
is-core-module@2.16.1:
dependencies:
hasown: 2.0.2
@ -29368,6 +29398,25 @@ snapshots:
- supports-color
- utf-8-validate
webdriver@9.19.0:
dependencies:
'@types/node': 20.19.10
'@types/ws': 8.18.1
'@wdio/config': 9.19.0
'@wdio/logger': 9.18.0
'@wdio/protocols': 9.16.2
'@wdio/types': 9.19.0
'@wdio/utils': 9.19.0
deepmerge-ts: 7.1.5
https-proxy-agent: 7.0.6
undici: 6.21.3
ws: 8.18.3
transitivePeerDependencies:
- bare-buffer
- bufferutil
- supports-color
- utf-8-validate
webdriverio@9.16.2(puppeteer-core@24.12.1):
dependencies:
'@types/node': 20.19.5
@ -29438,6 +29487,41 @@ snapshots:
- supports-color
- utf-8-validate
webdriverio@9.19.0(puppeteer-core@24.12.1):
dependencies:
'@types/node': 20.19.10
'@types/sinonjs__fake-timers': 8.1.5
'@wdio/config': 9.19.0
'@wdio/logger': 9.18.0
'@wdio/protocols': 9.16.2
'@wdio/repl': 9.16.2
'@wdio/types': 9.19.0
'@wdio/utils': 9.19.0
archiver: 7.0.1
aria-query: 5.3.2
cheerio: 1.1.0
css-shorthand-properties: 1.1.2
css-value: 0.0.1
grapheme-splitter: 1.0.4
htmlfy: 0.8.1
is-plain-obj: 4.1.0
jszip: 3.10.1
lodash.clonedeep: 4.5.0
lodash.zip: 4.2.0
query-selector-shadow-dom: 1.0.1
resq: 1.11.0
rgb2hex: 0.2.5
serialize-error: 12.0.0
urlpattern-polyfill: 10.1.0
webdriver: 9.19.0
optionalDependencies:
puppeteer-core: 24.12.1
transitivePeerDependencies:
- bare-buffer
- bufferutil
- supports-color
- utf-8-validate
webidl-conversions@3.0.1: {}
webidl-conversions@4.0.2: {}

View file

@ -77,6 +77,7 @@
"items": [
"configurationfile",
"runner",
"headless-and-xvfb",
"frameworks",
"assertion",
"organizingsuites",

View file

@ -0,0 +1,190 @@
---
id: headless-and-xvfb
title: Headless & Xvfb with the Testrunner
description: How WebdriverIO uses Xvfb for headless testing on Linux, configuration options, CI recipes, and troubleshooting.
---
This page explains how the WebdriverIO testrunner supports headless execution on Linux using Xvfb (X Virtual Framebuffer). It covers when Xvfb is useful, how to configure it, and how it behaves in CI and Docker.
## When to use Xvfb vs native headless
- Use native headless (e.g., Chrome `--headless=...`) when possible for minimal overhead.
- Use Xvfb when:
- Testing Electron or apps that require a window manager or desktop environment
- You rely on GLX or window-manager dependent behaviors
- Your tooling expects a display server (`DISPLAY`)
- You run into Chromium errors such as:
- `session not created: probably user data directory is already in use ...`
- `Chrome failed to start: exited abnormally. (DevToolsActivePort file doesn't exist)`
The user data directory collision error can be misleading as it is often the result of a browser crash and immediate restart that reuses the same profile directory from the prior instance. Ensuring a stable display (e.g., via Xvfb) often resolves it - if not, you should pass a unique `--user-data-dir` per worker.
## Configuration
Four runner options control Xvfb behavior:
- `autoXvfb` (boolean, default: true)
- Authoritative toggle for usage. If `false`, the runner never uses Xvfb.
- If `true`, the runner may use Xvfb when needed.
- `xvfbAutoInstall` (boolean, default: false)
- Enable automatic installation of `xvfb-run` if missing
- When false, the runner will warn and continue without installing
- `xvfbAutoInstallMode` ('root' | 'sudo', default: 'sudo')
- 'root': install only if running as root (no sudo)
- 'sudo': allow non-interactive sudo (`sudo -n`) if not root; skip if sudo missing
- `xvfbAutoInstallCommand` (string | string[], optional)
- Custom command to use for installation instead of built-in package manager detection
- When provided, this command is executed as-is and overrides the built-in installation logic
- `xvfbMaxRetries` (number, default: 3)
- Number of retry attempts for xvfb process failures.
- Useful for flaky CI environments where Xvfb startup may occasionally fail.
- `xvfbRetryDelay` (number, default: 1000)
- Base delay between retries in milliseconds for xvfb process failures.
- Uses progressive delay: delay × attempt number (e.g., 1000ms, 2000ms, 3000ms, etc.).
Examples:
```ts
export const config: WebdriverIO.Config = {
// Use Xvfb when needed
autoXvfb: true,
// Auto-install Xvfb packages using sudo
xvfbAutoInstall: true,
xvfbAutoInstallMode: 'sudo',
capabilities: [{
browserName: 'chrome',
'goog:chromeOptions': { args: ['--headless=new', '--no-sandbox'] }
}]
}
```
```ts
export const config: WebdriverIO.Config = {
// Use Xvfb when needed
autoXvfb: true,
// Auto-install Xvfb packages using a custom command and sudo
xvfbAutoInstall: true,
xvfbAutoInstallMode: 'sudo',
xvfbAutoInstallCommand: 'curl -L https://github.com/X11/xvfb/releases/download/v1.20.14/xvfb-linux-x64.tar.gz | tar -xz -C /usr/local/bin/',
capabilities: [{
browserName: 'chrome',
'goog:chromeOptions': { args: ['--headless=new', '--no-sandbox'] }
}]
}
```
```ts
export const config: WebdriverIO.Config = {
// Use Xvfb when needed
autoXvfb: true,
// Auto-install Xvfb packages using sudo
xvfbAutoInstall: true,
xvfbAutoInstallMode: 'sudo',
// Configure retry behavior for flaky CI environments
xvfbMaxRetries: 5,
xvfbRetryDelay: 1500,
capabilities: [{
browserName: 'chrome',
'goog:chromeOptions': { args: ['--headless=new', '--no-sandbox'] }
}]
}
```
## Detection logic
- The runner considers Xvfb when:
- Running on Linux
- No `DISPLAY` is set (headless environment), or headless browser flags are passed
- If `DISPLAY` is set, the runner won’t force Xvfb by default and will honor your existing X server/window manager.
Notes:
- `autoXvfb: false` disables Xvfb usage entirely (no wrapping with `xvfb-run`).
- `xvfbAutoInstall` only affects installation if `xvfb-run` is missing; it does not turn usage on/off.
- `xvfbAutoInstallMode` controls the installation method: 'root' for root-only installs, 'sudo' for sudo-based installs (default: 'sudo').
- Built-in package installs are always non-interactive. Root-only unless you opt into 'sudo' mode.
- The retry mechanism uses progressive delays: `xvfbRetryDelay × attempt number` (e.g., 1000ms, 2000ms, 3000ms, etc.).
## Using an existing DISPLAY in CI
If your CI sets up its own X server/window manager (e.g., with `Xvfb :99` and a WM), either:
- Leave `autoXvfb: true` and ensure `DISPLAY` is exported; the runner will honor it and avoid wrapping.
- Or set `autoXvfb: false` to explicitly disable any Xvfb behavior from the runner.
## CI and Docker recipes
GitHub Actions (using native headless):
```yaml
- name: Run tests
run: npx wdio run ./wdio.conf.ts
```
GitHub Actions (virtual display via Xvfb if missing and opted in):
```ts
// wdio.conf.ts
export const config = {
autoXvfb: true,
xvfbAutoInstall: true
}
```
Docker (Ubuntu/Debian example – preinstall xvfb):
```Dockerfile
RUN apt-get update -qq && apt-get install -y xvfb
```
For other distributions, adjust the package manager and package name accordingly (e.g., `dnf install xorg-x11-server-Xvfb` on Fedora/RHEL-based, `zypper install xvfb-run` on openSUSE/SLE).
## Automatic installation support (xvfbAutoInstall)
When `xvfbAutoInstall` is enabled, WebdriverIO attempts to install `xvfb` using your system package manager. The following managers and packages are supported:
| Package Manager | Command | Distributions (examples) | Package Name(s) |
|-----------------|-----------------|-------------------------------------------------------------|----------------------------------|
| apt | `apt-get` | Ubuntu, Debian, Pop!_OS, Mint, Elementary, Zorin, etc. | `xvfb` |
| dnf | `dnf` | Fedora, Rocky Linux, AlmaLinux, Nobara, Bazzite, etc. | `xorg-x11-server-Xvfb` |
| yum | `yum` | CentOS, RHEL (legacy) | `xorg-x11-server-Xvfb` |
| zypper | `zypper` | openSUSE, SUSE Linux Enterprise | `xvfb-run` |
| pacman | `pacman` | Arch Linux, Manjaro, EndeavourOS, CachyOS, etc. | `xorg-server-xvfb` |
| apk | `apk` | Alpine Linux, PostmarketOS | `xvfb-run` |
| xbps-install | `xbps-install` | Void Linux | `xvfb` |
Notes:
- If your environment uses a different package manager, the install will fail with an error; install `xvfb` manually.
- Package names are distro-specific; the table reflects the common names per family.
## Troubleshooting
- “xvfb-run failed to start”
- The runner automatically retries Xvfb-related failures with progressive backoff. If failures persist, increase `xvfbMaxRetries` and `xvfbRetryDelay` for flaky environments.
- Xvfb wrapped unexpectedly in CI
- If you have a custom `DISPLAY` / WM setup, set `autoXvfb: false` or ensure `DISPLAY` is exported before the runner starts.
- Missing `xvfb-run`
- Keep `xvfbAutoInstall: false` to avoid modifying the environment; install via your base image or set `xvfbAutoInstall: true` to opt in.
- Frequent Xvfb startup failures in CI
- Increase `xvfbMaxRetries` (e.g., to 5-10) and `xvfbRetryDelay` (e.g., to 2000ms) for more resilient behavior in unstable environments.
## Advanced
- The runner creates processes via a factory that wraps the node worker with `xvfb-run` if Xvfb is needed and available.
- Headless browser flags (Chrome/Edge/Firefox) signal headless usage and can trigger Xvfb in environments without a `DISPLAY`.