diff --git a/.github/workflows/test-xvfb-e2e.yml b/.github/workflows/test-xvfb-e2e.yml new file mode 100644 index 000000000..2ad6b25b6 --- /dev/null +++ b/.github/workflows/test-xvfb-e2e.yml @@ -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/ + diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d1c63f038..ec23c8f66 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/e2e/package.json b/e2e/package.json index 3a2c01ced..554b49d01 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -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", diff --git a/e2e/wdio/xvfb/base-install.e2e.ts b/e2e/wdio/xvfb/base-install.e2e.ts new file mode 100644 index 000000000..b0a2a4b44 --- /dev/null +++ b/e2e/wdio/xvfb/base-install.e2e.ts @@ -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, + 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) + }) +}) diff --git a/e2e/wdio/xvfb/docker/alpine-base.dockerfile b/e2e/wdio/xvfb/docker/alpine-base.dockerfile new file mode 100644 index 000000000..1d711140a --- /dev/null +++ b/e2e/wdio/xvfb/docker/alpine-base.dockerfile @@ -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"] diff --git a/e2e/wdio/xvfb/docker/alpine-with-xvfb.dockerfile b/e2e/wdio/xvfb/docker/alpine-with-xvfb.dockerfile new file mode 100644 index 000000000..087db4efa --- /dev/null +++ b/e2e/wdio/xvfb/docker/alpine-with-xvfb.dockerfile @@ -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"] diff --git a/e2e/wdio/xvfb/docker/arch-base.dockerfile b/e2e/wdio/xvfb/docker/arch-base.dockerfile new file mode 100644 index 000000000..142bce9da --- /dev/null +++ b/e2e/wdio/xvfb/docker/arch-base.dockerfile @@ -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"] diff --git a/e2e/wdio/xvfb/docker/arch-with-xvfb.dockerfile b/e2e/wdio/xvfb/docker/arch-with-xvfb.dockerfile new file mode 100644 index 000000000..87cf199dd --- /dev/null +++ b/e2e/wdio/xvfb/docker/arch-with-xvfb.dockerfile @@ -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"] diff --git a/e2e/wdio/xvfb/docker/centos-stream-base.dockerfile b/e2e/wdio/xvfb/docker/centos-stream-base.dockerfile new file mode 100644 index 000000000..b808e5227 --- /dev/null +++ b/e2e/wdio/xvfb/docker/centos-stream-base.dockerfile @@ -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"] diff --git a/e2e/wdio/xvfb/docker/centos-stream-with-xvfb.dockerfile b/e2e/wdio/xvfb/docker/centos-stream-with-xvfb.dockerfile new file mode 100644 index 000000000..1af3da685 --- /dev/null +++ b/e2e/wdio/xvfb/docker/centos-stream-with-xvfb.dockerfile @@ -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"] diff --git a/e2e/wdio/xvfb/docker/debian-base.dockerfile b/e2e/wdio/xvfb/docker/debian-base.dockerfile new file mode 100644 index 000000000..7ebd1a4db --- /dev/null +++ b/e2e/wdio/xvfb/docker/debian-base.dockerfile @@ -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"] diff --git a/e2e/wdio/xvfb/docker/debian-with-xvfb.dockerfile b/e2e/wdio/xvfb/docker/debian-with-xvfb.dockerfile new file mode 100644 index 000000000..aafb37c04 --- /dev/null +++ b/e2e/wdio/xvfb/docker/debian-with-xvfb.dockerfile @@ -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"] diff --git a/e2e/wdio/xvfb/docker/fedora-base.dockerfile b/e2e/wdio/xvfb/docker/fedora-base.dockerfile new file mode 100644 index 000000000..30e47f9a1 --- /dev/null +++ b/e2e/wdio/xvfb/docker/fedora-base.dockerfile @@ -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"] diff --git a/e2e/wdio/xvfb/docker/fedora-with-xvfb.dockerfile b/e2e/wdio/xvfb/docker/fedora-with-xvfb.dockerfile new file mode 100644 index 000000000..9205d4825 --- /dev/null +++ b/e2e/wdio/xvfb/docker/fedora-with-xvfb.dockerfile @@ -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"] diff --git a/e2e/wdio/xvfb/docker/suse-base.dockerfile b/e2e/wdio/xvfb/docker/suse-base.dockerfile new file mode 100644 index 000000000..9c79d3942 --- /dev/null +++ b/e2e/wdio/xvfb/docker/suse-base.dockerfile @@ -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"] diff --git a/e2e/wdio/xvfb/docker/suse-with-xvfb.dockerfile b/e2e/wdio/xvfb/docker/suse-with-xvfb.dockerfile new file mode 100644 index 000000000..aaad4df10 --- /dev/null +++ b/e2e/wdio/xvfb/docker/suse-with-xvfb.dockerfile @@ -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"] diff --git a/e2e/wdio/xvfb/docker/ubuntu-base.dockerfile b/e2e/wdio/xvfb/docker/ubuntu-base.dockerfile new file mode 100644 index 000000000..6e1e3635d --- /dev/null +++ b/e2e/wdio/xvfb/docker/ubuntu-base.dockerfile @@ -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"] diff --git a/e2e/wdio/xvfb/docker/ubuntu-with-xvfb.dockerfile b/e2e/wdio/xvfb/docker/ubuntu-with-xvfb.dockerfile new file mode 100644 index 000000000..8554d9229 --- /dev/null +++ b/e2e/wdio/xvfb/docker/ubuntu-with-xvfb.dockerfile @@ -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"] diff --git a/e2e/wdio/xvfb/docker/void-base.dockerfile b/e2e/wdio/xvfb/docker/void-base.dockerfile new file mode 100644 index 000000000..facb3f0e1 --- /dev/null +++ b/e2e/wdio/xvfb/docker/void-base.dockerfile @@ -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"] diff --git a/e2e/wdio/xvfb/docker/void-with-xvfb.dockerfile b/e2e/wdio/xvfb/docker/void-with-xvfb.dockerfile new file mode 100644 index 000000000..aa6d48377 --- /dev/null +++ b/e2e/wdio/xvfb/docker/void-with-xvfb.dockerfile @@ -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"] diff --git a/e2e/wdio/xvfb/existing-xvfb.e2e.ts b/e2e/wdio/xvfb/existing-xvfb.e2e.ts new file mode 100644 index 000000000..cada86e41 --- /dev/null +++ b/e2e/wdio/xvfb/existing-xvfb.e2e.ts @@ -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, + 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)) + }) +}) diff --git a/e2e/wdio/xvfb/wdio.conf.ts b/e2e/wdio/xvfb/wdio.conf.ts new file mode 100644 index 000000000..4998f68c5 --- /dev/null +++ b/e2e/wdio/xvfb/wdio.conf.ts @@ -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: [] + }, +} diff --git a/package.json b/package.json index 038857144..c0e770d26 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/wdio-local-runner/package.json b/packages/wdio-local-runner/package.json index 711f9f8ee..855894189 100644 --- a/packages/wdio-local-runner/package.json +++ b/packages/wdio-local-runner/package.json @@ -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", diff --git a/packages/wdio-local-runner/src/index.ts b/packages/wdio-local-runner/src/index.ts index 4aa43927c..6bf9f180b 100644 --- a/packages/wdio-local-runner/src/index.ts +++ b/packages/wdio-local-runner/src/index.ts @@ -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 = {} + 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 = {} /** @@ -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((resolve) => { + const shutdownResult = await new Promise((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 } } diff --git a/packages/wdio-local-runner/src/worker.ts b/packages/wdio-local-runner/src/worker.ts index c881c5276..2d2bede8e 100644 --- a/packages/wdio-local-runner/src/worker.ts +++ b/packages/wdio-local-runner/src/worker.ts @@ -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 logsAggregator: string[] = [] + #processFactory: ProcessFactory instances?: Record 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 { 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 } diff --git a/packages/wdio-local-runner/tests/localRunner.test.ts b/packages/wdio-local-runner/tests/localRunner.test.ts index df317efbd..ee7116545 100644 --- a/packages/wdio-local-runner/tests/localRunner.test.ts +++ b/packages/wdio-local-runner/tests/localRunner.test.ts @@ -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() }) diff --git a/packages/wdio-local-runner/tests/run.test.ts b/packages/wdio-local-runner/tests/run.test.ts index 6feac4c04..ecff709b3 100644 --- a/packages/wdio-local-runner/tests/run.test.ts +++ b/packages/wdio-local-runner/tests/run.test.ts @@ -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(() => { diff --git a/packages/wdio-local-runner/tests/worker.test.ts b/packages/wdio-local-runner/tests/worker.test.ts index 670ff4f46..d2fddf5dd 100644 --- a/packages/wdio-local-runner/tests/worker.test.ts +++ b/packages/wdio-local-runner/tests/worker.test.ts @@ -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 diff --git a/packages/wdio-types/src/Options.ts b/packages/wdio-types/src/Options.ts index 1dcea085b..b9497eae4 100644 --- a/packages/wdio-types/src/Options.ts +++ b/packages/wdio-types/src/Options.ts @@ -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 diff --git a/packages/wdio-xvfb/LICENSE b/packages/wdio-xvfb/LICENSE new file mode 100644 index 000000000..a23af2318 --- /dev/null +++ b/packages/wdio-xvfb/LICENSE @@ -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. \ No newline at end of file diff --git a/packages/wdio-xvfb/README.md b/packages/wdio-xvfb/README.md new file mode 100644 index 000000000..0bb8201fd --- /dev/null +++ b/packages/wdio-xvfb/README.md @@ -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`** - Initialize xvfb-run, returns true if ready, false if not needed +- **`executeWithRetry(commandFn, context?): Promise`** - 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 diff --git a/packages/wdio-xvfb/package.json b/packages/wdio-xvfb/package.json new file mode 100644 index 000000000..35d84fbd4 --- /dev/null +++ b/packages/wdio-xvfb/package.json @@ -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" + } +} diff --git a/packages/wdio-xvfb/src/ProcessFactory.ts b/packages/wdio-xvfb/src/ProcessFactory.ts new file mode 100644 index 000000000..78b078826 --- /dev/null +++ b/packages/wdio-xvfb/src/ProcessFactory.ts @@ -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; +} + +export interface ProcessCreationOptions { + cwd?: string; + env?: Record; + 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 { + 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 { + 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 + } + } +} diff --git a/packages/wdio-xvfb/src/XvfbManager.ts b/packages/wdio-xvfb/src/XvfbManager.ts new file mode 100644 index 000000000..7804fcd5a --- /dev/null +++ b/packages/wdio-xvfb/src/XvfbManager.ts @@ -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 + + 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 - true if xvfb-run is ready, false if not needed + */ + public async init(capabilities?: Capabilities.ResolvedTestrunnerCapabilities): Promise { + 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 { + 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 + + // 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): 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): caps is Record { + 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 { + // 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 { + this.log.info('Detecting package manager...') + const packageManager = await this.detectPackageManager() + this.log.info(`Detected package manager: ${packageManager}`) + + const installCommands: Record = { + 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( + commandFn: () => Promise, + context: string = 'xvfb operation' + ): Promise { + 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()) + ) + } +} diff --git a/packages/wdio-xvfb/src/index.ts b/packages/wdio-xvfb/src/index.ts new file mode 100644 index 000000000..dd8e4da52 --- /dev/null +++ b/packages/wdio-xvfb/src/index.ts @@ -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() diff --git a/packages/wdio-xvfb/tests/ProcessFactory.test.ts b/packages/wdio-xvfb/tests/ProcessFactory.test.ts new file mode 100644 index 000000000..cc7d14431 --- /dev/null +++ b/packages/wdio-xvfb/tests/ProcessFactory.test.ts @@ -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 + 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() + }) + }) + +}) diff --git a/packages/wdio-xvfb/tests/XvfbManager.test.ts b/packages/wdio-xvfb/tests/XvfbManager.test.ts new file mode 100644 index 000000000..415d15058 --- /dev/null +++ b/packages/wdio-xvfb/tests/XvfbManager.test.ts @@ -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 + + 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) + }) + }) + +}) diff --git a/packages/wdio-xvfb/tsconfig.json b/packages/wdio-xvfb/tsconfig.json new file mode 100644 index 000000000..7380525a6 --- /dev/null +++ b/packages/wdio-xvfb/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./build", + "rootDir": "./src" + }, + "include": [ + "./src/**/*" + ] +} diff --git a/packages/wdio-xvfb/tsconfig.prod.json b/packages/wdio-xvfb/tsconfig.prod.json new file mode 100644 index 000000000..78c03cb8d --- /dev/null +++ b/packages/wdio-xvfb/tsconfig.prod.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.prod.json", + "compilerOptions": { + "outDir": "./build", + "rootDir": "./src" + }, + "include": [ + "./src/**/*" + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce5ed856b..88730f765 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: