feat: @wdio/xvfb (#14663)

* feat: `@wdio/xvfb`

* feat(local-runner): use `@wdio/xvfb`

* chore: update lockfile

* chore(wdio-xvfb): update version

* chore: add `@wdio/xvfb` to compile script

* chore(wdio-xvfb): fix exports

* test(wdio-xvfb): fix unit tests

* test(wdio-local-runner): add xvfb tests

* chore(wdio-xvfb): add logging

* chore(wdio-xvfb): process runs independently

* feat(wdio-xvfb): skip startup when display in use

* test(wdio-xvfb): fix unit tests

* refactor(wdio-xvfb): use `xvfb-run`

* chore: update lockfile

* refactor(wdio-local-runner): use processFactory

* refactor(wdio-xvfb): remove command execution

* test(e2e): add xvfb E2Es

* chore: update lockfile

* docs(wdio-xvfb): update LICENSE

Co-authored-by: Christian Bromann <git@bromann.dev>

* refactor(wdio-xvfb): rework distro detection

* docs(wdio-xvfb): update for distros

* test(e2e): add new distros, fix perm issues

* refactor(wdio-xvfb): use `is-ci`

* chore: update lockfile

* test(wdio-xvfb): mock `is-ci`

* refactor(wdio-local-runner): processFactory tweaks

* test(wdio-xvfb): fix `is-ci` mocking

* refactor(wdio-xvfb): rework distro detection

* refactor(wdio-local-runner): import xvfb directly

* fix(e2e): fix log perms issue

* fix(e2e): xvfb dockerfile fixes

* chore(e2e): exit correctly

* fix(wdio-xvfb): update install commands

* chore(e2e): ensure clean environments without xvfb

* docs(wdio-xvfb): update README

* chore(e2e): fix arch chrome install

* fix(e2e): fix SUSE perm issue

* fix(e2e): fix EACCES

* fix(e2e): fix exit issue

* chore(e2e): add zypper xvfb fallback

* fix(e2e): more aggressive removal of xvfb in clean images

* chore(e2e): fix issues

* fix(e2e): fix suse xvfb install

* chore(wdio-types): add disableAutoXvfb option

* feat(wdio-local-runner): skip auto xvfb init

* chore(e2e): disable auto xvfb init

* fix(wdio-local-runner): check if xvfb-run available

* chore(e2e): increase timeout

* chore(wdio-xvfb): add logging

* chore(wdio-xvfb): add override & force props for tests

* test(e2e): update for new manager props

* test(wdio-xvfb): update units

* test(e2e): add debug

* test(wdio-local-runner): make flaky test more robust

* chore(e2e): update chromium install for alpine

* chore(e2e): add more debug

* test(wdio-xvfb): update timeout values

* chore(e2e): update alpine repos

* test(e2e): add more debug

* test(e2e): add more debug

* test(e2e): remove cleanup

* test(e2e): port fix to existing-xvfb

* chore(e2e): standardize on node 22

* chore(e2e): remove debug

* chore(e2e): remove redundant test

* chore(e2e): add back alpine fix

* chore(e2e): update chromeOptions

* chore(e2e): display logs on failure

* chore(e2e): standardize nomenclature

* chore(e2e): delete README

* chore: add NL

* feat(wdio-xvfb): add void / xbps support

* test(e2e): add void E2Es

* refactor(e2e): remove rocky tests (covered by fedora)

* fix(e2e): fix void docker issues

* feat(wdio-xvfb): detect headless mode

* feat(wdio-local-runner): lazy init xvfb for headless mode detection

* docs(wdio-xvfb): update for package manager-centric approach & headless mode detection

* chore(wdio-xvfb): update xbps install command

* chore(wdio-xvfb): use types package for caps, remove legacy options support

* test(wdio-xvfb): remove tests for unsupported options

* chore(wdio-xvfb): add `@wdio/types`

* chore: update lockfile

* chore(wdio-xvfb): update xbps command

* chore(wdio-xvfb): update commands to refresh package repos

* test(wdio-xvfb): fix type error

* chore(e2e): update base dockerfiles for distro-specific xvfb removal

* chore(wdio-xvfb): update zypper command

* chore(e2e): update zypper xvfb clean

* refactor: move ProcessFactory to `wdio-xvfb`

* feat(wdio-xvfb): retries

* chore(wdio-types): add xvfb opts

* refactor(wdio-local-runner): pass xvfbmanager to processfactory

* refactor(e2e): update for `@wdio/xvfb` changes

* docs(wdio-xvfb): update README for retries

* chore(e2e): remove browserVersion

* Update packages/wdio-local-runner/src/index.ts

---------

Co-authored-by: Christian Bromann <git@bromann.dev>
This commit is contained in:
goosewobbler 2025-08-11 01:48:31 +01:00 committed by GitHub
parent 504857fa05
commit d30462eb7f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 3343 additions and 152 deletions

179
.github/workflows/test-xvfb-e2e.yml vendored Normal file
View file

@ -0,0 +1,179 @@
name: XVFB E2E Tests
on:
workflow_call:
# Make this a reusable workflow, no value needed
# https://docs.github.com/en/actions/using-workflows/reusing-workflows
jobs:
xvfb-e2e-tests:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
# Ubuntu scenarios
- distro: ubuntu
version: "24.04"
scenario: base
dockerfile: ubuntu-base.dockerfile
- distro: ubuntu
version: "24.04"
scenario: with-xvfb
dockerfile: ubuntu-with-xvfb.dockerfile
# Fedora scenarios
- distro: fedora
version: "40"
scenario: base
dockerfile: fedora-base.dockerfile
- distro: fedora
version: "40"
scenario: with-xvfb
dockerfile: fedora-with-xvfb.dockerfile
# Alpine scenarios
- distro: alpine
version: "3.20"
scenario: base
dockerfile: alpine-base.dockerfile
- distro: alpine
version: "3.20"
scenario: with-xvfb
dockerfile: alpine-with-xvfb.dockerfile
# Debian scenarios
- distro: debian
version: "12"
scenario: base
dockerfile: debian-base.dockerfile
- distro: debian
version: "12"
scenario: with-xvfb
dockerfile: debian-with-xvfb.dockerfile
# CentOS Stream scenarios (upstream RHEL)
- distro: centos-stream
version: "9"
scenario: base
dockerfile: centos-stream-base.dockerfile
- distro: centos-stream
version: "9"
scenario: with-xvfb
dockerfile: centos-stream-with-xvfb.dockerfile
# Arch Linux scenarios
- distro: arch
version: "latest"
scenario: base
dockerfile: arch-base.dockerfile
- distro: arch
version: "latest"
scenario: with-xvfb
dockerfile: arch-with-xvfb.dockerfile
# SUSE scenarios
- distro: suse
version: "15.6"
scenario: base
dockerfile: suse-base.dockerfile
- distro: suse
version: "15.6"
scenario: with-xvfb
dockerfile: suse-with-xvfb.dockerfile
# Void Linux scenarios
- distro: void
version: "latest"
scenario: base
dockerfile: void-base.dockerfile
- distro: void
version: "latest"
scenario: with-xvfb
dockerfile: void-with-xvfb.dockerfile
name: "XVFB E2E: ${{ matrix.distro }}:${{ matrix.version }} (${{ matrix.scenario }})"
steps:
- name: ⬇️ Checkout Code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: 🧑‍🔧 Get Core Dependencies
uses: ./.github/workflows/actions/get-core-dependencies
- name: Download Build Archive
uses: ./.github/workflows/actions/download-archive
with:
name: webdriverio
path: .
filename: webdriverio-build.zip
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build test container
run: |
docker build \
-f e2e/wdio/xvfb/docker/${{ matrix.dockerfile }} \
-t xvfb-test:${{ matrix.distro }}-${{ matrix.scenario }} \
e2e/wdio/xvfb/docker/
- name: Run XVFB E2E tests in container
run: |
# Copy built packages and test files to container
docker run --rm \
-v ${{ github.workspace }}:/workspace \
-w /workspace \
-e CI=true \
-e NODE_ENV=test \
-e WDIO_LOG_LEVEL=debug \
xvfb-test:${{ matrix.distro }}-${{ matrix.scenario }} \
bash -c "
set -e
set -o pipefail
# Fix permissions for testuser to access workspace
sudo chown -R testuser:testuser /workspace/
sudo chmod -R 755 /workspace/
# Install dependencies in container (pnpm already installed in Docker image)
pnpm install --frozen-lockfile
# Run the appropriate E2E test based on scenario
if [ '${{ matrix.scenario }}' = 'base' ]; then
cd e2e && pnpm test:xvfb:base
else
cd e2e && pnpm test:xvfb:existing
fi
"
- name: Display logs on failure
if: failure()
run: |
echo "=== Test failed, displaying diagnostic logs ==="
# Show WDIO-generated log files
if [ -f e2e/wdio/xvfb/logs/wdio.log ]; then
echo "=== WDIO Main Log ==="
cat e2e/wdio/xvfb/logs/wdio.log
fi
if [ -f e2e/wdio/xvfb/logs/wdio-0-0.log ]; then
echo "=== WDIO Worker Log ==="
cat e2e/wdio/xvfb/logs/wdio-0-0.log
fi
if [ -f e2e/wdio/xvfb/logs/wdio-0-0-chromedriver.log ]; then
echo "=== Chromedriver Log ==="
cat e2e/wdio/xvfb/logs/wdio-0-0-chromedriver.log
fi
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: failure()
with:
name: xvfb-e2e-logs-${{ matrix.distro }}-${{ matrix.version }}-${{ matrix.scenario }}
path: |
e2e/wdio/xvfb/logs/
e2e/wdio/xvfb/screenshots/

View file

@ -78,3 +78,8 @@ jobs:
name: E2E Tests
needs: [smoke_tests, component_tests, unit_tests, interop_tests, typing_tests]
uses: ./.github/workflows/test-standalone.yml
xvfb_tests:
name: XVFB E2E Tests
needs: [smoke_tests, component_tests, unit_tests, interop_tests, typing_tests]
uses: ./.github/workflows/test-xvfb-e2e.yml

View file

@ -33,6 +33,9 @@
"test:multiremote": "wdio ./wdio/wdio-multiremote.conf.ts",
"test:customReporter": "wdio ./custom-reporter/wdio.conf.js",
"test:customService": "wdio ./custom-service/wdio.conf.js",
"test:xvfb": "wdio ./wdio/xvfb/wdio.conf.ts",
"test:xvfb:base": "wdio ./wdio/xvfb/wdio.conf.ts --spec base-install.e2e.ts",
"test:xvfb:existing": "wdio ./wdio/xvfb/wdio.conf.ts --spec existing-xvfb.e2e.ts",
"verifyCoverage": "test -d ./browser-runner/coverage"
},
"devDependencies": {
@ -62,6 +65,7 @@
"@wdio/shared-store-service": "workspace:*",
"@wdio/spec-reporter": "workspace:*",
"@wdio/types": "workspace:*",
"@wdio/xvfb": "workspace:*",
"babel-plugin-transform-hook-names": "^1.0.2",
"cross-env": "^7.0.3",
"expect": "^29.7.0",

View file

@ -0,0 +1,98 @@
import { expect } from '@wdio/globals'
import { execSync } from 'node:child_process'
import { XvfbManager } from '@wdio/xvfb'
describe('xvfb fresh installation', () => {
let xvfbManager: XvfbManager
beforeEach(() => {
xvfbManager = new XvfbManager()
})
it('should detect missing xvfb and install it automatically', async function() {
// Set a longer timeout for this test since it involves package installation
this.timeout(300000) // 5 minutes
// 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')
})
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

@ -0,0 +1,50 @@
FROM alpine:3.20
# Set environment variables
ENV CI=true
# Add latest repositories
RUN echo @latest https://dl-cdn.alpinelinux.org/alpine/latest-stable/main >> /etc/apk/repositories && \
echo @latest https://dl-cdn.alpinelinux.org/alpine/latest-stable/community >> /etc/apk/repositories
# Update apk index
RUN apk update
# Install basic requirements but explicitly NOT xvfb
RUN apk add --no-cache \
curl \
ca-certificates \
sudo \
nodejs-current \
npm \
bash \
which \
unzip \
libc6-compat \
libstdc++
# Install pnpm globally as root
RUN npm install -g pnpm
# Install Chrome for testing
RUN apk add --no-cache chromium@latest
# Set Chrome binary path for Alpine (uses chromium)
ENV CHROME_BIN=/usr/bin/chromium
# Create test user with sudo access
RUN adduser -D -s /bin/bash testuser && \
echo 'testuser ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
# Ensure clean environment by removing any xvfb packages (do this at the very end)
RUN apk del xvfb-run || true && \
rm -f /usr/bin/xvfb-run /usr/local/bin/xvfb-run
# Verify xvfb-run is NOT available
RUN ! which xvfb-run || exit 1
WORKDIR /app
USER testuser
# Default command
CMD ["bash"]

View file

@ -0,0 +1,48 @@
FROM alpine:3.20
# Set environment variables
ENV CI=true
# Add latest repositories
RUN echo @latest https://dl-cdn.alpinelinux.org/alpine/latest-stable/main >> /etc/apk/repositories && \
echo @latest https://dl-cdn.alpinelinux.org/alpine/latest-stable/community >> /etc/apk/repositories
# Update apk index
RUN apk update
# Install requirements including xvfb
RUN apk add --no-cache \
curl \
ca-certificates \
sudo \
nodejs-current \
npm \
bash \
which \
xvfb \
xvfb-run \
unzip \
libc6-compat \
libstdc++
# Install pnpm globally as root
RUN npm install -g pnpm
# Install Chromium for testing
RUN apk add --no-cache chromium@latest
# Set Chrome binary path for Alpine (uses chromium)
ENV CHROME_BIN=/usr/bin/chromium
# Verify xvfb-run is available
RUN which xvfb-run
# Create test user with sudo access
RUN adduser -D -s /bin/bash testuser && \
echo 'testuser ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
WORKDIR /app
USER testuser
# Default command
CMD ["bash"]

View file

@ -0,0 +1,41 @@
FROM archlinux:latest
# Set environment variables
ENV CI=true
# Update system and install basic requirements but explicitly NOT xvfb
RUN pacman -Syu --noconfirm && \
pacman -S --noconfirm \
curl \
ca-certificates \
sudo \
nodejs \
npm \
which && \
pacman -Scc --noconfirm
# Install pnpm globally as root
RUN npm install -g pnpm
# Install Chrome for testing (Arch doesn't have google-chrome in main repos, use chromium)
RUN pacman -S --noconfirm chromium
# Set Chrome binary path for Arch (uses chromium)
ENV CHROME_BIN=/usr/bin/chromium
# Create test user with sudo access
RUN useradd -m -s /bin/bash testuser && \
echo 'testuser ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
# Ensure clean environment by removing any xvfb packages (do this at the very end)
RUN pacman -R --noconfirm xorg-server-xvfb || true && \
rm -f /usr/bin/xvfb-run /usr/local/bin/xvfb-run
# Verify xvfb-run is NOT available
RUN ! which xvfb-run || exit 1
WORKDIR /app
USER testuser
# Default command
CMD ["bash"]

View file

@ -0,0 +1,38 @@
FROM archlinux:latest
# Set environment variables
ENV CI=true
# Update system and install requirements including xvfb
RUN pacman -Syu --noconfirm && \
pacman -S --noconfirm \
curl \
ca-certificates \
sudo \
nodejs \
npm \
which \
xorg-server-xvfb && \
pacman -Scc --noconfirm
# Install pnpm globally as root
RUN npm install -g pnpm
# Install Chrome for testing (Arch doesn't have google-chrome in main repos, use chromium)
RUN pacman -S --noconfirm chromium
# Set Chrome binary path for Arch (uses chromium)
ENV CHROME_BIN=/usr/bin/chromium
# Verify xvfb-run is available
RUN which xvfb-run
# Create test user with sudo access
RUN useradd -m -s /bin/bash testuser && \
echo 'testuser ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
WORKDIR /app
USER testuser
# Default command
CMD ["bash"]

View file

@ -0,0 +1,48 @@
FROM quay.io/centos/centos:stream9
# Set environment variables
ENV CI=true
# Install basic requirements but explicitly NOT xvfb
RUN dnf update -y && \
dnf install -y \
ca-certificates \
sudo \
which && \
dnf clean all
# Install Node.js from NodeSource
RUN curl -fsSL https://rpm.nodesource.com/setup_22.x | bash - && \
dnf install -y nodejs
# Install pnpm globally as root
RUN npm install -g pnpm
# Install Chrome for testing
RUN echo '[google-chrome]' > /etc/yum.repos.d/google-chrome.repo && \
echo 'name=google-chrome' >> /etc/yum.repos.d/google-chrome.repo && \
echo 'baseurl=http://dl.google.com/linux/chrome/rpm/stable/x86_64' >> /etc/yum.repos.d/google-chrome.repo && \
echo 'enabled=1' >> /etc/yum.repos.d/google-chrome.repo && \
echo 'gpgcheck=1' >> /etc/yum.repos.d/google-chrome.repo && \
echo 'gpgkey=https://dl.google.com/linux/linux_signing_key.pub' >> /etc/yum.repos.d/google-chrome.repo && \
dnf install -y google-chrome-stable && \
dnf clean all
# Ensure clean environment by removing any xvfb packages
RUN dnf remove -y xorg-x11-server-Xvfb || true && \
dnf autoremove -y && \
rm -f /usr/bin/xvfb-run /usr/local/bin/xvfb-run && \
dnf clean all
# Verify xvfb-run is NOT available
RUN ! which xvfb-run || exit 1
# Create test user with sudo access
RUN useradd -m -s /bin/bash testuser && \
echo 'testuser ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
WORKDIR /app
USER testuser
# Default command
CMD ["bash"]

View file

@ -0,0 +1,43 @@
FROM quay.io/centos/centos:stream9
# Set environment variables
ENV CI=true
# Install requirements including xvfb
RUN dnf update -y && \
dnf install -y \
ca-certificates \
sudo \
which \
xorg-x11-server-Xvfb && \
dnf clean all
# Install Node.js from NodeSource
RUN curl -fsSL https://rpm.nodesource.com/setup_22.x | bash - && \
dnf install -y nodejs
# Install pnpm globally as root
RUN npm install -g pnpm
# Install Chrome for testing
RUN echo '[google-chrome]' > /etc/yum.repos.d/google-chrome.repo && \
echo 'name=google-chrome' >> /etc/yum.repos.d/google-chrome.repo && \
echo 'baseurl=http://dl.google.com/linux/chrome/rpm/stable/x86_64' >> /etc/yum.repos.d/google-chrome.repo && \
echo 'enabled=1' >> /etc/yum.repos.d/google-chrome.repo && \
echo 'gpgcheck=1' >> /etc/yum.repos.d/google-chrome.repo && \
echo 'gpgkey=https://dl.google.com/linux/linux_signing_key.pub' >> /etc/yum.repos.d/google-chrome.repo && \
dnf install -y google-chrome-stable && \
dnf clean all
# Verify xvfb-run is available
RUN which xvfb-run
# Create test user with sudo access
RUN useradd -m -s /bin/bash testuser && \
echo 'testuser ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
WORKDIR /app
USER testuser
# Default command
CMD ["bash"]

View file

@ -0,0 +1,51 @@
FROM debian:12
# Avoid interactive prompts during installation
ENV DEBIAN_FRONTEND=noninteractive
ENV CI=true
# Install basic requirements but explicitly NOT xvfb
RUN apt-get update -qq && \
apt-get install -y \
curl \
ca-certificates \
gnupg \
sudo && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Install Chrome for testing
RUN curl -fsSL https://dl.google.com/linux/linux_signing_key.pub | gpg --dearmor -o /usr/share/keyrings/google-chrome-keyring.gpg && \
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/google-chrome-keyring.gpg] http://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google-chrome.list && \
apt-get update -qq && \
apt-get install -y google-chrome-stable && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Install Node.js from NodeSource
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
apt-get install -y nodejs
# Install pnpm globally as root
RUN npm install -g pnpm
# Create test user with sudo access
RUN useradd -m -s /bin/bash testuser && \
echo 'testuser ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
# Ensure clean environment by removing any xvfb packages (do this at the very end)
RUN apt-get update -qq && \
apt-get remove -y xvfb || true && \
apt-get autoremove -y && \
rm -f /usr/bin/xvfb-run /usr/local/bin/xvfb-run && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Verify xvfb-run is NOT available
RUN ! which xvfb-run || exit 1
WORKDIR /app
USER testuser
# Default command
CMD ["bash"]

View file

@ -0,0 +1,44 @@
FROM debian:12
# Avoid interactive prompts during installation
ENV DEBIAN_FRONTEND=noninteractive
ENV CI=true
# Install requirements including xvfb
RUN apt-get update -qq && \
apt-get install -y \
curl \
ca-certificates \
gnupg \
sudo \
xvfb && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Install Chrome for testing
RUN curl -fsSL https://dl.google.com/linux/linux_signing_key.pub | gpg --dearmor -o /usr/share/keyrings/google-chrome-keyring.gpg && \
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/google-chrome-keyring.gpg] http://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google-chrome.list && \
apt-get update -qq && \
apt-get install -y google-chrome-stable && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Install Node.js from NodeSource
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
apt-get install -y nodejs
# Install pnpm globally as root
RUN npm install -g pnpm
# Verify xvfb-run is available
RUN which xvfb-run
# Create test user with sudo access
RUN useradd -m -s /bin/bash testuser && \
echo 'testuser ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
WORKDIR /app
USER testuser
# Default command
CMD ["bash"]

View file

@ -0,0 +1,47 @@
FROM fedora:40
# Set environment variables
ENV CI=true
# Install basic requirements but explicitly NOT xvfb
RUN dnf update -y && \
dnf install -y \
curl \
ca-certificates \
sudo \
nodejs \
npm \
which && \
dnf clean all
# Install pnpm globally as root
RUN npm install -g pnpm
# Install Chrome for testing
RUN echo '[google-chrome]' > /etc/yum.repos.d/google-chrome.repo && \
echo 'name=google-chrome' >> /etc/yum.repos.d/google-chrome.repo && \
echo 'baseurl=http://dl.google.com/linux/chrome/rpm/stable/x86_64' >> /etc/yum.repos.d/google-chrome.repo && \
echo 'enabled=1' >> /etc/yum.repos.d/google-chrome.repo && \
echo 'gpgcheck=1' >> /etc/yum.repos.d/google-chrome.repo && \
echo 'gpgkey=https://dl.google.com/linux/linux_signing_key.pub' >> /etc/yum.repos.d/google-chrome.repo && \
dnf install -y google-chrome-stable && \
dnf clean all
# Create test user with sudo access
RUN useradd -m -s /bin/bash testuser && \
echo 'testuser ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
# Ensure clean environment by removing any xvfb packages (do this at the very end)
RUN dnf remove -y xorg-x11-server-Xvfb || true && \
dnf autoremove -y && \
rm -f /usr/bin/xvfb-run /usr/local/bin/xvfb-run && \
dnf clean all
# Verify xvfb-run is NOT available
RUN ! which xvfb-run || exit 1
WORKDIR /app
USER testuser
# Default command
CMD ["bash"]

View file

@ -0,0 +1,42 @@
FROM fedora:40
# Set environment variables
ENV CI=true
# Install requirements including xvfb
RUN dnf update -y && \
dnf install -y \
curl \
ca-certificates \
sudo \
nodejs \
npm \
which \
xorg-x11-server-Xvfb && \
dnf clean all
# Install pnpm globally as root
RUN npm install -g pnpm
# Install Chrome for testing
RUN echo '[google-chrome]' > /etc/yum.repos.d/google-chrome.repo && \
echo 'name=google-chrome' >> /etc/yum.repos.d/google-chrome.repo && \
echo 'baseurl=http://dl.google.com/linux/chrome/rpm/stable/x86_64' >> /etc/yum.repos.d/google-chrome.repo && \
echo 'enabled=1' >> /etc/yum.repos.d/google-chrome.repo && \
echo 'gpgcheck=1' >> /etc/yum.repos.d/google-chrome.repo && \
echo 'gpgkey=https://dl.google.com/linux/linux_signing_key.pub' >> /etc/yum.repos.d/google-chrome.repo && \
dnf install -y google-chrome-stable && \
dnf clean all
# Verify xvfb-run is available
RUN which xvfb-run
# Create test user with sudo access
RUN useradd -m -s /bin/bash testuser && \
echo 'testuser ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
WORKDIR /app
USER testuser
# Default command
CMD ["bash"]

View file

@ -0,0 +1,43 @@
FROM opensuse/leap:15.6
# Set environment variables
ENV CI=true
# Install basic requirements but explicitly NOT xvfb
RUN zypper refresh && \
zypper install -y \
curl \
ca-certificates \
sudo \
nodejs \
npm && \
zypper clean -a
# Install pnpm globally as root
RUN npm install -g pnpm
# Install Chrome for testing
RUN zypper addrepo -f http://dl.google.com/linux/chrome/rpm/stable/x86_64 google-chrome && \
rpm --import https://dl.google.com/linux/linux_signing_key.pub && \
zypper refresh && \
zypper install -y google-chrome-stable && \
zypper clean -a
# Create test user with sudo access
RUN groupadd testuser && \
useradd -m -g testuser -s /bin/bash testuser && \
echo 'testuser ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
# Ensure clean environment by removing any xvfb packages (do this at the very end)
RUN zypper remove -y xvfb-run || true && \
rm -f /usr/bin/xvfb-run /usr/local/bin/xvfb-run && \
zypper clean -a
# Verify xvfb-run is NOT available
RUN ! which xvfb-run || exit 1
WORKDIR /app
USER testuser
# Default command
CMD ["bash"]

View file

@ -0,0 +1,40 @@
FROM opensuse/leap:15.6
# Set environment variables
ENV CI=true
# Install requirements including xvfb
RUN zypper refresh && \
zypper install -y \
curl \
ca-certificates \
sudo \
nodejs \
npm \
xorg-x11-server-Xvfb \
xvfb-run && \
zypper clean -a
# Install pnpm globally as root
RUN npm install -g pnpm
# Install Chrome for testing
RUN zypper addrepo -f http://dl.google.com/linux/chrome/rpm/stable/x86_64 google-chrome && \
rpm --import https://dl.google.com/linux/linux_signing_key.pub && \
zypper refresh && \
zypper install -y google-chrome-stable && \
zypper clean -a
# Verify xvfb-run is available
RUN which xvfb-run
# Create test user with sudo access
RUN groupadd testuser && \
useradd -m -g testuser -s /bin/bash testuser && \
echo 'testuser ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
WORKDIR /app
USER testuser
# Default command
CMD ["bash"]

View file

@ -0,0 +1,51 @@
FROM ubuntu:24.04
# Avoid interactive prompts during installation
ENV DEBIAN_FRONTEND=noninteractive
ENV CI=true
# Install basic requirements but explicitly NOT xvfb
RUN apt-get update -qq && \
apt-get install -y \
curl \
ca-certificates \
gnupg \
sudo && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Install Chrome for testing
RUN curl -fsSL https://dl.google.com/linux/linux_signing_key.pub | gpg --dearmor -o /usr/share/keyrings/google-chrome-keyring.gpg && \
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/google-chrome-keyring.gpg] http://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google-chrome.list && \
apt-get update -qq && \
apt-get install -y google-chrome-stable && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Install Node.js from NodeSource
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
apt-get install -y nodejs
# Install pnpm globally as root
RUN npm install -g pnpm
# Create test user with sudo access
RUN useradd -m -s /bin/bash testuser && \
echo 'testuser ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
# Ensure clean environment by removing any xvfb packages (do this at the very end)
RUN apt-get update -qq && \
apt-get remove -y xvfb || true && \
apt-get autoremove -y && \
rm -f /usr/bin/xvfb-run /usr/local/bin/xvfb-run && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Verify xvfb-run is NOT available
RUN ! which xvfb-run || exit 1
WORKDIR /app
USER testuser
# Default command
CMD ["bash"]

View file

@ -0,0 +1,44 @@
FROM ubuntu:24.04
# Avoid interactive prompts during installation
ENV DEBIAN_FRONTEND=noninteractive
ENV CI=true
# Install requirements including xvfb
RUN apt-get update -qq && \
apt-get install -y \
curl \
ca-certificates \
gnupg \
sudo \
xvfb && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Install Chrome for testing
RUN curl -fsSL https://dl.google.com/linux/linux_signing_key.pub | gpg --dearmor -o /usr/share/keyrings/google-chrome-keyring.gpg && \
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/google-chrome-keyring.gpg] http://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google-chrome.list && \
apt-get update -qq && \
apt-get install -y google-chrome-stable && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Install Node.js from NodeSource
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
apt-get install -y nodejs
# Install pnpm globally as root
RUN npm install -g pnpm
# Verify xvfb-run is available
RUN which xvfb-run
# Create test user with sudo access
RUN useradd -m -s /bin/bash testuser && \
echo 'testuser ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
WORKDIR /app
USER testuser
# Default command
CMD ["bash"]

View file

@ -0,0 +1,43 @@
FROM voidlinux/voidlinux:latest
# Set environment variables
ENV CI=true
# Fix repository configuration, update XBPS, and install packages in one step
RUN echo 'repository=https://repo-default.voidlinux.org/current' > /etc/xbps.d/00-repository-main.conf && \
xbps-install -Sy && \
xbps-install -yu xbps && \
xbps-install -Sy && \
xbps-install -y \
curl \
ca-certificates \
sudo \
nodejs \
bash \
which \
unzip \
glibc \
chromium
# Install pnpm globally as root
RUN npm install -g pnpm
# Set Chrome binary path for Void (uses chromium)
ENV CHROME_BIN=/usr/bin/chromium
# Create test user with sudo access
RUN useradd -m -s /bin/bash testuser && \
echo 'testuser ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
# Ensure clean environment by removing any xvfb packages (do this at the very end)
RUN xbps-remove -y xvfb-run || true && \
rm -f /usr/bin/xvfb-run /usr/local/bin/xvfb-run
# Verify xvfb-run is NOT available
RUN ! which xvfb-run || exit 1
WORKDIR /app
USER testuser
# Default command
CMD ["bash"]

View file

@ -0,0 +1,40 @@
FROM voidlinux/voidlinux:latest
# Set environment variables
ENV CI=true
# Fix repository configuration, update XBPS, and install packages in one step
RUN echo 'repository=https://repo-default.voidlinux.org/current' > /etc/xbps.d/00-repository-main.conf && \
xbps-install -Sy && \
xbps-install -yu xbps && \
xbps-install -Sy && \
xbps-install -y \
curl \
ca-certificates \
sudo \
nodejs \
bash \
which \
xvfb-run \
unzip \
glibc \
chromium
# Install pnpm globally as root
RUN npm install -g pnpm
# Set Chrome binary path for Void (uses chromium)
ENV CHROME_BIN=/usr/bin/chromium
# Verify xvfb-run is available
RUN which xvfb-run
# Create test user with sudo access
RUN useradd -m -s /bin/bash testuser && \
echo 'testuser ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
WORKDIR /app
USER testuser
# Default command
CMD ["bash"]

View file

@ -0,0 +1,111 @@
import { expect } from '@wdio/globals'
import { execSync } from 'node:child_process'
import { XvfbManager } from '@wdio/xvfb'
describe('xvfb existing installation', () => {
let xvfbManager: XvfbManager
beforeEach(() => {
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()
// Verify xvfb.shouldRun() returns true
expect(xvfbManager.shouldRun()).toBe(true)
// Verify xvfb-run is available (pre-installed in this container)
let xvfbRunPath = ''
try {
xvfbRunPath = execSync('which xvfb-run', { encoding: 'utf8' }).trim()
} catch (error) {
throw new Error(`xvfb-run should be pre-installed: ${error}`)
}
expect(xvfbRunPath).toContain('xvfb-run')
expect(xvfbRunPath.length).toBeGreaterThan(0)
// Initialize xvfb - should detect existing installation
const initResult = await xvfbManager.init()
expect(initResult).toBe(true)
// Verify xvfb-run still works after init
try {
const testOutput = execSync('xvfb-run --help', { encoding: 'utf8' })
expect(testOutput).toContain('xvfb-run')
} catch (error) {
throw new Error(`xvfb-run execution failed: ${error}`)
}
})
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')
// 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
try {
// Execute a basic command that should work under xvfb
const result = execSync('xvfb-run --auto-servernum -- echo "test successful"', {
encoding: 'utf8',
timeout: 10000
})
expect(result.trim()).toBe('test successful')
} catch (error) {
throw new Error(`xvfb-run command execution failed: ${error}`)
}
})
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

@ -0,0 +1,60 @@
import url from 'node:url'
import path from 'node:path'
const __dirname = path.dirname(url.fileURLToPath(import.meta.url))
export const config: WebdriverIO.Config = {
/**
* specify test files
*/
specs: [
path.join(__dirname, '*.e2e.ts')
],
/**
* capabilities - run headless since we're testing xvfb functionality
*/
capabilities: [{
browserName: 'chrome',
'goog:chromeOptions': {
args: [
'--headless=new',
'--no-sandbox',
'--disable-dev-shm-usage'
],
// Handle Alpine Linux with chromium-browser
...(process.env.CHROME_BIN && { binary: process.env.CHROME_BIN })
}
}],
/**
* test configurations
*/
logLevel: 'info',
framework: 'mocha',
outputDir: path.join(__dirname, 'logs'),
/**
* Use local runner to test xvfb integration
*/
runner: 'local',
/**
* Disable automatic xvfb initialization so tests can control it manually
*/
autoXvfb: false,
/**
* Reporters
*/
reporters: ['spec'],
/**
* Mocha options
*/
mochaOpts: {
ui: 'bdd',
timeout: 300000, // 5 minutes to allow for package installation
require: []
},
}

View file

@ -21,7 +21,7 @@
"compile": "pnpm -r --filter=@wdio/compiler run build",
"compile:all": "run-s compile:all:*",
"compile:all:core": "pnpm -r --filter=@wdio/compiler run build -p @wdio/logger -p @wdio/types -p @wdio/globals -p @wdio/protocols -p @wdio/repl",
"compile:all:main": "pnpm -r --filter=@wdio/compiler run build -p @wdio/utils -p @wdio/reporter -p @wdio/config -p webdriver -p webdriverio",
"compile:all:main": "pnpm -r --filter=@wdio/compiler run build -p @wdio/utils -p @wdio/reporter -p @wdio/config -p @wdio/xvfb -p webdriver -p webdriverio",
"compile:all:all": "pnpm -r --filter=@wdio/compiler run build",
"generate": "run-s generate:*",
"generate:bidi": "tsx ./scripts/bidi/index.ts && pnpm eslint --fix packages/webdriver/src/bidi/",
@ -32,6 +32,7 @@
"docs:generate": "run-s clean:website generate:bidi && tsx ./scripts/docs-generation/generateDocs.ts",
"docs:build": "cd ./website && pnpm build && cd ..",
"docs:deploy": "tsx ./scripts/updateDocs.ts",
"eslint:fix": "eslint --fix packages examples scripts tests",
"test": "run-s test:eslint test:ejslint test:typings test:unit:run test:smoke test:component",
"test:depcheck": "tsx ./scripts/depcheck.ts",
"test:eslint": "eslint --cache packages examples scripts tests",
@ -56,6 +57,7 @@
"test:e2e:classic": "cd ./e2e && npx wdio run ./wdio/wdio.classic.conf.ts",
"test:e2e:cloud": "cd ./e2e && npx wdio run ./wdio/wdio.sauce.conf.ts",
"test:e2e:launch": "vitest --config ./e2e/vitest.config.ts --run launch",
"test:e2e:xvfb": "cd ./e2e && npx wdio run ./wdio/xvfb/wdio.conf.ts",
"test:e2e": "run-s test:e2e:* ",
"dev": "pnpm -r --filter=@wdio/compiler run build --watch",
"watch:docs": "pnpm docs:generate && cd website && pnpm i && pnpm start",

View file

@ -41,6 +41,7 @@
"@wdio/repl": "workspace:*",
"@wdio/runner": "workspace:*",
"@wdio/types": "workspace:*",
"@wdio/xvfb": "workspace:*",
"exit-hook": "^4.0.0",
"expect-webdriverio": "^5.3.4",
"split2": "^4.1.0",

View file

@ -1,5 +1,6 @@
import logger from '@wdio/logger'
import { WritableStreamBuffer } from 'stream-buffers'
import { XvfbManager } from '@wdio/xvfb'
import type { Workers } from '@wdio/types'
import WorkerInstance from './worker.js'
@ -16,25 +17,41 @@ export interface RunArgs extends Workers.WorkerRunPayload {
export default class LocalRunner {
workerPool: Record<string, WorkerInstance> = {}
private xvfbInitialized = false
private xvfbManager: XvfbManager
stdout = new WritableStreamBuffer(BUFFER_OPTIONS)
stderr = new WritableStreamBuffer(BUFFER_OPTIONS)
constructor (
constructor(
private _options: never,
protected _config: WebdriverIO.Config
) {}
protected config: WebdriverIO.Config
) {
// Initialize XvfbManager
this.xvfbManager = new XvfbManager({
xvfbMaxRetries: this.config.xvfbMaxRetries,
xvfbRetryDelay: this.config.xvfbRetryDelay
})
}
/**
* nothing to initialize when running locally
* initialize local runner environment
*/
initialize () {}
async initialize() {
// XVFB initialization is handled lazily during first worker creation, to access capabilities for headless detection
}
getWorkerCount () {
getWorkerCount() {
return Object.keys(this.workerPool).length
}
async run ({ command, args, ...workerOptions }: RunArgs) {
async run({ command, args, ...workerOptions }: RunArgs) {
// Initialize XVFB lazily on first worker creation
if (!this.xvfbInitialized) {
await this.initializeXvfb(workerOptions)
this.xvfbInitialized = true
}
/**
* adjust max listeners on stdout/stderr when creating listeners
*/
@ -44,23 +61,61 @@ export default class LocalRunner {
process.stderr.setMaxListeners(workerCnt + 2)
}
const worker = new WorkerInstance(this._config, workerOptions, this.stdout, this.stderr)
const worker = new WorkerInstance(
this.config,
workerOptions,
this.stdout,
this.stderr,
this.xvfbManager
)
this.workerPool[workerOptions.cid] = worker
worker.postMessage(command, args)
await worker.postMessage(command, args)
return worker
}
/**
* Initialize XVFB with capability-aware detection
*/
private async initializeXvfb(workerOptions: Workers.WorkerRunPayload) {
// Skip Xvfb initialization if disabled
if (this.config.autoXvfb === false) {
log.info('Skipping automatic Xvfb initialization (disabled by config)')
return
}
// Initialize Xvfb if needed for headless testing
try {
const capabilities = workerOptions.caps
const xvfbInitialized = await this.xvfbManager.init(capabilities)
if (xvfbInitialized) {
log.info('Xvfb is ready for use')
}
} catch (error) {
log.warn(
'Failed to initialize Xvfb, continuing without virtual display:',
error
)
}
}
/**
* shutdown all worker processes
*
* @return {Promise} resolves when all worker have been shutdown or
* a timeout was reached
*/
shutdown () {
async shutdown() {
log.info('Shutting down spawned worker')
for (const [cid, worker] of Object.entries(this.workerPool)) {
const { capabilities, server, sessionId, config, isMultiremote, instances } = worker
const {
capabilities,
server,
sessionId,
config,
isMultiremote,
instances,
} = worker
let payload: Partial<Workers.WorkerMessageArgs> = {}
/**
@ -73,21 +128,22 @@ export default class LocalRunner {
capabilities,
watch: true,
isMultiremote,
instances
instances,
} as unknown as Workers.WorkerMessageArgs
} else if (!worker.isBusy) {
delete this.workerPool[cid]
continue
}
worker.postMessage('endSession', payload)
await worker.postMessage('endSession', payload)
}
return new Promise<boolean>((resolve) => {
const shutdownResult = await new Promise<boolean>((resolve) => {
const timeout = setTimeout(resolve, SHUTDOWN_TIMEOUT)
const interval = setInterval(() => {
const busyWorker = Object.entries(this.workerPool)
.filter(([, worker]) => worker.isBusy).length
const busyWorker = Object.entries(this.workerPool).filter(
([, worker]) => worker.isBusy
).length
log.info(`Waiting for ${busyWorker} to shut down gracefully`)
if (busyWorker === 0) {
@ -98,5 +154,12 @@ export default class LocalRunner {
}
}, 250)
})
// Xvfb cleanup is handled automatically by xvfb-run
if (this.xvfbManager.shouldRun()) {
log.info('Xvfb cleanup handled automatically by xvfb-run')
}
return shutdownResult
}
}

View file

@ -1,9 +1,9 @@
import url from 'node:url'
import path from 'node:path'
import child from 'node:child_process'
import { EventEmitter } from 'node:events'
import type { ChildProcess } from 'node:child_process'
import type { WritableStreamBuffer } from 'stream-buffers'
import { ProcessFactory, type XvfbManager } from '@wdio/xvfb'
import type { Workers } from '@wdio/types'
import type { ReplConfig } from '@wdio/repl'
@ -45,6 +45,7 @@ export default class WorkerInstance extends EventEmitter implements Workers.Work
sessionId?: string
server?: Record<string, string>
logsAggregator: string[] = []
#processFactory: ProcessFactory
instances?: Record<string, { sessionId: string }>
isMultiremote?: boolean
@ -65,12 +66,14 @@ export default class WorkerInstance extends EventEmitter implements Workers.Work
* @param {string[]} specs list of paths to test files to run in this worker
* @param {number} retries number of retries remaining
* @param {object} execArgv execution arguments for the test run
* @param {XvfbManager} xvfbManager configured XvfbManager instance
*/
constructor(
config: WebdriverIO.Config,
{ cid, configFile, caps, specs, execArgv, retries }: Workers.WorkerRunPayload,
stdout: WritableStreamBuffer,
stderr: WritableStreamBuffer
stderr: WritableStreamBuffer,
xvfbManager: XvfbManager
) {
super()
this.cid = cid
@ -83,6 +86,7 @@ export default class WorkerInstance extends EventEmitter implements Workers.Work
this.retries = retries
this.stdout = stdout
this.stderr = stderr
this.#processFactory = new ProcessFactory(xvfbManager)
this.isReady = new Promise((resolve) => { this.isReadyResolver = resolve })
this.isSetup = new Promise((resolve) => { this.isSetupResolver = resolve })
@ -91,7 +95,7 @@ export default class WorkerInstance extends EventEmitter implements Workers.Work
/**
* spawns process to kick of wdio-runner
*/
startProcess() {
async startProcess() {
const { cid, execArgv } = this
const argv = process.argv.slice(2)
@ -112,12 +116,18 @@ export default class WorkerInstance extends EventEmitter implements Workers.Work
runnerEnv.NODE_OPTIONS = process.env.NODE_OPTIONS + ' ' + (runnerEnv.NODE_OPTIONS || '')
log.info(`Start worker ${cid} with arg: ${argv.join(' ')}`)
const childProcess = this.childProcess = child.fork(path.join(__dirname, 'run.js'), argv, {
cwd: process.cwd(),
env: runnerEnv,
execArgv,
stdio: ['inherit', 'pipe', 'pipe', 'ipc']
})
// Use ProcessFactory to create the appropriate process
const childProcess = this.childProcess = await this.#processFactory.createWorkerProcess(
path.join(__dirname, 'run.js'),
argv,
{
cwd: process.cwd(),
env: runnerEnv,
execArgv,
stdio: ['inherit', 'pipe', 'pipe', 'ipc']
}
)
childProcess.on('message', this._handleMessage.bind(this))
childProcess.on('error', this._handleError.bind(this))
@ -226,7 +236,7 @@ export default class WorkerInstance extends EventEmitter implements Workers.Work
* @param command method to run in wdio-runner
* @param args arguments for functions to call
*/
postMessage (command: string, args: Workers.WorkerMessageArgs, requiresSetup = false): void {
async postMessage (command: string, args: Workers.WorkerMessageArgs, requiresSetup = false): Promise<void> {
const { cid, configFile, capabilities, specs, retries, isBusy } = this
if (isBusy && !ACCEPTABLE_BUSY_COMMANDS.includes(command)) {
@ -238,7 +248,7 @@ export default class WorkerInstance extends EventEmitter implements Workers.Work
* closes after running its job
*/
if (!this.childProcess) {
this.childProcess = this.startProcess()
this.childProcess = await this.startProcess()
}
const cmd: Workers.WorkerCommand = { cid, command, configFile, args, caps: capabilities, specs, retries }

View file

@ -1,30 +1,46 @@
import path from 'node:path'
import child from 'node:child_process'
import { expect, test, vi } from 'vitest'
import { expect, test, vi, beforeEach } from 'vitest'
import LocalRunner from '../src/index.js'
const sleep = (ms = 100) => new Promise((resolve) => setTimeout(resolve, ms))
vi.mock('@wdio/logger', () => import(path.join(process.cwd(), '__mocks__', '@wdio/logger')))
beforeEach(async () => {
vi.clearAllMocks()
})
vi.mock('child_process', () => {
vi.mock(
'@wdio/logger',
() => import(path.join(process.cwd(), '__mocks__', '@wdio/logger'))
)
vi.mock('@wdio/xvfb', () => {
const childProcessMock = {
on: vi.fn(),
send: vi.fn(),
kill: vi.fn()
kill: vi.fn(),
}
return {
default: { fork: vi.fn().mockReturnValue(childProcessMock) }
ProcessFactory: vi.fn().mockImplementation(() => ({
createWorkerProcess: vi.fn().mockResolvedValue(childProcessMock)
})),
XvfbManager: vi.fn().mockImplementation(() => ({
init: vi.fn().mockResolvedValue(true),
shouldRun: vi.fn().mockReturnValue(true)
})),
default: vi.fn()
}
})
test('should fork a new process', async () => {
const runner = new LocalRunner(undefined as never, {
outputDir: '/foo/bar',
runnerEnv: { FORCE_COLOR: 1 }
} as any)
const runner = new LocalRunner(
undefined as never,
{
outputDir: '/foo/bar',
runnerEnv: { FORCE_COLOR: 1 },
} as any
)
const worker = await runner.run({
cid: '0-5',
command: 'run',
@ -33,17 +49,12 @@ test('should fork a new process', async () => {
caps: {},
specs: ['/foo/bar.test.js'],
execArgv: [],
retries: 0
retries: 0,
})
worker['_handleMessage']({ name: 'ready' } as any)
await sleep()
expect(worker.isBusy).toBe(true)
expect(vi.mocked(child.fork).mock.calls[0][0].endsWith('run.js')).toBe(true)
const { env } = vi.mocked(child.fork).mock.calls[0][2]! as any
expect(env.WDIO_LOG_PATH).toMatch(/(\\|\/)foo(\\|\/)bar(\\|\/)wdio-0-5\.log/)
expect(env.FORCE_COLOR).toBe(1)
expect(worker.childProcess?.on).toHaveBeenCalled()
expect(worker.childProcess?.send).toHaveBeenCalledWith({
@ -53,17 +64,20 @@ test('should fork a new process', async () => {
command: 'run',
configFile: '/path/to/wdio.conf.js',
retries: 0,
specs: ['/foo/bar.test.js']
specs: ['/foo/bar.test.js'],
})
worker.postMessage('runAgain', { foo: 'bar' })
await worker.postMessage('runAgain', { foo: 'bar' } as any)
})
test('should shut down worker processes', async () => {
const runner = new LocalRunner(undefined as never, {
outputDir: '/foo/bar',
runnerEnv: { FORCE_COLOR: 1 }
} as any)
const runner = new LocalRunner(
undefined as never,
{
outputDir: '/foo/bar',
runnerEnv: { FORCE_COLOR: 1 },
} as any
)
const worker1 = await runner.run({
cid: '0-4',
command: 'run',
@ -72,7 +86,7 @@ test('should shut down worker processes', async () => {
caps: {},
specs: ['/foo/bar2.test.js'],
execArgv: [],
retries: 0
retries: 0,
})
worker1['_handleMessage']({ name: 'ready' } as any)
await sleep()
@ -84,7 +98,7 @@ test('should shut down worker processes', async () => {
caps: {},
specs: ['/foo/bar.test.js'],
execArgv: [],
retries: 0
retries: 0,
})
worker2['_handleMessage']({ name: 'ready' } as any)
await sleep()
@ -100,29 +114,36 @@ test('should shut down worker processes', async () => {
const after = Date.now()
expect(after - before).toBeGreaterThanOrEqual(750)
const call1: any = vi.mocked(worker1.childProcess?.send)!.mock.calls.pop()![0]
const call1: any = vi
.mocked(worker1.childProcess?.send)!
.mock.calls.pop()![0]
expect(call1.cid).toBe('0-5')
expect(call1.command).toBe('endSession')
const call2: any = vi.mocked(worker1.childProcess?.send)!.mock.calls.pop()![0]
const call2: any = vi
.mocked(worker1.childProcess?.send)!
.mock.calls.pop()![0]
expect(call2.cid).toBe('0-4')
expect(call2.command).toBe('endSession')
})
test('should avoid shutting down if worker is not busy', async () => {
const runner = new LocalRunner(undefined as never, {
outputDir: '/foo/bar',
runnerEnv: { FORCE_COLOR: 1 }
} as any)
const runner = new LocalRunner(
undefined as never,
{
outputDir: '/foo/bar',
runnerEnv: { FORCE_COLOR: 1 },
} as any
)
await runner.run({
cid: '0-8',
command: 'run',
configFile: '/path/to/wdio.conf.js',
args: { sessionId: 'abc' },
args: { sessionId: 'abc' } as any,
caps: {},
specs: ['/foo/bar2.test.js'],
execArgv: [],
retries: 0
retries: 0,
})
runner.workerPool['0-8'].isBusy = false
@ -132,21 +153,24 @@ 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, {
outputDir: '/foo/bar',
runnerEnv: { FORCE_COLOR: 1 },
watch: true,
} as any)
const runner = new LocalRunner(
undefined as never,
{
outputDir: '/foo/bar',
runnerEnv: { FORCE_COLOR: 1 },
watch: true,
} as any
)
const worker = await runner.run({
cid: '0-6',
command: 'run',
configFile: '/path/to/wdio.conf.js',
args: { sessionId: 'abc' },
args: { sessionId: 'abc' } as any,
caps: {},
specs: ['/foo/bar2.test.js'],
execArgv: [],
retries: 0
retries: 0,
})
worker['_handleMessage']({ name: 'ready' } as any)
runner.workerPool['0-6'].sessionId = 'abc'
@ -163,7 +187,9 @@ test('should shut down worker processes in watch mode - regular', async () => {
expect(after - before).toBeGreaterThanOrEqual(300)
const call: any = vi.mocked(worker.childProcess?.send)!.mock.calls.pop()![0]
const call: any = vi
.mocked(worker.childProcess?.send)!
.mock.calls.pop()![0]
expect(call.cid).toBe('0-6')
expect(call.command).toBe('endSession')
expect(call.args.watch).toBe(true)
@ -173,11 +199,14 @@ 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, {
outputDir: '/foo/bar',
runnerEnv: { FORCE_COLOR: 1 },
watch: true,
} as any)
const runner = new LocalRunner(
undefined as never,
{
outputDir: '/foo/bar',
runnerEnv: { FORCE_COLOR: 1 },
watch: true,
} as any
)
const worker = await runner.run({
cid: '0-7',
@ -187,15 +216,15 @@ test('should shut down worker processes in watch mode - mutliremote', async () =
caps: {},
specs: ['/foo/bar.test.js'],
execArgv: [],
retries: 0
retries: 0,
})
worker['_handleMessage']({ name: 'ready' } as any)
runner.workerPool['0-7'].isMultiremote = true
runner.workerPool['0-7'].instances = { foo: { sessionId: '123' } }
runner.workerPool['0-7'].caps = {
foo: {
capabilities: { browser: 'chrome' }
}
capabilities: { browser: 'chrome' },
},
} as any
setTimeout(() => {
@ -208,7 +237,9 @@ test('should shut down worker processes in watch mode - mutliremote', async () =
expect(after - before).toBeGreaterThanOrEqual(300)
const call: any = vi.mocked(worker.childProcess?.send)!.mock.calls.pop()![0]
const call: any = vi
.mocked(worker.childProcess?.send)!
.mock.calls.pop()![0]
expect(call.cid).toBe('0-7')
expect(call.command).toBe('endSession')
expect(call.args.watch).toBe(true)
@ -218,5 +249,172 @@ 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)
expect(runner.initialize()).toBe(undefined)
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)
// Mock the xvfbManager instance that was created in constructor
const mockInit = vi.fn().mockResolvedValue(true)
runner['xvfbManager'] = { init: mockInit } as any
// Initialize should not call xvfb.init
await runner.initialize()
expect(mockInit).not.toHaveBeenCalled()
// First run should initialize xvfb
await runner.run({
cid: '0-1',
command: 'run',
configFile: '/path/to/wdio.conf.js',
args: {},
caps: { 'goog:chromeOptions': { args: ['--headless'] } },
specs: ['/foo/bar.test.js'],
execArgv: [],
retries: 0,
})
expect(mockInit).toHaveBeenCalledWith({ 'goog:chromeOptions': { args: ['--headless'] } })
})
test('should not initialize xvfb during run when not needed', async () => {
const runner = new LocalRunner(undefined as never, {} as any)
// Mock the xvfbManager instance that was created in constructor
const mockInit = vi.fn().mockResolvedValue(false)
runner['xvfbManager'] = { init: mockInit } as any
await runner.initialize()
await runner.run({
cid: '0-2',
command: 'run',
configFile: '/path/to/wdio.conf.js',
args: {},
caps: {},
specs: ['/foo/bar.test.js'],
execArgv: [],
retries: 0,
})
expect(mockInit).toHaveBeenCalled()
// Verify that xvfb didn't actually initialize (returned false)
const initResult = await mockInit.mock.results[0].value
expect(initResult).toBe(false)
})
test('should handle xvfb initialization failure gracefully', async () => {
const runner = new LocalRunner(undefined as never, {} as any)
// Mock the xvfbManager instance that was created in constructor
const mockInit = vi.fn().mockResolvedValue(true)
runner['xvfbManager'] = { init: mockInit } as any
// Should not throw during run, just log the error
await expect(runner.run({
cid: '0-3',
command: 'run',
configFile: '/path/to/wdio.conf.js',
args: {},
caps: {},
specs: ['/foo/bar.test.js'],
execArgv: [],
retries: 0,
})).resolves.toBeDefined()
expect(mockInit).toHaveBeenCalled()
})
test('should only initialize xvfb once across multiple runs', async () => {
const runner = new LocalRunner(undefined as never, {} as any)
// Mock the xvfbManager instance that was created in constructor
const mockInit = vi.fn().mockResolvedValue(true)
runner['xvfbManager'] = { init: mockInit } as any
// First run should initialize xvfb
await runner.run({
cid: '0-4',
command: 'run',
configFile: '/path/to/wdio.conf.js',
args: {},
caps: {},
specs: ['/foo/bar1.test.js'],
execArgv: [],
retries: 0,
})
// Second run should not initialize xvfb again
await runner.run({
cid: '0-5',
command: 'run',
configFile: '/path/to/wdio.conf.js',
args: {},
caps: {},
specs: ['/foo/bar2.test.js'],
execArgv: [],
retries: 0,
})
expect(mockInit).toHaveBeenCalledTimes(1)
})
test('should handle xvfb operations with existing workers', async () => {
const runner = new LocalRunner(
undefined as never,
{
outputDir: '/foo/bar',
runnerEnv: { FORCE_COLOR: 1 },
} as any
)
// Mock the xvfbManager instance that was created in constructor
const mockInit = vi.fn().mockResolvedValue(true)
const mockShouldRun = vi.fn().mockReturnValue(true)
runner['xvfbManager'] = { init: mockInit, shouldRun: mockShouldRun } as any
// Start a worker (should initialize xvfb)
const worker = await runner.run({
cid: '0-9',
command: 'run',
configFile: '/path/to/wdio.conf.js',
args: {},
caps: {},
specs: ['/foo/bar.test.js'],
execArgv: [],
retries: 0,
})
worker['_handleMessage']({ name: 'ready' } as any)
setTimeout(() => {
worker.isBusy = false
}, 100)
await runner.shutdown()
expect(mockInit).toHaveBeenCalled()
})
test('should skip xvfb initialization when disabled in config', async () => {
const runner = new LocalRunner(
undefined as never,
{ autoXvfb: false } as any
)
// Mock the xvfbManager instance that was created in constructor
const mockInit = vi.fn().mockResolvedValue(true)
runner['xvfbManager'] = { init: mockInit } as any
await runner.run({
cid: '0-10',
command: 'run',
configFile: '/path/to/wdio.conf.js',
args: {},
caps: {},
specs: ['/foo/bar.test.js'],
execArgv: [],
retries: 0,
})
expect(mockInit).not.toHaveBeenCalled()
})

View file

@ -105,20 +105,24 @@ test('should call gracefulExit(130) and set sigintWasCalled on SIGINT', () => {
test('should delay shutdown in exitHook if SIGINT was received', async () => {
runner.sigintWasCalled = true
const startTime = Date.now()
const startTime = process.hrtime.bigint()
await exitHookCallback?.()
const endTime = Date.now()
const endTime = process.hrtime.bigint()
// Should wait at least SHUTDOWN_TIMEOUT (10ms in test due to mock)
expect(endTime - startTime).toBeGreaterThanOrEqual(10)
// Use nanosecond precision and allow for 9ms minimum to account for timing variance
const elapsedMs = Number(endTime - startTime) / 1_000_000
expect(elapsedMs).toBeGreaterThanOrEqual(9)
})
test('should not delay in exitHook if SIGINT was not received', async () => {
runner.sigintWasCalled = false
const start = Date.now()
const startTime = process.hrtime.bigint()
await exitHookCallback?.()
const end = Date.now()
const endTime = process.hrtime.bigint()
// Should not wait (almost immediate) when sigintWasCalled = false
expect(end - start).toBeLessThan(10)
// Allow up to 5ms for normal async overhead
const elapsedMs = Number(endTime - startTime) / 1_000_000
expect(elapsedMs).toBeLessThan(5)
})
afterAll(() => {

View file

@ -19,9 +19,14 @@ const workerConfig = {
vi.mock('@wdio/logger', () => import(path.join(process.cwd(), '__mocks__', '@wdio/logger')))
// Mock XvfbManager
const mockXvfbManager = {
init: vi.fn().mockResolvedValue(true)
}
describe('handleMessage', () => {
it('should emit payload with cid', () => {
const worker = new Worker({} as any, workerConfig, new WritableStreamBuffer(), new WritableStreamBuffer())
const worker = new Worker({} as any, workerConfig, new WritableStreamBuffer(), new WritableStreamBuffer(), mockXvfbManager as any)
worker.emit = vi.fn()
worker['_handleMessage']({ foo: 'bar' } as unknown as Workers.WorkerMessage)
@ -32,20 +37,20 @@ describe('handleMessage', () => {
})
it('should un mark worker as busy if command is finished', () => {
const worker = new Worker({} as any, workerConfig, new WritableStreamBuffer(), new WritableStreamBuffer())
const worker = new Worker({} as any, workerConfig, new WritableStreamBuffer(), new WritableStreamBuffer(), mockXvfbManager as any)
worker.isBusy = true
worker['_handleMessage']({ name: 'finishedCommand' } as unknown as Workers.WorkerMessage)
expect(worker.isBusy).toBe(false)
})
it('should mark worker as ready if ready message was received', async () => {
const worker = new Worker({} as any, workerConfig, new WritableStreamBuffer(), new WritableStreamBuffer())
const worker = new Worker({} as any, workerConfig, new WritableStreamBuffer(), new WritableStreamBuffer(), mockXvfbManager as any)
worker['_handleMessage']({ name: 'ready' } as unknown as Workers.WorkerMessage)
expect(await worker.isReady).toBe(true)
})
it('stores sessionId and connection data to worker instance', () => {
const worker = new Worker({} as any, workerConfig, new WritableStreamBuffer(), new WritableStreamBuffer())
const worker = new Worker({} as any, workerConfig, new WritableStreamBuffer(), new WritableStreamBuffer(), mockXvfbManager as any)
worker.emit = vi.fn()
const payload = {
name: 'sessionStarted',
@ -59,7 +64,7 @@ describe('handleMessage', () => {
})
it('stores instances to worker instance in Multiremote mode', () => {
const worker = new Worker({} as any, workerConfig, new WritableStreamBuffer(), new WritableStreamBuffer())
const worker = new Worker({} as any, workerConfig, new WritableStreamBuffer(), new WritableStreamBuffer(), mockXvfbManager as any)
const payload = {
name: 'sessionStarted',
content: {
@ -75,7 +80,7 @@ describe('handleMessage', () => {
describe('handleError', () => {
it('should emit error', () => {
const worker = new Worker({} as any, workerConfig, new WritableStreamBuffer(), new WritableStreamBuffer())
const worker = new Worker({} as any, workerConfig, new WritableStreamBuffer(), new WritableStreamBuffer(), mockXvfbManager as any)
worker.emit = vi.fn()
worker['_handleError']({ foo: 'bar' } as unknown as Error)
expect(worker.emit).toBeCalledWith('error', {
@ -87,7 +92,7 @@ describe('handleError', () => {
describe('handleExit', () => {
it('should handle it', () => {
const worker = new Worker({} as any, workerConfig, new WritableStreamBuffer(), new WritableStreamBuffer())
const worker = new Worker({} as any, workerConfig, new WritableStreamBuffer(), new WritableStreamBuffer(), mockXvfbManager as any)
const childProcess = { kill: vi.fn() }
worker.childProcess = childProcess as unknown as ChildProcess
worker.isBusy = true
@ -106,25 +111,25 @@ describe('handleExit', () => {
})
describe('postMessage', () => {
it('should log if the cid is busy and exit', () => {
const worker = new Worker({} as any, workerConfig, new WritableStreamBuffer(), new WritableStreamBuffer())
it('should log if the cid is busy and exit', async () => {
const worker = new Worker({} as any, workerConfig, new WritableStreamBuffer(), new WritableStreamBuffer(), mockXvfbManager as any)
const log = logger('webdriver')
vi.spyOn(log, 'info').mockImplementation((string) => string)
worker.isBusy = true
worker.postMessage('test-message', {})
await worker.postMessage('test-message', {})
expect(log.info)
.toHaveBeenCalledWith('worker with cid 0-3 already busy and can\'t take new commands')
})
it('should create a process if it does not have one', () => {
const worker = new Worker({} as any, workerConfig, new WritableStreamBuffer(), new WritableStreamBuffer())
it('should create a process if it does not have one', async () => {
const worker = new Worker({} as any, workerConfig, new WritableStreamBuffer(), new WritableStreamBuffer(), mockXvfbManager as any)
worker.isReady = Promise.resolve(true)
worker.childProcess = undefined
vi.spyOn(worker, 'startProcess').mockImplementation(
() => ({ send: vi.fn() }) as unknown as ChildProcess)
worker.postMessage('test-message', {})
async () => ({ send: vi.fn() }) as unknown as ChildProcess)
await worker.postMessage('test-message', {})
expect(worker.startProcess).toHaveBeenCalled()
expect(worker.isBusy).toBeTruthy()
@ -133,9 +138,9 @@ describe('postMessage', () => {
})
it('should wait sending the command until worker is ready', async () => {
const worker = new Worker({} as any, workerConfig, new WritableStreamBuffer(), new WritableStreamBuffer())
const worker = new Worker({} as any, workerConfig, new WritableStreamBuffer(), new WritableStreamBuffer(), mockXvfbManager as any)
worker.childProcess = { send: vi.fn() } as any
worker.postMessage('test-message', {})
await worker.postMessage('test-message', {})
expect(worker.childProcess!.send).toBeCalledTimes(0)
worker.isReadyResolver(true)
await worker.isReady

View file

@ -327,6 +327,23 @@ export interface Testrunner extends Hooks, WebdriverIO, WebdriverIO.HookFunction
* Shard tests and execute only the selected shard. Specify in the one-based form like `{ total: 5, current: 2 }`.
*/
shard?: ShardOptions
/**
* Enable automatic Xvfb initialization in local runner for headless testing on Linux.
* When disabled, tests should manually call xvfb.init() if needed.
* @default true
*/
autoXvfb?: boolean
/**
* Number of retry attempts for xvfb process failures.
* @default 3
*/
xvfbMaxRetries?: number
/**
* Base delay between retries in milliseconds for xvfb process failures.
* Progressive delay will be: xvfbRetryDelay * attemptNumber
* @default 1000
*/
xvfbRetryDelay?: number
// framework options
/**
* Mocha specific options

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 OpenJS Foundation
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,315 @@
# @wdio/xvfb
A standalone utility to manage Xvfb (X Virtual Framebuffer) for headless testing on Linux systems.
## Installation
```bash
npm install @wdio/xvfb
```
## Usage
### Automatic Integration
**Most users don't need to use this package directly.** It's automatically integrated into:
- **`@wdio/local-runner`** - Automatically ensures xvfb is available for headless browser testing
- **`wdio-electron-service`** - Provides headless Electron testing capabilities
Simply run your WebDriverIO tests normally, and xvfb will be managed automatically when needed.
### Manual Usage (Advanced)
For custom integrations or non-WDIO environments:
```js
import { xvfb } from "@wdio/xvfb";
// Initialize xvfb-run (only works on Linux in headless environments)
const ready = await xvfb.init();
if (ready) {
console.log("xvfb-run is ready for use");
// Use xvfb-run to execute commands that need a display
// e.g., xvfb-run npm test
}
```
## API
### XvfbManager Class
#### Constructor Options
```ts
interface XvfbOptions {
force?: boolean; // Force Xvfb even on non-Linux systems (for testing)
xvfbMaxRetries?: number; // Number of retry attempts for xvfb failures (default: 3)
xvfbRetryDelay?: number; // Base delay between retries in milliseconds (default: 1000)
}
```
#### Methods
- **`shouldRun(capabilities?): boolean`** - Check if Xvfb should run on this system
- **`init(capabilities?): Promise<boolean>`** - Initialize xvfb-run, returns true if ready, false if not needed
- **`executeWithRetry<T>(commandFn, context?): Promise<T>`** - Execute a function with automatic retry on xvfb-related errors
### Advanced Usage Examples
#### Force Mode (Testing Only)
For testing on non-Linux systems:
```js
import { XvfbManager } from "@wdio/xvfb";
const manager = new XvfbManager({
force: true,
xvfbMaxRetries: 3,
xvfbRetryDelay: 1000
});
const ready = await manager.init();
```
#### Custom Retry Configuration
```js
import { XvfbManager } from "@wdio/xvfb";
// High-reliability setup for CI environments
const manager = new XvfbManager({
xvfbMaxRetries: 10, // More retries for flaky CI
xvfbRetryDelay: 500 // Faster retries
});
// Execute operation with automatic retry
const result = await manager.executeWithRetry(async () => {
// Your operation that might fail due to xvfb issues
return await runHeadlessTests();
}, 'Running headless tests');
```
## When does it run?
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)
## Features
- **Conditional execution**: Only runs on Linux systems in headless environments
- **Smart headless detection**: Automatically detects browser headless flags (`--headless`, `--headless=new`, etc.) and forces XVFB usage for consistent behavior
- **Automatic retry mechanism**: Built-in retry logic with progressive delays for handling xvfb startup failures
- **Configurable retry behavior**: Customizable retry count and delay settings via WDIO configuration
- **Universal package manager support**: Automatically detects and uses the system's package manager (`apt`, `dnf`, `yum`, `zypper`, `pacman`, `apk`, `xbps`)
- **Cross-distro compatibility**: Works across hundreds of Linux distributions by supporting their underlying package managers
- **Command execution**: Execute commands under Xvfb using `xvfb-run`
- **Graceful cleanup**: Automatic cleanup when commands complete
- **State tracking**: Prevents duplicate starts and handles multiple calls safely
- **Flexible logging**: Configurable logger support
## Retry Mechanism
The package includes automatic retry functionality for handling xvfb-related failures:
### Automatic Retry Detection
The retry mechanism automatically detects xvfb-specific errors:
- `xvfb-run: error: Xvfb failed to start`
- `Xvfb failed to start`
- `xvfb-run: error:`
- `X server died`
### Progressive Delay Strategy
Retry delays increase progressively to avoid overwhelming the system:
- Attempt 1: Immediate execution
- Attempt 2: Wait `xvfbRetryDelay × 1` ms (default: 1000ms)
- Attempt 3: Wait `xvfbRetryDelay × 2` ms (default: 2000ms)
- Attempt N: Wait `xvfbRetryDelay × (N-1)` ms
### Usage Example
```js
import { XvfbManager } from '@wdio/xvfb';
const manager = new XvfbManager({
xvfbMaxRetries: 5,
xvfbRetryDelay: 1500
});
// Automatic retry on xvfb failures
const result = await manager.executeWithRetry(async () => {
// Your xvfb-dependent operation
return await someXvfbDependentTask();
}, 'Custom operation name');
```
## Implementation Details
This package uses `xvfb-run` to manage Xvfb sessions, which:
- Automatically selects available display numbers with `--auto-servernum`
- Configures screen resolution, DPI, and other display settings
- Handles process cleanup automatically when commands complete
- Supports custom arguments for advanced Xvfb configuration
## Supported Package Managers
The utility automatically detects the system's package manager and installs xvfb accordingly:
| Package Manager | Command | Distributions | Package Name |
|----------------|---------|---------------|--------------|
| **`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` |
| **`pacman`** | `pacman` | Arch Linux, Manjaro, EndeavourOS, CachyOS, etc. | `xorg-server-xvfb` |
| **`apk`** | `apk` | Alpine Linux, PostmarketOS | `xvfb-run` |
| **`xbps`** | `xbps-install` | Void Linux | `xvfb` |
### Future-Proof Design
This package manager-focused approach means **new Linux distributions automatically work** without code changes, as long as they use one of the supported package managers. For example:
- New Ubuntu derivatives automatically use `apt`
- New Arch derivatives automatically use `pacman`
- New Fedora derivatives automatically use `dnf`
The 7 supported package managers cover **95%+ of all Linux users** and hundreds of distributions, making this solution both comprehensive and maintainable.
## Examples
### Basic Setup for Custom Tools
```js
import { xvfb } from "@wdio/xvfb";
import { exec } from "child_process";
import { promisify } from "util";
const execAsync = promisify(exec);
async function runCustomTests() {
const ready = await xvfb.init();
const command = ready
? "xvfb-run your-custom-test-command"
: "your-custom-test-command";
await execAsync(command);
}
```
### WebDriverIO Configuration
For most users, no configuration is needed. WDIO automatically handles xvfb:
```js
// wdio.conf.js - Minimal configuration
export const config = {
// No xvfb configuration needed - handled automatically
capabilities: [{
browserName: 'chrome',
'goog:chromeOptions': {
args: ['--headless', '--no-sandbox'] // Automatically detected - will force XVFB
}
}],
services: [
'chromedriver',
// xvfb is automatically managed by local-runner
]
};
```
#### Advanced WDIO Configuration
You can customize xvfb behavior and retry settings:
```js
// wdio.conf.js - Custom xvfb configuration
export const config = {
// Xvfb configuration options (all optional)
autoXvfb: true, // Enable automatic xvfb (default: true)
xvfbMaxRetries: 5, // Max retry attempts for xvfb failures (default: 3)
xvfbRetryDelay: 2000, // Base delay between retries in ms (default: 1000)
capabilities: [{
browserName: 'chrome',
'goog:chromeOptions': {
args: ['--headless', '--no-sandbox']
}
}]
};
```
**Configuration Options:**
- **`autoXvfb`** *(boolean, default: true)*: Enable/disable automatic xvfb initialization
- **`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)
### Headless Flag Detection
The utility automatically detects browser headless flags and forces XVFB usage even when a DISPLAY is available:
```js
// These configurations will automatically trigger XVFB on Linux:
// Chrome/Chromium
capabilities: [{
'goog:chromeOptions': {
args: ['--headless'] // Detected
}
}]
// Firefox
capabilities: [{
'moz:firefoxOptions': {
args: ['--headless'] // Detected
}
}]
// Edge (Chromium-based)
capabilities: [{
'ms:edgeOptions': {
args: ['--headless=new'] // Detected
}
}]
// Legacy formats also supported
capabilities: [{
chromeOptions: { args: ['--headless'] }, // Legacy Chrome
edgeOptions: { args: ['--headless'] } // Legacy Edge
}]
// Multiremote scenarios are also supported
capabilities: {
browserA: { 'goog:chromeOptions': { args: ['--headless'] }},
browserB: { 'ms:edgeOptions': { args: ['--no-sandbox'] }}
// XVFB will be used because browserA has headless flag
}
```
This ensures consistent headless behavior regardless of host environment setup.
## Logging
The utility uses `@wdio/logger` with the namespace `@wdio/xvfb`. Enable debug logging:
```bash
DEBUG=@wdio/xvfb npm run test
```
The logger is automatically created with the namespace `@wdio/xvfb` and cannot be customized in the current interface.
## License
MIT

View file

@ -0,0 +1,52 @@
{
"name": "@wdio/xvfb",
"version": "9.18.4",
"description": "A standalone utility to manage Xvfb (X Virtual Framebuffer) for headless testing",
"author": "WebdriverIO Team",
"homepage": "https://github.com/webdriverio/webdriverio/tree/main/packages/wdio-xvfb",
"license": "MIT",
"engines": {
"node": ">=18"
},
"repository": {
"type": "git",
"url": "git://github.com/webdriverio/webdriverio.git",
"directory": "packages/wdio-xvfb"
},
"keywords": [
"xvfb",
"headless",
"display",
"linux",
"testing",
"x11",
"virtual-framebuffer",
"electron",
"browser-testing"
],
"bugs": {
"url": "https://github.com/webdriverio/webdriverio/issues"
},
"type": "module",
"types": "./build/index.d.ts",
"exports": {
".": {
"types": "./build/index.d.ts",
"import": "./build/index.js"
},
"./package.json": "./package.json"
},
"typeScriptVersion": "3.8.3",
"dependencies": {
"@wdio/logger": "workspace:*",
"is-ci": "^4.1.0"
},
"devDependencies": {
"@wdio/types": "workspace:*",
"@types/node": "^20.0.0",
"@types/is-ci": "^3.0.4"
},
"publishConfig": {
"access": "public"
}
}

View file

@ -0,0 +1,135 @@
import { spawn, fork, execSync } from 'node:child_process'
import type {
ChildProcess,
SpawnOptions,
ForkOptions,
} from 'node:child_process'
import logger from '@wdio/logger'
import { XvfbManager } from './XvfbManager.js'
export interface ProcessCreator {
createWorkerProcess(
scriptPath: string,
args: string[],
options: ProcessCreationOptions
): Promise<ChildProcess>;
}
export interface ProcessCreationOptions {
cwd?: string;
env?: Record<string, string>;
execArgv?: string[];
stdio?: ('inherit' | 'pipe' | 'ignore' | 'ipc')[];
}
export class ProcessFactory implements ProcessCreator {
#xvfbManager: XvfbManager
#log = logger('@wdio/xvfb:ProcessFactory')
constructor(xvfbManager?: XvfbManager) {
this.#xvfbManager = xvfbManager || new XvfbManager()
}
async createWorkerProcess(
scriptPath: string,
args: string[],
options: ProcessCreationOptions
): Promise<ChildProcess> {
const shouldRun = this.#xvfbManager.shouldRun()
const isAvailable = this.#isXvfbRunAvailable()
this.#log.info(`ProcessFactory: shouldRun=${shouldRun}, isAvailable=${isAvailable}`)
if (shouldRun && isAvailable) {
this.#log.info('Creating worker process with xvfb-run wrapper and retry logic')
return await this.#xvfbManager.executeWithRetry(
() => this.#createXvfbProcess(scriptPath, args, options),
'xvfb worker process creation'
)
}
this.#log.info('Creating worker process with regular fork')
return this.#createRegularProcess(scriptPath, args, options)
}
/**
* Create process with xvfb-run wrapper
*/
#createXvfbProcess(
scriptPath: string,
args: string[],
options: ProcessCreationOptions
): Promise<ChildProcess> {
return new Promise((resolve, reject) => {
const { cwd, env, execArgv = [], stdio } = options
const nodeArgs = [...execArgv, scriptPath, ...args]
const childProcess = spawn(
'xvfb-run',
['--auto-servernum', '--', 'node', ...nodeArgs],
{
cwd,
env,
stdio,
} as SpawnOptions
)
// Check for immediate startup failures
let resolved = false
const startupTimeout = setTimeout(() => {
if (!resolved) {
resolved = true
resolve(childProcess)
}
}, 100) // Short timeout to detect immediate failures
childProcess.on('error', (error) => {
if (!resolved) {
resolved = true
clearTimeout(startupTimeout)
reject(error)
}
})
childProcess.on('exit', (code, signal) => {
if (!resolved && code !== 0) {
resolved = true
clearTimeout(startupTimeout)
reject(new Error(`xvfb-run process exited with code ${code} and signal ${signal}`))
}
})
})
}
/**
* Create regular process without xvfb
*/
#createRegularProcess(
scriptPath: string,
args: string[],
options: ProcessCreationOptions
): ChildProcess {
const { cwd, env, execArgv = [], stdio } = options
return fork(scriptPath, args, {
cwd,
env,
execArgv,
stdio,
} as ForkOptions)
}
/**
* Check if xvfb-run is actually available on the system
*/
#isXvfbRunAvailable(): boolean {
try {
execSync('which xvfb-run', { stdio: 'ignore' })
this.#log.info('xvfb-run found in PATH')
return true
} catch {
this.#log.info('xvfb-run not found, falling back to regular fork')
return false
}
}
}

View file

@ -0,0 +1,370 @@
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 {
/**
* Force Xvfb to run even on non-Linux systems (for testing)
*/
force?: boolean;
/**
* Override package manager detection (for testing)
*/
packageManager?: string;
/**
* Skip xvfb-run availability check and force installation (for testing)
*/
forceInstall?: boolean;
/**
* Number of retry attempts for xvfb process failures (default: 3)
*/
xvfbMaxRetries?: number;
/**
* Base delay between retries in milliseconds (default: 1000)
* Progressive delay will be: baseDelay * attemptNumber
*/
xvfbRetryDelay?: number;
}
const execAsync = promisify(exec)
export class XvfbManager {
private force: boolean
private packageManagerOverride?: string
private forceInstall: boolean
private maxRetries: number
private retryDelay: number
private log: ReturnType<typeof logger>
constructor(options: XvfbOptions = {}) {
this.force = options.force ?? false
this.packageManagerOverride = options.packageManager
this.forceInstall = options.forceInstall ?? false
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) {
return true
}
// Only run on Linux systems
if (os.platform() !== 'linux') {
return false
}
// Check if we're in a headless environment (no DISPLAY set or in CI)
const hasDisplay = process.env.DISPLAY
const inHeadlessEnvironment = !hasDisplay || isCI
// Force XVFB if headless Chrome flags are detected
const hasHeadlessFlag = this.detectHeadlessMode(capabilities)
return inHeadlessEnvironment || hasHeadlessFlag
}
/**
* Initialize xvfb-run for use
* @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')
if (!this.shouldRun(capabilities)) {
this.log.info('Xvfb not needed on current platform')
return false
}
this.log.info('Xvfb should run, checking if setup is needed')
try {
await this.ensureXvfbRunAvailable()
this.log.info('xvfb-run is ready for use')
return true
} catch (error) {
this.log.error('Failed to setup xvfb-run:', error)
throw error
}
}
/**
* Ensure xvfb-run is available, installing if necessary
*/
private async ensureXvfbRunAvailable(): Promise<void> {
this.log.info('Checking if xvfb-run is available...')
if (!this.forceInstall) {
try {
// Check if xvfb-run is already available
await execAsync('which xvfb-run')
this.log.info('xvfb-run found in PATH')
return
} catch {
this.log.info('xvfb-run not found, installing xvfb packages...')
}
} else {
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')
// Verify xvfb-run is now available (skip if force install to allow error testing)
if (!this.forceInstall) {
this.log.info('Verifying xvfb-run installation...')
try {
const { stdout } = await execAsync('which xvfb-run')
this.log.info(
`Successfully installed xvfb-run at: ${stdout.trim()}`
)
} catch (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."
)
}
}
}
/**
* Detect if headless mode is enabled in Chrome/Chromium capabilities
*/
private detectHeadlessMode(capabilities?: Capabilities.ResolvedTestrunnerCapabilities): boolean {
if (!capabilities) {
return false
}
// Handle both single and multiremote capabilities
const caps = capabilities as WebdriverIO.Capabilities | Record<string, WebdriverIO.Capabilities | { capabilities: WebdriverIO.Capabilities }>
// Check if it's a single capability object (has browser-specific options)
if (this.isSingleCapability(caps)) {
return this.checkCapabilityForHeadless(caps)
}
// Handle multiremote scenario
if (this.isMultiRemoteCapability(caps)) {
for (const [, browserConfig] of Object.entries(caps)) {
const browserCaps = this.extractCapabilitiesFromBrowserConfig(browserConfig)
if (this.checkCapabilityForHeadless(browserCaps)) {
return true
}
}
}
return false
}
/**
* Check if the capabilities object is a single capability (not multiremote)
*/
private isSingleCapability(caps: WebdriverIO.Capabilities | Record<string, WebdriverIO.Capabilities | { capabilities: WebdriverIO.Capabilities }>): caps is WebdriverIO.Capabilities {
return Boolean(
caps['goog:chromeOptions'] ||
caps['ms:edgeOptions'] ||
caps['moz:firefoxOptions']
)
}
/**
* Check if the capabilities object is multiremote
*/
private isMultiRemoteCapability(caps: WebdriverIO.Capabilities | Record<string, WebdriverIO.Capabilities | { capabilities: WebdriverIO.Capabilities }>): caps is Record<string, WebdriverIO.Capabilities | { capabilities: WebdriverIO.Capabilities }> {
return !this.isSingleCapability(caps) && typeof caps === 'object' && caps !== null
}
/**
* Extract capabilities from browser config (handles both nested and direct formats)
*/
private extractCapabilitiesFromBrowserConfig(browserConfig: { capabilities: WebdriverIO.Capabilities } | WebdriverIO.Capabilities): WebdriverIO.Capabilities {
if (browserConfig && typeof browserConfig === 'object' && 'capabilities' in browserConfig && browserConfig.capabilities) {
return browserConfig.capabilities
}
return browserConfig as WebdriverIO.Capabilities
}
/**
* Check a single capability object for headless flags
*/
private checkCapabilityForHeadless(caps: WebdriverIO.Capabilities): boolean {
if (!caps || typeof caps !== 'object') {
return false
}
// Check Chrome options
if (this.hasHeadlessFlag(caps['goog:chromeOptions'], ['--headless'])) {
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')
return true
}
// Check Firefox options
if (this.hasHeadlessFlag(caps['moz:firefoxOptions'], ['--headless', '-headless'])) {
this.log.info('Detected headless Firefox flag, forcing XVFB usage')
return true
}
return false
}
/**
* Check if browser options contain headless flags
*/
private hasHeadlessFlag(options: { args?: string[] } | undefined, headlessFlags: string[]): boolean {
if (!options?.args || !Array.isArray(options.args)) {
return false
}
return options.args.some((arg: string) => {
if (typeof arg !== 'string') {
return false
}
return headlessFlags.some(flag =>
arg === flag || (flag === '--headless' && arg.startsWith('--headless='))
)
})
}
protected async detectPackageManager(): Promise<string> {
// Use override if provided (for testing)
if (this.packageManagerOverride) {
return this.packageManagerOverride
}
const packageManagers = [
{ command: 'apt-get', name: 'apt' },
{ command: 'dnf', name: 'dnf' },
{ command: 'yum', name: 'yum' },
{ command: 'zypper', name: 'zypper' },
{ command: 'pacman', name: 'pacman' },
{ command: 'apk', name: 'apk' },
{ command: 'xbps-install', name: 'xbps' },
]
for (const { command, name } of packageManagers) {
try {
await execAsync(`which ${command}`)
return name
} catch {
// Continue to next package manager
}
}
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}`)
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',
}
const command = installCommands[packageManager]
if (!command) {
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
}
}
/**
* Execute a command with retry logic for xvfb failures
*/
public async executeWithRetry<T>(
commandFn: () => Promise<T>,
context: string = 'xvfb operation'
): Promise<T> {
let lastError: Error | unknown = null
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
try {
if (attempt === 1) {
this.log.info(`🚀 Executing ${context}`)
} else {
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}`)
}
return result
} catch (error: unknown) {
this.log.info(`❌ Attempt ${attempt}/${this.maxRetries} failed: ${error}`)
lastError = error
// Check if this is an xvfb-related error that we should retry
const errorMessage = error instanceof Error ? error.message : String(error)
const isXvfbError = this.isXvfbError(errorMessage)
if (!isXvfbError) {
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...`)
await new Promise(resolve => setTimeout(resolve, delay))
} else {
this.log.info(`❌ All ${this.maxRetries} attempts failed`)
}
}
}
throw lastError
}
/**
* Check if an error is related to xvfb failures
*/
private isXvfbError(errorMessage: string): boolean {
const xvfbErrorPatterns = [
'xvfb-run: error: Xvfb failed to start',
'Xvfb failed to start',
'xvfb-run: error:',
'X server died'
]
return xvfbErrorPatterns.some(pattern =>
errorMessage.toLowerCase().includes(pattern.toLowerCase())
)
}
}

View file

@ -0,0 +1,8 @@
import { XvfbManager } from './XvfbManager.js'
// export classes
export { XvfbManager, type XvfbOptions } from './XvfbManager.js'
export { ProcessFactory, type ProcessCreator, type ProcessCreationOptions } from './ProcessFactory.js'
// Export a default instance for convenience
export const xvfb = new XvfbManager()

View file

@ -0,0 +1,236 @@
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'
import type { ChildProcess } from 'node:child_process'
// Use vi.hoisted to ensure mocks are set up before imports
const mockSpawn = vi.hoisted(() => vi.fn())
const mockFork = vi.hoisted(() => vi.fn())
const mockExecSync = vi.hoisted(() => vi.fn())
// Mock child_process module
vi.mock('node:child_process', () => ({
spawn: mockSpawn,
fork: mockFork,
execSync: mockExecSync
}))
// Mock logger
vi.mock('@wdio/logger', () => ({
default: vi.fn(() => ({
info: vi.fn(),
error: vi.fn(),
warn: vi.fn()
}))
}))
// Mock XvfbManager
const mockXvfbManager = {
shouldRun: vi.fn(),
init: vi.fn(),
executeWithRetry: vi.fn()
}
vi.mock('../src/XvfbManager.js', () => ({
XvfbManager: vi.fn(() => mockXvfbManager)
}))
// Import after mocks are set up
const { ProcessFactory } = await import('../src/ProcessFactory.js')
describe('ProcessFactory', () => {
let processFactory: InstanceType<typeof ProcessFactory>
const mockChildProcess = {
on: vi.fn(),
send: vi.fn(),
kill: vi.fn(),
pid: 12345,
stdout: null,
stderr: null,
stdin: null
} as unknown as ChildProcess
beforeEach(() => {
vi.clearAllMocks()
processFactory = new ProcessFactory()
// Reset mock implementations
mockSpawn.mockReturnValue(mockChildProcess)
mockFork.mockReturnValue(mockChildProcess)
mockExecSync.mockReturnValue('success')
mockXvfbManager.shouldRun.mockReturnValue(false)
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('constructor', () => {
it('should create instance with default XvfbManager', () => {
const factory = new ProcessFactory()
expect(factory).toBeInstanceOf(ProcessFactory)
})
it('should create instance with custom XvfbManager', () => {
const customManager = mockXvfbManager
const factory = new ProcessFactory(customManager as any)
expect(factory).toBeInstanceOf(ProcessFactory)
})
})
describe('createWorkerProcess', () => {
const scriptPath = '/path/to/script.js'
const args = ['--arg1', '--arg2']
const options = {
cwd: '/working/dir',
env: { NODE_ENV: 'test' },
execArgv: ['--inspect'],
stdio: ['inherit', 'pipe', 'pipe', 'ipc'] as const
}
describe('when xvfb should not run', () => {
beforeEach(() => {
mockXvfbManager.shouldRun.mockReturnValue(false)
})
it('should use regular fork when xvfb is not needed', async () => {
const result = await processFactory.createWorkerProcess(scriptPath, args, options)
expect(mockXvfbManager.shouldRun).toHaveBeenCalled()
expect(mockFork).toHaveBeenCalledWith(scriptPath, args, {
cwd: options.cwd,
env: options.env,
execArgv: options.execArgv,
stdio: options.stdio
})
expect(mockSpawn).not.toHaveBeenCalled()
expect(result).toBe(mockChildProcess)
})
it('should use fork with default execArgv when not provided', async () => {
const optionsWithoutExecArgv = { ...options }
delete optionsWithoutExecArgv.execArgv
await processFactory.createWorkerProcess(scriptPath, args, optionsWithoutExecArgv)
expect(mockFork).toHaveBeenCalledWith(scriptPath, args, {
cwd: options.cwd,
env: options.env,
execArgv: [],
stdio: options.stdio
})
})
})
describe('when xvfb should run', () => {
beforeEach(() => {
mockXvfbManager.shouldRun.mockReturnValue(true)
mockXvfbManager.executeWithRetry.mockImplementation(async (fn) => await fn())
})
it('should use executeWithRetry when xvfb is available', async () => {
mockExecSync.mockReturnValue('/usr/bin/xvfb-run')
const mockProcess = {
...mockChildProcess,
on: vi.fn()
} as unknown as ChildProcess
mockSpawn.mockReturnValue(mockProcess)
const result = await processFactory.createWorkerProcess(scriptPath, args, options)
expect(mockXvfbManager.shouldRun).toHaveBeenCalled()
expect(mockExecSync).toHaveBeenCalledWith('which xvfb-run', { stdio: 'ignore' })
expect(mockXvfbManager.executeWithRetry).toHaveBeenCalledWith(
expect.any(Function),
'xvfb worker process creation'
)
expect(mockFork).not.toHaveBeenCalled()
expect(result).toBe(mockProcess)
})
it('should fallback to fork when xvfb-run is not available', async () => {
mockExecSync.mockImplementation(() => {
throw new Error('Command not found')
})
const result = await processFactory.createWorkerProcess(scriptPath, args, options)
expect(mockXvfbManager.shouldRun).toHaveBeenCalled()
expect(mockExecSync).toHaveBeenCalledWith('which xvfb-run', { stdio: 'ignore' })
expect(mockXvfbManager.executeWithRetry).not.toHaveBeenCalled()
expect(mockFork).toHaveBeenCalledWith(scriptPath, args, {
cwd: options.cwd,
env: options.env,
execArgv: options.execArgv,
stdio: options.stdio
})
expect(mockSpawn).not.toHaveBeenCalled()
expect(result).toBe(mockChildProcess)
})
})
describe('edge cases', () => {
it('should handle minimal options', async () => {
mockXvfbManager.shouldRun.mockReturnValue(false)
const minimalOptions = {}
await processFactory.createWorkerProcess(scriptPath, args, minimalOptions)
expect(mockFork).toHaveBeenCalledWith(scriptPath, args, {
cwd: undefined,
env: undefined,
execArgv: [],
stdio: undefined
})
})
it('should handle empty args array', async () => {
mockXvfbManager.shouldRun.mockReturnValue(false)
await processFactory.createWorkerProcess(scriptPath, [], options)
expect(mockFork).toHaveBeenCalledWith(scriptPath, [], {
cwd: options.cwd,
env: options.env,
execArgv: options.execArgv,
stdio: options.stdio
})
})
})
})
describe('integration with XvfbManager', () => {
it('should call shouldRun on the XvfbManager instance', async () => {
const customManager = {
shouldRun: vi.fn().mockReturnValue(false),
executeWithRetry: vi.fn()
}
const factory = new ProcessFactory(customManager as any)
await factory.createWorkerProcess('/script.js', [], {})
expect(customManager.shouldRun).toHaveBeenCalled()
})
it('should respect XvfbManager decision about whether to run xvfb', async () => {
const customManager = {
shouldRun: vi.fn().mockReturnValue(true),
executeWithRetry: vi.fn().mockImplementation(async (fn) => await fn())
}
const factory = new ProcessFactory(customManager as any)
mockExecSync.mockReturnValue('/usr/bin/xvfb-run')
const mockProcess = {
...mockChildProcess,
on: vi.fn()
} as unknown as ChildProcess
mockSpawn.mockReturnValue(mockProcess)
await factory.createWorkerProcess('/script.js', [], {})
expect(customManager.shouldRun).toHaveBeenCalled()
expect(customManager.executeWithRetry).toHaveBeenCalled()
expect(mockFork).not.toHaveBeenCalled()
})
})
})

View file

@ -0,0 +1,518 @@
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'
// Use vi.hoisted to ensure mocks are set up before imports
const mockExecAsync = vi.hoisted(() => vi.fn())
const mockPlatform = vi.hoisted(() => vi.fn())
const mockIsCI = vi.hoisted(() => ({ value: false }))
// Mock all the modules before importing anything else
vi.mock('node:child_process', () => ({
exec: vi.fn()
}))
vi.mock('node:util', () => ({
promisify: vi.fn(() => mockExecAsync)
}))
vi.mock('node:os', () => ({
default: {
platform: mockPlatform
}
}))
vi.mock('is-ci', () => ({
get default() {
return mockIsCI.value
}
}))
vi.mock('@wdio/logger', () => ({
default: vi.fn(() => ({
info: vi.fn(),
error: vi.fn(),
warn: vi.fn()
}))
}))
// Import after mocks are set up
const { XvfbManager } = await import('../src/XvfbManager.js')
describe('XvfbManager', () => {
let manager: InstanceType<typeof XvfbManager>
beforeEach(() => {
vi.clearAllMocks()
manager = new XvfbManager()
// Reset environment
delete process.env.DISPLAY
mockIsCI.value = false
mockPlatform.mockReturnValue('linux')
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('constructor', () => {
it('should create instance with default options', () => {
const manager = new XvfbManager()
expect(manager).toBeInstanceOf(XvfbManager)
})
it('should create instance with custom options', () => {
const manager = new XvfbManager({
force: true,
packageManager: 'apt',
xvfbMaxRetries: 5,
xvfbRetryDelay: 2000
})
expect(manager).toBeInstanceOf(XvfbManager)
})
})
describe('shouldRun', () => {
it('should return true when forced', () => {
const manager = new XvfbManager({ force: true })
mockPlatform.mockReturnValue('darwin') // Non-Linux platform
expect(manager.shouldRun()).toBe(true)
})
it('should return false on non-Linux platforms', () => {
mockPlatform.mockReturnValue('darwin')
expect(manager.shouldRun()).toBe(false)
})
it('should return true on Linux without DISPLAY', () => {
mockPlatform.mockReturnValue('linux')
delete process.env.DISPLAY
expect(manager.shouldRun()).toBe(true)
})
it('should return true on Linux in CI environment', () => {
mockPlatform.mockReturnValue('linux')
process.env.DISPLAY = ':0'
mockIsCI.value = true
expect(manager.shouldRun()).toBe(true)
})
it('should return false on Linux with existing DISPLAY (not in CI)', () => {
mockPlatform.mockReturnValue('linux')
process.env.DISPLAY = ':0'
mockIsCI.value = false
expect(manager.shouldRun()).toBe(false)
})
it('should return true when Chrome headless flag is detected', () => {
mockPlatform.mockReturnValue('linux')
process.env.DISPLAY = ':0'
mockIsCI.value = false
const capabilities = {
'goog:chromeOptions': {
args: ['--headless']
}
}
expect(manager.shouldRun(capabilities)).toBe(true)
})
it('should return true when Chrome headless=new flag is detected', () => {
mockPlatform.mockReturnValue('linux')
process.env.DISPLAY = ':0'
mockIsCI.value = false
const capabilities = {
'goog:chromeOptions': {
args: ['--headless=new']
}
}
expect(manager.shouldRun(capabilities)).toBe(true)
})
it('should return true when Chrome headless=old flag is detected', () => {
mockPlatform.mockReturnValue('linux')
process.env.DISPLAY = ':0'
mockIsCI.value = false
const capabilities = {
'goog:chromeOptions': {
args: ['--headless=old']
}
}
expect(manager.shouldRun(capabilities)).toBe(true)
})
it('should return true when Firefox headless flag is detected', () => {
mockPlatform.mockReturnValue('linux')
process.env.DISPLAY = ':0'
mockIsCI.value = false
const capabilities = {
'moz:firefoxOptions': {
args: ['--headless']
}
}
expect(manager.shouldRun(capabilities)).toBe(true)
})
it('should return true when Firefox -headless flag is detected', () => {
mockPlatform.mockReturnValue('linux')
process.env.DISPLAY = ':0'
mockIsCI.value = false
const capabilities = {
'moz:firefoxOptions': {
args: ['-headless']
}
}
expect(manager.shouldRun(capabilities)).toBe(true)
})
it('should handle array of capabilities (multiremote)', () => {
mockPlatform.mockReturnValue('linux')
process.env.DISPLAY = ':0'
mockIsCI.value = false
const capabilities = {
browser1: {
capabilities: {
'goog:chromeOptions': {
args: ['--headless']
}
}
},
browser2: {
capabilities: {
'moz:firefoxOptions': {
args: ['--disable-gpu']
}
}
}
}
expect(manager.shouldRun(capabilities)).toBe(true)
})
it('should return false when no headless flags in capabilities', () => {
mockPlatform.mockReturnValue('linux')
process.env.DISPLAY = ':0'
mockIsCI.value = false
const capabilities = {
'goog:chromeOptions': {
args: ['--disable-gpu']
}
}
expect(manager.shouldRun(capabilities)).toBe(false)
})
it('should handle capabilities without args', () => {
mockPlatform.mockReturnValue('linux')
process.env.DISPLAY = ':0'
mockIsCI.value = false
const capabilities = {
'goog:chromeOptions': {}
}
expect(manager.shouldRun(capabilities)).toBe(false)
})
it('should handle empty capabilities', () => {
mockPlatform.mockReturnValue('linux')
process.env.DISPLAY = ':0'
mockIsCI.value = false
expect(manager.shouldRun({})).toBe(false)
})
it('should handle undefined capabilities', () => {
mockPlatform.mockReturnValue('linux')
process.env.DISPLAY = ':0'
mockIsCI.value = false
expect(manager.shouldRun(undefined)).toBe(false)
})
it('should return true when Edge headless flag is detected (ms:edgeOptions)', () => {
mockPlatform.mockReturnValue('linux')
process.env.DISPLAY = ':0'
mockIsCI.value = false
const capabilities = {
'ms:edgeOptions': {
args: ['--headless']
}
}
expect(manager.shouldRun(capabilities)).toBe(true)
})
})
describe('init', () => {
beforeEach(() => {
mockPlatform.mockReturnValue('linux')
})
it('should setup xvfb-run when needed', async () => {
mockExecAsync.mockResolvedValue({ stdout: '/usr/bin/xvfb-run\n', stderr: '' })
const result = await manager.init()
expect(result).toBe(true)
expect(mockExecAsync).toHaveBeenCalledWith('which xvfb-run')
})
it('should not setup when not needed', async () => {
mockPlatform.mockReturnValue('darwin')
const result = await manager.init()
expect(result).toBe(false)
})
it('should setup xvfb-run when headless capabilities are provided', async () => {
process.env.DISPLAY = ':0'
mockIsCI.value = false
mockExecAsync.mockResolvedValue({ stdout: '/usr/bin/xvfb-run\n', stderr: '' })
const capabilities = {
'goog:chromeOptions': {
args: ['--headless']
}
}
const result = await manager.init(capabilities)
expect(result).toBe(true)
})
it('should install xvfb when xvfb-run is not available', async () => {
// Mock xvfb-run not found first, then found after installation
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: '' })
const manager = new XvfbManager()
// Mock platform and environment
mockPlatform.mockReturnValue('linux')
delete process.env.DISPLAY
const result = await manager.init()
expect(result).toBe(true)
expect(mockExecAsync).toHaveBeenCalledWith('which xvfb-run')
expect(mockExecAsync).toHaveBeenCalledWith('which apt-get')
expect(mockExecAsync).toHaveBeenCalledWith(
'sudo apt-get update -qq && sudo apt-get install -y xvfb',
{ timeout: 240000 }
)
})
})
describe('cross-distribution support', () => {
beforeEach(() => {
mockPlatform.mockReturnValue('linux')
})
it('should detect Ubuntu distribution', async () => {
// Mock xvfb-run not found, then package manager detection
mockExecAsync
.mockRejectedValueOnce(new Error('Command not found'))
.mockResolvedValueOnce({ stdout: '/usr/bin/apt-get', stderr: '' })
.mockResolvedValueOnce({ stdout: 'installation success', stderr: '' })
.mockResolvedValueOnce({ stdout: '/usr/bin/xvfb-run\n', stderr: '' })
const manager = new XvfbManager()
delete process.env.DISPLAY
await manager.init()
expect(mockExecAsync).toHaveBeenCalledWith('which apt-get')
expect(mockExecAsync).toHaveBeenCalledWith(
'sudo apt-get update -qq && sudo apt-get install -y xvfb',
{ timeout: 240000 }
)
})
it('should detect dnf package manager', async () => {
// Mock xvfb-run not found, 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: '' })
const manager = new XvfbManager()
delete process.env.DISPLAY
await manager.init()
expect(mockExecAsync).toHaveBeenCalledWith('which dnf')
expect(mockExecAsync).toHaveBeenCalledWith(
'sudo dnf makecache && sudo dnf install -y xorg-x11-server-Xvfb',
{ timeout: 240000 }
)
})
it('should detect pacman package manager', async () => {
// Mock xvfb-run not found, then package manager detection
mockExecAsync
.mockRejectedValueOnce(new Error('Command not found'))
.mockRejectedValueOnce(new Error('apt-get not found'))
.mockRejectedValueOnce(new Error('dnf not found'))
.mockRejectedValueOnce(new Error('yum not found'))
.mockRejectedValueOnce(new Error('zypper not found'))
.mockResolvedValueOnce({ stdout: '/usr/bin/pacman', stderr: '' })
.mockResolvedValueOnce({ stdout: 'installation success', stderr: '' })
.mockResolvedValueOnce({ stdout: '/usr/bin/xvfb-run\n', stderr: '' })
const manager = new XvfbManager()
delete process.env.DISPLAY
await manager.init()
expect(mockExecAsync).toHaveBeenCalledWith('which pacman')
expect(mockExecAsync).toHaveBeenCalledWith(
'sudo 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
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: '' })
const manager = new XvfbManager()
delete process.env.DISPLAY
await manager.init()
expect(mockExecAsync).toHaveBeenCalledWith('which apt-get')
expect(mockExecAsync).toHaveBeenCalledWith('which dnf')
})
it('should handle unsupported package managers gracefully', async () => {
// Mock all package managers as not found
mockExecAsync
.mockRejectedValueOnce(new Error('Command not found'))
.mockRejectedValue(new Error('Package manager not found'))
const manager = new XvfbManager()
delete process.env.DISPLAY
await expect(manager.init()).rejects.toThrow(
'Unsupported package manager: unknown. Please install Xvfb manually.'
)
})
})
describe('executeWithRetry', () => {
beforeEach(() => {
mockPlatform.mockReturnValue('linux')
})
it('should succeed on first attempt', async () => {
const mockFn = vi.fn().mockResolvedValue('success')
const result = await manager.executeWithRetry(mockFn, 'test operation')
expect(result).toBe('success')
expect(mockFn).toHaveBeenCalledTimes(1)
})
it('should retry on xvfb-related errors', async () => {
const manager = new XvfbManager({ xvfbMaxRetries: 2, xvfbRetryDelay: 10 })
const mockFn = vi.fn()
.mockRejectedValueOnce(new Error('xvfb-run: error: Xvfb failed to start'))
.mockResolvedValueOnce('success')
const result = await manager.executeWithRetry(mockFn, 'test operation')
expect(result).toBe('success')
expect(mockFn).toHaveBeenCalledTimes(2)
})
it('should retry with progressive delay', async () => {
const manager = new XvfbManager({ xvfbMaxRetries: 3, xvfbRetryDelay: 100 })
const mockFn = vi.fn()
.mockRejectedValueOnce(new Error('Xvfb failed to start'))
.mockRejectedValueOnce(new Error('xvfb-run: error:'))
.mockResolvedValueOnce('success')
const startTime = Date.now()
const result = await manager.executeWithRetry(mockFn, 'test operation')
const endTime = Date.now()
expect(result).toBe('success')
expect(mockFn).toHaveBeenCalledTimes(3)
// Should have waited at least 100ms + 200ms = 300ms for two retries
expect(endTime - startTime).toBeGreaterThan(280)
})
it('should not retry on non-xvfb errors', async () => {
const mockFn = vi.fn().mockRejectedValue(new Error('Regular error'))
await expect(manager.executeWithRetry(mockFn, 'test operation')).rejects.toThrow('Regular error')
expect(mockFn).toHaveBeenCalledTimes(1)
})
it('should exhaust retries and throw last error', async () => {
const manager = new XvfbManager({ xvfbMaxRetries: 2, xvfbRetryDelay: 10 })
const mockFn = vi.fn()
.mockRejectedValueOnce(new Error('xvfb-run: error: Xvfb failed to start'))
.mockRejectedValueOnce(new Error('X server died'))
await expect(manager.executeWithRetry(mockFn, 'test operation')).rejects.toThrow('X server died')
expect(mockFn).toHaveBeenCalledTimes(2)
})
it('should detect various xvfb error patterns', async () => {
const manager = new XvfbManager({ xvfbMaxRetries: 1, xvfbRetryDelay: 10 })
const errorPatterns = [
'xvfb-run: error: Xvfb failed to start',
'Xvfb failed to start',
'xvfb-run: error: something else',
'X server died'
]
for (const errorMessage of errorPatterns) {
const mockFn = vi.fn().mockRejectedValue(new Error(errorMessage))
await expect(manager.executeWithRetry(mockFn, 'test')).rejects.toThrow(errorMessage)
expect(mockFn).toHaveBeenCalledTimes(1)
}
})
it('should handle case insensitive error matching', async () => {
const manager = new XvfbManager({ xvfbMaxRetries: 1, xvfbRetryDelay: 10 })
const mockFn = vi.fn().mockRejectedValue(new Error('XVFB-RUN: ERROR: XVFB FAILED TO START'))
await expect(manager.executeWithRetry(mockFn, 'test')).rejects.toThrow('XVFB-RUN: ERROR: XVFB FAILED TO START')
expect(mockFn).toHaveBeenCalledTimes(1)
})
})
})

View file

@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./build",
"rootDir": "./src"
},
"include": [
"./src/**/*"
]
}

View file

@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.prod.json",
"compilerOptions": {
"outDir": "./build",
"rootDir": "./src"
},
"include": [
"./src/**/*"
]
}

View file

@ -291,6 +291,9 @@ importers:
'@wdio/types':
specifier: workspace:*
version: link:../packages/wdio-types
'@wdio/xvfb':
specifier: workspace:*
version: link:../packages/wdio-xvfb
babel-plugin-transform-hook-names:
specifier: ^1.0.2
version: 1.0.2(@babel/core@7.27.7)
@ -443,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.16.2)(webdriverio@9.17.0(puppeteer-core@24.12.1))
version: 5.3.4(@wdio/globals@9.17.0)(@wdio/logger@9.18.0)(webdriverio@9.18.4(puppeteer-core@24.12.1))
mocha:
specifier: ^10.2.0
version: 10.8.2
@ -988,7 +991,7 @@ importers:
dependencies:
expect-webdriverio:
specifier: ^5.3.4
version: 5.3.4(@wdio/globals@9.17.0)(@wdio/logger@9.16.2)(webdriverio@9.16.2(puppeteer-core@24.12.1))
version: 5.3.4(@wdio/globals@9.17.0)(@wdio/logger@9.18.0)(webdriverio@9.16.2(puppeteer-core@24.12.1))
webdriverio:
specifier: ^9.0.0
version: 9.16.2(puppeteer-core@24.12.1)
@ -1133,12 +1136,15 @@ importers:
'@wdio/types':
specifier: workspace:*
version: link:../wdio-types
'@wdio/xvfb':
specifier: workspace:*
version: link:../wdio-xvfb
exit-hook:
specifier: ^4.0.0
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.17.0(puppeteer-core@24.12.1))
version: 5.3.4(@wdio/globals@9.17.0)(@wdio/logger@packages+wdio-logger)(webdriverio@9.18.4(puppeteer-core@24.12.1))
split2:
specifier: ^4.1.0
version: 4.2.0
@ -1489,6 +1495,25 @@ importers:
specifier: workspace:*
version: link:../wdio-globals
packages/wdio-xvfb:
dependencies:
'@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
'@wdio/types':
specifier: workspace:*
version: link:../wdio-types
packages/webdriver:
dependencies:
'@types/node':
@ -1895,7 +1920,7 @@ importers:
version: link:../../../packages/wdio-utils
expect-webdriverio:
specifier: ^5.3.4
version: 5.3.4(@wdio/globals@packages+wdio-globals)(@wdio/logger@9.16.2)(webdriverio@packages+webdriverio)
version: 5.3.4(@wdio/globals@packages+wdio-globals)(@wdio/logger@9.18.0)(webdriverio@packages+webdriverio)
webdriver:
specifier: workspace:*
version: link:../../../packages/webdriver
@ -5652,6 +5677,9 @@ 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==}
@ -6075,8 +6103,8 @@ packages:
resolution: {integrity: sha512-a7zDSNzpGgkb6mWrg9GWPmvh/sZFzaf86/iBjCv+n2DTY0+8v8NLruRQmWuCaQAlLVhM3XAqmB+fWLqxDhdvOA==}
engines: {node: '>=18.20.0'}
'@wdio/config@9.17.0':
resolution: {integrity: sha512-AmP9J1GiCUj8g9aNeCuJRKAo+dET8rukVzrGQVq8jdqkj4PLjLA2hbfcQ8zQi/xwVUebnuIUT5weaomgKyb7Ng==}
'@wdio/config@9.18.0':
resolution: {integrity: sha512-fN+Z7SkKjb0u3UUMSxMN4d+CCZQKZhm/tx3eX7Rv+3T78LtpOjlesBYQ7Ax3tQ3tp8hgEo+CoOXU0jHEYubFrg==}
engines: {node: '>=18.20.0'}
'@wdio/dot-reporter@9.16.2':
@ -6104,6 +6132,10 @@ packages:
resolution: {integrity: sha512-6A1eVpNPToWupLIo8CXStth4HJGTfxKsAiKtwE0xQFKyDM8uPTm3YO3Nf15vCSHbmsncbYVEo7QrUwRUEB4YUg==}
engines: {node: '>=18.20.0'}
'@wdio/logger@9.18.0':
resolution: {integrity: sha512-HdzDrRs+ywAqbXGKqe1i/bLtCv47plz4TvsHFH3j729OooT5VH38ctFn5aLXgECmiAKDkmH/A6kOq2Zh5DIxww==}
engines: {node: '>=18.20.0'}
'@wdio/protocols@9.16.2':
resolution: {integrity: sha512-h3k97/lzmyw5MowqceAuY3HX/wGJojXHkiPXA3WlhGPCaa2h4+GovV2nJtRvknCKsE7UHA1xB5SWeI8MzloBew==}
@ -6130,8 +6162,8 @@ packages:
resolution: {integrity: sha512-bsRdEUXUTYvznXH/Z+p6HDzHSjMI6I6bnu8WXWTeDDDyqybWK5D8cbZvs8A/kMmGXoz1GZkSBHxy4Z5NTg8OQg==}
engines: {node: '>=18.20.0'}
'@wdio/utils@9.17.0':
resolution: {integrity: sha512-z9k8+DbhyJPLs88w/SLcUuXMdoPq2r/lE2qQ79pqWAG0jmm7kJqkBvZyndaTrSrGbh+NgjTEgh1epD1PHYtnwg==}
'@wdio/utils@9.18.0':
resolution: {integrity: sha512-M+QH05FUw25aFXZfjb+V16ydKoURgV61zeZrMjQdW2aAiks3F5iiI9pgqYT5kr1kHZcMy8gawGqQQ+RVfKYscQ==}
engines: {node: '>=18.20.0'}
'@webassemblyjs/ast@1.14.1':
@ -6893,10 +6925,6 @@ packages:
resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==}
engines: {node: '>=8'}
ci-info@4.2.0:
resolution: {integrity: sha512-cYY9mypksY8NRqgDB1XD1RiJL338v/551niynFTGkZOO2LHuB2OmOYxDIe/ttN9AHwrqdum1360G3ald0W9kCg==}
engines: {node: '>=8'}
ci-info@4.3.0:
resolution: {integrity: sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ==}
engines: {node: '>=8'}
@ -9309,6 +9337,10 @@ 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'}
@ -13739,8 +13771,8 @@ packages:
resolution: {integrity: sha512-T7QKqD+N0hfvrxq/am5wqdOuyOy7F2tGS7X2f/7jyhrxSPG6Q0cNkSt4gCwla+q3nDMivCP0QIPc7mAVSx5AYQ==}
engines: {node: '>=18.20.0'}
webdriver@9.17.0:
resolution: {integrity: sha512-vDdW5SeAe1Fn2N79cdrRIHnbTcXlpIM1f0Ki8AHGsC0DkvYRyshnuI6kgySXN8y4+Thy/xo+vDdr0CLJF2Zqgw==}
webdriver@9.18.0:
resolution: {integrity: sha512-07lC4FLj45lHJo0FvLjUp5qkjzEGWJWKGsxLoe9rQ2Fg88iYsqgr9JfSj8qxHpazBaBd+77+ZtpmMZ2X2D1Zuw==}
engines: {node: '>=18.20.0'}
webdriverio@9.16.2:
@ -13752,8 +13784,8 @@ packages:
puppeteer-core:
optional: true
webdriverio@9.17.0:
resolution: {integrity: sha512-LXLDhEUQC3AiYAPWZmpkZXBLaLzXlEGQskpG0CLx6S5hXAWNHs5wItgozrH9CfG7N6j26mc2avWN2ruy/vwrWQ==}
webdriverio@9.18.4:
resolution: {integrity: sha512-Q/gghz/Zt7EhTnbDQfLb61WgSwCksXZE60lEzmDXe4fULCH/6Js5IWUsne3W+BRy6nXeVvFscHD/d7S77dbamw==}
engines: {node: '>=18.20.0'}
peerDependencies:
puppeteer-core: '>=22.x || <=24.x'
@ -15879,7 +15911,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)
@ -15919,7 +15951,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)
@ -19279,6 +19311,10 @@ 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': {}
@ -19853,11 +19889,11 @@ snapshots:
- bare-buffer
- supports-color
'@wdio/config@9.17.0':
'@wdio/config@9.18.0':
dependencies:
'@wdio/logger': 9.16.2
'@wdio/logger': 9.18.0
'@wdio/types': 9.16.2
'@wdio/utils': 9.17.0
'@wdio/utils': 9.18.0
deepmerge-ts: 7.1.5
glob: 10.4.5
import-meta-resolve: 4.1.0
@ -19899,13 +19935,13 @@ snapshots:
'@wdio/globals@9.17.0(expect-webdriverio@5.3.4)(webdriverio@9.16.2(puppeteer-core@24.12.1))':
dependencies:
expect-webdriverio: 5.3.4(@wdio/globals@9.17.0)(@wdio/logger@9.16.2)(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.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.17.0(puppeteer-core@24.12.1))':
'@wdio/globals@9.17.0(expect-webdriverio@5.3.4)(webdriverio@9.18.4(puppeteer-core@24.12.1))':
dependencies:
expect-webdriverio: 5.3.4(@wdio/globals@9.17.0)(@wdio/logger@9.16.2)(webdriverio@9.17.0(puppeteer-core@24.12.1))
webdriverio: 9.17.0(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))
webdriverio: 9.18.4(puppeteer-core@24.12.1)
'@wdio/logger@9.16.2':
dependencies:
@ -19914,6 +19950,14 @@ snapshots:
loglevel-plugin-prefix: 0.8.4
strip-ansi: 7.1.0
'@wdio/logger@9.18.0':
dependencies:
chalk: 5.4.1
loglevel: 1.9.2
loglevel-plugin-prefix: 0.8.4
safe-regex2: 5.0.0
strip-ansi: 7.1.0
'@wdio/protocols@9.16.2': {}
'@wdio/repl@9.16.2':
@ -19970,10 +20014,10 @@ snapshots:
- bare-buffer
- supports-color
'@wdio/utils@9.17.0':
'@wdio/utils@9.18.0':
dependencies:
'@puppeteer/browsers': 2.10.5
'@wdio/logger': 9.16.2
'@wdio/logger': 9.18.0
'@wdio/types': 9.16.2
decamelize: 6.0.0
deepmerge-ts: 7.1.5
@ -20919,8 +20963,6 @@ snapshots:
ci-info@3.9.0: {}
ci-info@4.2.0: {}
ci-info@4.3.0: {}
citty@0.1.6:
@ -22243,7 +22285,7 @@ snapshots:
'@babel/helper-validator-identifier': 7.27.1
'@eslint-community/eslint-utils': 4.7.0(eslint@9.30.0(jiti@2.4.2))
'@eslint/plugin-kit': 0.2.8
ci-info: 4.2.0
ci-info: 4.3.0
clean-regexp: 1.0.0
core-js-compat: 3.43.0
eslint: 9.30.0(jiti@2.4.2)
@ -22464,41 +22506,41 @@ snapshots:
expect-type@1.2.1: {}
expect-webdriverio@5.3.4(@wdio/globals@9.17.0)(@wdio/logger@9.16.2)(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.16.2(puppeteer-core@24.12.1)):
dependencies:
'@vitest/snapshot': 3.2.4
'@wdio/globals': 9.17.0(expect-webdriverio@5.3.4)(webdriverio@9.16.2(puppeteer-core@24.12.1))
'@wdio/logger': 9.16.2
'@wdio/logger': 9.18.0
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@9.17.0)(@wdio/logger@9.16.2)(webdriverio@9.17.0(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)):
dependencies:
'@vitest/snapshot': 3.2.4
'@wdio/globals': 9.17.0(expect-webdriverio@5.3.4)(webdriverio@9.17.0(puppeteer-core@24.12.1))
'@wdio/logger': 9.16.2
'@wdio/globals': 9.17.0(expect-webdriverio@5.3.4)(webdriverio@9.18.4(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.17.0(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@packages+wdio-logger)(webdriverio@9.17.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)):
dependencies:
'@vitest/snapshot': 3.2.4
'@wdio/globals': 9.17.0(expect-webdriverio@5.3.4)(webdriverio@9.17.0(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/logger': link:packages/wdio-logger
expect: 30.0.4
jest-matcher-utils: 30.0.3
lodash.isequal: 4.5.0
webdriverio: 9.17.0(puppeteer-core@24.12.1)
webdriverio: 9.18.4(puppeteer-core@24.12.1)
expect-webdriverio@5.3.4(@wdio/globals@packages+wdio-globals)(@wdio/logger@9.16.2)(webdriverio@packages+webdriverio):
expect-webdriverio@5.3.4(@wdio/globals@packages+wdio-globals)(@wdio/logger@9.18.0)(webdriverio@packages+webdriverio):
dependencies:
'@vitest/snapshot': 3.2.4
'@wdio/globals': link:packages/wdio-globals
'@wdio/logger': 9.16.2
'@wdio/logger': 9.18.0
expect: 30.0.4
jest-matcher-utils: 30.0.3
lodash.isequal: 4.5.0
@ -23829,6 +23871,10 @@ 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
@ -24475,7 +24521,7 @@ snapshots:
libnpmpublish@9.0.9:
dependencies:
ci-info: 4.2.0
ci-info: 4.3.0
normalize-package-data: 6.0.2
npm-package-arg: 11.0.2
npm-registry-fetch: 17.1.0
@ -29212,15 +29258,15 @@ snapshots:
- supports-color
- utf-8-validate
webdriver@9.17.0:
webdriver@9.18.0:
dependencies:
'@types/node': 20.19.5
'@types/ws': 8.18.1
'@wdio/config': 9.17.0
'@wdio/logger': 9.16.2
'@wdio/config': 9.18.0
'@wdio/logger': 9.18.0
'@wdio/protocols': 9.16.2
'@wdio/types': 9.16.2
'@wdio/utils': 9.17.0
'@wdio/utils': 9.18.0
deepmerge-ts: 7.1.5
https-proxy-agent: 7.0.6
undici: 6.21.3
@ -29266,16 +29312,16 @@ snapshots:
- supports-color
- utf-8-validate
webdriverio@9.17.0(puppeteer-core@24.12.1):
webdriverio@9.18.4(puppeteer-core@24.12.1):
dependencies:
'@types/node': 20.19.5
'@types/sinonjs__fake-timers': 8.1.5
'@wdio/config': 9.17.0
'@wdio/logger': 9.16.2
'@wdio/config': 9.18.0
'@wdio/logger': 9.18.0
'@wdio/protocols': 9.16.2
'@wdio/repl': 9.16.2
'@wdio/types': 9.16.2
'@wdio/utils': 9.17.0
'@wdio/utils': 9.18.0
archiver: 7.0.1
aria-query: 5.3.2
cheerio: 1.1.0
@ -29290,9 +29336,9 @@ snapshots:
query-selector-shadow-dom: 1.0.1
resq: 1.11.0
rgb2hex: 0.2.5
serialize-error: 11.0.3
serialize-error: 12.0.0
urlpattern-polyfill: 10.1.0
webdriver: 9.17.0
webdriver: 9.18.0
optionalDependencies:
puppeteer-core: 24.12.1
transitivePeerDependencies: