From 6f683ae9406adca6a8c1c9ee85c38924025de2ab Mon Sep 17 00:00:00 2001 From: quanru Date: Fri, 28 Aug 2026 10:32:55 +0800 Subject: [PATCH 1/5] test(workflow): add temporary computer recorder acceptance --- .github/workflows/headless-linux.yml | 685 +++++++++++++++++---------- 1 file changed, 426 insertions(+), 259 deletions(-) diff --git a/.github/workflows/headless-linux.yml b/.github/workflows/headless-linux.yml index 77a1e6b137..a3d4b2ff5c 100644 --- a/.github/workflows/headless-linux.yml +++ b/.github/workflows/headless-linux.yml @@ -30,7 +30,7 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" + ELECTRON_SKIP_BINARY_DOWNLOAD: '1' jobs: headless-linux-ai-todo: @@ -40,67 +40,234 @@ jobs: MIDSCENE_MODEL_BASE_URL: ${{ vars.MIDSCENE_MODEL_BASE_URL }} MIDSCENE_MODEL_NAME: ${{ vars.MIDSCENE_MODEL_NAME }} MIDSCENE_MODEL_FAMILY: ${{ vars.MIDSCENE_MODEL_FAMILY }} - MIDSCENE_MODEL_RETRY_COUNT: "2" - MIDSCENE_MODEL_RETRY_INTERVAL: "60000" - MIDSCENE_REPORT_QUIET: "true" - MIDSCENE_COMPUTER_HEADLESS_LINUX: "true" - CI: "1" + MIDSCENE_MODEL_RETRY_COUNT: '2' + MIDSCENE_MODEL_RETRY_INTERVAL: '60000' + MIDSCENE_REPORT_QUIET: 'true' + MIDSCENE_COMPUTER_HEADLESS_LINUX: 'true' + CI: '1' steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - with: - ref: ${{ github.event.inputs.branch || github.ref }} - - - name: Setup Node.js and pnpm - uses: ./.github/actions/setup-node-pnpm - - - name: Cache pnpm store - id: pnpm-cache - uses: ./.github/actions/pnpm-cache/restore - - - name: Install dependencies - run: pnpm install --frozen-lockfile --ignore-scripts - - - name: Save pnpm store - if: steps.pnpm-cache.outputs.cache-hit != 'true' - uses: ./.github/actions/pnpm-cache/save - - - name: Setup headless browser - uses: ./.github/actions/setup-headless-browser - - - name: Build AI todo projects - run: pnpm exec nx run-many --target=build --projects="@midscene/report,@midscene/computer" --skip-nx-cache - - - name: Verify system setup - run: | - echo "--- Xvfb ---" - which Xvfb && echo "Xvfb: OK" - echo "--- xrandr ---" - which xrandr && echo "xrandr: OK" - echo "--- import (imagemagick) ---" - which import && echo "import: OK" - echo "--- Browser ---" - chromium --version || echo "Chrome not found" - echo "--- DISPLAY ---" - echo "DISPLAY=${DISPLAY:-(not set)}" - - - name: Run AI todo test with Xvfb - run: AI_TEST_TYPE=computer npx nx test @midscene/computer -- tests/ai/ai-auto-todo.test.ts - timeout-minutes: 25 - id: test-ai - continue-on-error: true - - - name: Upload test report - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: headless-linux-ai-report - path: packages/computer/midscene_run/report - if-no-files-found: ignore - - - name: Check test result - if: steps.test-ai.outcome == 'failure' - run: exit 1 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + ref: ${{ github.event.inputs.branch || github.ref }} + + - name: Setup Node.js and pnpm + uses: ./.github/actions/setup-node-pnpm + + - name: Cache pnpm store + id: pnpm-cache + uses: ./.github/actions/pnpm-cache/restore + + - name: Install dependencies + run: pnpm install --frozen-lockfile --ignore-scripts + + - name: Save pnpm store + if: steps.pnpm-cache.outputs.cache-hit != 'true' + uses: ./.github/actions/pnpm-cache/save + + - name: Setup headless browser + uses: ./.github/actions/setup-headless-browser + + - name: Build AI todo projects + run: pnpm exec nx run-many --target=build --projects="@midscene/report,@midscene/computer" --skip-nx-cache + + - name: Verify system setup + run: | + echo "--- Xvfb ---" + which Xvfb && echo "Xvfb: OK" + echo "--- xrandr ---" + which xrandr && echo "xrandr: OK" + echo "--- import (imagemagick) ---" + which import && echo "import: OK" + echo "--- Browser ---" + chromium --version || echo "Chrome not found" + echo "--- DISPLAY ---" + echo "DISPLAY=${DISPLAY:-(not set)}" + + - name: Run AI todo test with Xvfb + run: AI_TEST_TYPE=computer npx nx test @midscene/computer -- tests/ai/ai-auto-todo.test.ts + timeout-minutes: 25 + id: test-ai + continue-on-error: true + + - name: Upload test report + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: headless-linux-ai-report + path: packages/computer/midscene_run/report + if-no-files-found: ignore + + - name: Check test result + if: steps.test-ai.outcome == 'failure' + run: exit 1 + + # Temporary acceptance job for PR #3049. Remove after the recorder artifact + # and formal observation assertion have been verified in CI. + computer-recorder-acceptance: + runs-on: ${{ fromJSON(vars.LINUX_SELF_HOSTED_RUNNER_LABELS || '"ubuntu-22.04"') }} + env: + MIDSCENE_MODEL_API_KEY: ${{ secrets.MIDSCENE_MODEL_API_KEY }} + MIDSCENE_MODEL_BASE_URL: ${{ vars.MIDSCENE_MODEL_BASE_URL }} + MIDSCENE_MODEL_NAME: ${{ vars.MIDSCENE_MODEL_NAME }} + MIDSCENE_MODEL_FAMILY: ${{ vars.MIDSCENE_MODEL_FAMILY }} + MIDSCENE_MODEL_RETRY_COUNT: '2' + MIDSCENE_MODEL_RETRY_INTERVAL: '60000' + MIDSCENE_REPORT_QUIET: 'true' + MIDSCENE_COMPUTER_HEADLESS_LINUX: 'true' + CI: '1' + + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + ref: ${{ github.event.inputs.branch || github.ref }} + + - name: Setup Node.js and pnpm + uses: ./.github/actions/setup-node-pnpm + + - name: Cache pnpm store + id: pnpm-cache + uses: ./.github/actions/pnpm-cache/restore + + - name: Install dependencies + run: pnpm install --frozen-lockfile --ignore-scripts + + - name: Save pnpm store + if: steps.pnpm-cache.outputs.cache-hit != 'true' + uses: ./.github/actions/pnpm-cache/save + + - name: Setup headless browser + uses: ./.github/actions/setup-headless-browser + + - name: Build Computer recorder projects + run: pnpm exec nx run-many --target=build --projects="@midscene/report,@midscene/computer" --skip-nx-cache + + - name: Record and assert a real Xvfb observation through a PTY + id: recorder-acceptance + continue-on-error: true + timeout-minutes: 15 + shell: bash + run: | + set -euo pipefail + + acceptance_dir="${RUNNER_TEMP}/computer-recorder-acceptance" + manifest="${acceptance_dir}/observation.json" + frames_dir="${acceptance_dir}/observation.frames" + record_log="${acceptance_dir}/record.log" + assert_log="${acceptance_dir}/assert.log" + before_locks="${acceptance_dir}/before-xvfb-locks.txt" + active_locks="${acceptance_dir}/active-xvfb-locks.txt" + after_locks="${acceptance_dir}/after-xvfb-locks.txt" + mkdir -p "$acceptance_dir" + touch "$record_log" + find /tmp -maxdepth 1 -name '.X*-lock' -print | sort > "$before_locks" + + record_command="pnpm --filter @midscene/computer exec midscene-computer record start --output '${manifest}' --interval-ms 500 --max-frames 12 --watchdog-ms 90000 --headless --verbose=jsonl" + + set +e + ( + ready=0 + for _ in $(seq 1 90); do + if grep -q '"event":"recording_ready"' "$record_log"; then + ready=1 + break + fi + sleep 1 + done + if [ "$ready" -ne 1 ]; then + echo "Recorder did not become ready within 90 seconds" >&2 + exit 2 + fi + + find /tmp -maxdepth 1 -name '.X*-lock' -print | sort > "$active_locks" + display_lock=$(comm -13 "$before_locks" "$active_locks" | head -n 1) + if [ -z "$display_lock" ]; then + echo "Recorder did not create a detectable Xvfb display" >&2 + exit 3 + fi + display_number=${display_lock#/tmp/.X} + display_number=${display_number%-lock} + display=":${display_number}" + echo "$display" > "${acceptance_dir}/display.txt" + + DISPLAY="$display" xsetroot -solid '#202020' + sleep 2 + DISPLAY="$display" xsetroot -solid '#2f80ed' + sleep 2 + + # stdin is a real PTY here. The first Ctrl+C must be consumed only by + # the Midscene child so pnpm stays alive while artifacts are saved. + printf '\003' + while true; do + sleep 1 + printf '\000' + done + ) | timeout --signal=TERM 120s script --quiet --return --flush --command "$record_command" "$record_log" + recorder_status=${PIPESTATUS[1]} + set -e + + if [ "$recorder_status" -ne 0 ]; then + echo "Recorder exited with status ${recorder_status}" >&2 + exit "$recorder_status" + fi + grep -q 'Observation record saved:' "$record_log" + test -s "$manifest" + test -d "$frames_dir" + + node - "$manifest" <<'NODE' + const fs = require('node:fs'); + const path = require('node:path'); + + const manifestPath = path.resolve(process.argv[2]); + const record = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + if (record.type !== 'midscene_ui_observation' || record.version !== 1) { + throw new Error('Unexpected observation manifest type or version'); + } + if (!Array.isArray(record.frames) || record.frames.length < 2) { + throw new Error(`Expected at least 2 observation frames, received ${record.frames?.length ?? 0}`); + } + for (const [index, frame] of record.frames.entries()) { + if (path.isAbsolute(frame.path)) { + throw new Error(`Frame ${index} must use a relative path`); + } + const framePath = path.resolve(path.dirname(manifestPath), frame.path); + if (!fs.statSync(framePath).isFile() || fs.statSync(framePath).size === 0) { + throw new Error(`Frame ${index} is missing or empty: ${framePath}`); + } + } + const temporaryDirectories = fs + .readdirSync(path.dirname(manifestPath)) + .filter((entry) => entry.startsWith('observation.frames.tmp-')); + if (temporaryDirectories.length > 0) { + throw new Error(`Temporary frame directories remain: ${temporaryDirectories.join(', ')}`); + } + console.log(`COMPUTER_RECORDER_MANIFEST_OK frames=${record.frames.length}`); + NODE + + find /tmp -maxdepth 1 -name '.X*-lock' -print | sort > "$after_locks" + cmp "$before_locks" "$after_locks" + echo "COMPUTER_RECORDER_XVFB_CLEANUP_OK" + + pnpm --filter @midscene/computer exec midscene-computer assert \ + --record "$manifest" \ + --prompt "The observation shows the virtual desktop changing from a dark background to a blue background." \ + 2>&1 | tee "$assert_log" + grep -q 'Assertion passed' "$assert_log" + echo "COMPUTER_RECORDER_ASSERT_OK" + + - name: Upload Computer recorder acceptance artifacts + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: computer-recorder-acceptance + path: | + ${{ runner.temp }}/computer-recorder-acceptance + packages/computer/midscene_run/report + if-no-files-found: warn + + - name: Check Computer recorder acceptance result + if: steps.recorder-acceptance.outcome == 'failure' + run: exit 1 chrome-extension: runs-on: ${{ fromJSON(vars.LINUX_SELF_HOSTED_RUNNER_LABELS || '"ubuntu-22.04"') }} @@ -110,56 +277,56 @@ jobs: MIDSCENE_MODEL_BASE_URL: ${{ vars.MIDSCENE_MODEL_BASE_URL }} MIDSCENE_MODEL_NAME: ${{ vars.MIDSCENE_MODEL_NAME }} MIDSCENE_MODEL_FAMILY: ${{ vars.MIDSCENE_MODEL_FAMILY }} - MIDSCENE_MODEL_RETRY_COUNT: "2" - MIDSCENE_MODEL_RETRY_INTERVAL: "60000" - MIDSCENE_REPORT_QUIET: "true" - MIDSCENE_COMPUTER_HEADLESS_LINUX: "true" - CI: "1" + MIDSCENE_MODEL_RETRY_COUNT: '2' + MIDSCENE_MODEL_RETRY_INTERVAL: '60000' + MIDSCENE_REPORT_QUIET: 'true' + MIDSCENE_COMPUTER_HEADLESS_LINUX: 'true' + CI: '1' # Env vars to inject into Chrome extension's config UI via CDP - MIDSCENE_USE_QWEN3_VL: "1" + MIDSCENE_USE_QWEN3_VL: '1' steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - with: - ref: ${{ github.event.inputs.branch || github.ref }} + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + ref: ${{ github.event.inputs.branch || github.ref }} - - name: Setup Node.js and pnpm - uses: ./.github/actions/setup-node-pnpm + - name: Setup Node.js and pnpm + uses: ./.github/actions/setup-node-pnpm - - name: Cache pnpm store - id: pnpm-cache - uses: ./.github/actions/pnpm-cache/restore + - name: Cache pnpm store + id: pnpm-cache + uses: ./.github/actions/pnpm-cache/restore - - name: Install dependencies - run: pnpm install --frozen-lockfile --ignore-scripts + - name: Install dependencies + run: pnpm install --frozen-lockfile --ignore-scripts - - name: Save pnpm store - if: steps.pnpm-cache.outputs.cache-hit != 'true' - uses: ./.github/actions/pnpm-cache/save + - name: Save pnpm store + if: steps.pnpm-cache.outputs.cache-hit != 'true' + uses: ./.github/actions/pnpm-cache/save - - name: Setup headless browser - uses: ./.github/actions/setup-headless-browser + - name: Setup headless browser + uses: ./.github/actions/setup-headless-browser - - name: Build Chrome extension projects - run: pnpm exec nx run-many --target=build --projects="@midscene/report,chrome-extension" --skip-nx-cache + - name: Build Chrome extension projects + run: pnpm exec nx run-many --target=build --projects="@midscene/report,chrome-extension" --skip-nx-cache - - name: Run Chrome extension test - run: AI_TEST_TYPE=computer npx nx test @midscene/computer -- tests/ai/chrome-extension.test.ts - timeout-minutes: 40 - id: test-ext - continue-on-error: true + - name: Run Chrome extension test + run: AI_TEST_TYPE=computer npx nx test @midscene/computer -- tests/ai/chrome-extension.test.ts + timeout-minutes: 40 + id: test-ext + continue-on-error: true - - name: Upload test report - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: chrome-extension-report - path: packages/computer/midscene_run/report - if-no-files-found: ignore + - name: Upload test report + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: chrome-extension-report + path: packages/computer/midscene_run/report + if-no-files-found: ignore - - name: Check test result - if: steps.test-ext.outcome == 'failure' - run: exit 1 + - name: Check test result + if: steps.test-ext.outcome == 'failure' + run: exit 1 chrome-extension-bridge: runs-on: ${{ fromJSON(vars.LINUX_SELF_HOSTED_RUNNER_LABELS || '"ubuntu-22.04"') }} @@ -168,55 +335,55 @@ jobs: MIDSCENE_MODEL_BASE_URL: ${{ vars.MIDSCENE_MODEL_BASE_URL }} MIDSCENE_MODEL_NAME: ${{ vars.MIDSCENE_MODEL_NAME }} MIDSCENE_MODEL_FAMILY: ${{ vars.MIDSCENE_MODEL_FAMILY }} - MIDSCENE_MODEL_RETRY_COUNT: "2" - MIDSCENE_MODEL_RETRY_INTERVAL: "60000" - MIDSCENE_REPORT_QUIET: "true" - MIDSCENE_COMPUTER_HEADLESS_LINUX: "true" - CI: "1" - MIDSCENE_USE_QWEN3_VL: "1" + MIDSCENE_MODEL_RETRY_COUNT: '2' + MIDSCENE_MODEL_RETRY_INTERVAL: '60000' + MIDSCENE_REPORT_QUIET: 'true' + MIDSCENE_COMPUTER_HEADLESS_LINUX: 'true' + CI: '1' + MIDSCENE_USE_QWEN3_VL: '1' steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - with: - ref: ${{ github.event.inputs.branch || github.ref }} + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + ref: ${{ github.event.inputs.branch || github.ref }} - - name: Setup Node.js and pnpm - uses: ./.github/actions/setup-node-pnpm + - name: Setup Node.js and pnpm + uses: ./.github/actions/setup-node-pnpm - - name: Cache pnpm store - id: pnpm-cache - uses: ./.github/actions/pnpm-cache/restore + - name: Cache pnpm store + id: pnpm-cache + uses: ./.github/actions/pnpm-cache/restore - - name: Install dependencies - run: pnpm install --frozen-lockfile --ignore-scripts + - name: Install dependencies + run: pnpm install --frozen-lockfile --ignore-scripts - - name: Save pnpm store - if: steps.pnpm-cache.outputs.cache-hit != 'true' - uses: ./.github/actions/pnpm-cache/save + - name: Save pnpm store + if: steps.pnpm-cache.outputs.cache-hit != 'true' + uses: ./.github/actions/pnpm-cache/save - - name: Setup headless browser - uses: ./.github/actions/setup-headless-browser + - name: Setup headless browser + uses: ./.github/actions/setup-headless-browser - - name: Build Chrome extension projects - run: pnpm exec nx run-many --target=build --projects="@midscene/report,chrome-extension" --skip-nx-cache + - name: Build Chrome extension projects + run: pnpm exec nx run-many --target=build --projects="@midscene/report,chrome-extension" --skip-nx-cache - - name: Run Bridge mode start/stop test - run: AI_TEST_TYPE=computer npx nx test @midscene/computer -- tests/ai/chrome-extension-bridge.test.ts - timeout-minutes: 40 - id: test-bridge - continue-on-error: true + - name: Run Bridge mode start/stop test + run: AI_TEST_TYPE=computer npx nx test @midscene/computer -- tests/ai/chrome-extension-bridge.test.ts + timeout-minutes: 40 + id: test-bridge + continue-on-error: true - - name: Upload bridge test report - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: chrome-extension-bridge-report - path: packages/computer/midscene_run/report - if-no-files-found: ignore + - name: Upload bridge test report + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: chrome-extension-bridge-report + path: packages/computer/midscene_run/report + if-no-files-found: ignore - - name: Check test result - if: steps.test-bridge.outcome == 'failure' - run: exit 1 + - name: Check test result + if: steps.test-bridge.outcome == 'failure' + run: exit 1 chrome-extension-playground: runs-on: ${{ fromJSON(vars.LINUX_SELF_HOSTED_RUNNER_LABELS || '"ubuntu-22.04"') }} @@ -233,55 +400,55 @@ jobs: MIDSCENE_MODEL_BASE_URL: ${{ vars.MIDSCENE_MODEL_BASE_URL }} MIDSCENE_MODEL_NAME: ${{ vars.MIDSCENE_MODEL_NAME }} MIDSCENE_MODEL_FAMILY: ${{ vars.MIDSCENE_MODEL_FAMILY }} - MIDSCENE_MODEL_RETRY_COUNT: "2" - MIDSCENE_MODEL_RETRY_INTERVAL: "60000" - MIDSCENE_REPORT_QUIET: "true" - MIDSCENE_COMPUTER_HEADLESS_LINUX: "true" - CI: "1" - MIDSCENE_USE_QWEN3_VL: "1" + MIDSCENE_MODEL_RETRY_COUNT: '2' + MIDSCENE_MODEL_RETRY_INTERVAL: '60000' + MIDSCENE_REPORT_QUIET: 'true' + MIDSCENE_COMPUTER_HEADLESS_LINUX: 'true' + CI: '1' + MIDSCENE_USE_QWEN3_VL: '1' steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - with: - ref: ${{ github.event.inputs.branch || github.ref }} + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + ref: ${{ github.event.inputs.branch || github.ref }} - - name: Setup Node.js and pnpm - uses: ./.github/actions/setup-node-pnpm + - name: Setup Node.js and pnpm + uses: ./.github/actions/setup-node-pnpm - - name: Cache pnpm store - id: pnpm-cache - uses: ./.github/actions/pnpm-cache/restore + - name: Cache pnpm store + id: pnpm-cache + uses: ./.github/actions/pnpm-cache/restore - - name: Install dependencies - run: pnpm install --frozen-lockfile --ignore-scripts + - name: Install dependencies + run: pnpm install --frozen-lockfile --ignore-scripts - - name: Save pnpm store - if: steps.pnpm-cache.outputs.cache-hit != 'true' - uses: ./.github/actions/pnpm-cache/save + - name: Save pnpm store + if: steps.pnpm-cache.outputs.cache-hit != 'true' + uses: ./.github/actions/pnpm-cache/save - - name: Setup headless browser - uses: ./.github/actions/setup-headless-browser + - name: Setup headless browser + uses: ./.github/actions/setup-headless-browser - - name: Build Chrome extension projects - run: pnpm exec nx run-many --target=build --projects="@midscene/report,chrome-extension" --skip-nx-cache + - name: Build Chrome extension projects + run: pnpm exec nx run-many --target=build --projects="@midscene/report,chrome-extension" --skip-nx-cache - - name: Run Playground E2E test - run: AI_TEST_TYPE=computer npx nx test @midscene/computer -- tests/ai/${{ matrix.test.file }} - timeout-minutes: 40 - id: test-playground - continue-on-error: true + - name: Run Playground E2E test + run: AI_TEST_TYPE=computer npx nx test @midscene/computer -- tests/ai/${{ matrix.test.file }} + timeout-minutes: 40 + id: test-playground + continue-on-error: true - - name: Upload test report - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: ${{ matrix.test.report }} - path: packages/computer/midscene_run/report - if-no-files-found: ignore + - name: Upload test report + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: ${{ matrix.test.report }} + path: packages/computer/midscene_run/report + if-no-files-found: ignore - - name: Check test result - if: steps.test-playground.outcome == 'failure' - run: exit 1 + - name: Check test result + if: steps.test-playground.outcome == 'failure' + run: exit 1 chrome-extension-recorder: runs-on: ${{ fromJSON(vars.LINUX_SELF_HOSTED_RUNNER_LABELS || '"ubuntu-22.04"') }} @@ -290,55 +457,55 @@ jobs: MIDSCENE_MODEL_BASE_URL: ${{ vars.MIDSCENE_MODEL_BASE_URL }} MIDSCENE_MODEL_NAME: ${{ vars.MIDSCENE_MODEL_NAME }} MIDSCENE_MODEL_FAMILY: ${{ vars.MIDSCENE_MODEL_FAMILY }} - MIDSCENE_MODEL_RETRY_COUNT: "2" - MIDSCENE_MODEL_RETRY_INTERVAL: "60000" - MIDSCENE_REPORT_QUIET: "true" - MIDSCENE_COMPUTER_HEADLESS_LINUX: "true" - CI: "1" - MIDSCENE_USE_QWEN3_VL: "1" + MIDSCENE_MODEL_RETRY_COUNT: '2' + MIDSCENE_MODEL_RETRY_INTERVAL: '60000' + MIDSCENE_REPORT_QUIET: 'true' + MIDSCENE_COMPUTER_HEADLESS_LINUX: 'true' + CI: '1' + MIDSCENE_USE_QWEN3_VL: '1' steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - with: - ref: ${{ github.event.inputs.branch || github.ref }} + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + ref: ${{ github.event.inputs.branch || github.ref }} - - name: Setup Node.js and pnpm - uses: ./.github/actions/setup-node-pnpm + - name: Setup Node.js and pnpm + uses: ./.github/actions/setup-node-pnpm - - name: Cache pnpm store - id: pnpm-cache - uses: ./.github/actions/pnpm-cache/restore + - name: Cache pnpm store + id: pnpm-cache + uses: ./.github/actions/pnpm-cache/restore - - name: Install dependencies - run: pnpm install --frozen-lockfile --ignore-scripts + - name: Install dependencies + run: pnpm install --frozen-lockfile --ignore-scripts - - name: Save pnpm store - if: steps.pnpm-cache.outputs.cache-hit != 'true' - uses: ./.github/actions/pnpm-cache/save + - name: Save pnpm store + if: steps.pnpm-cache.outputs.cache-hit != 'true' + uses: ./.github/actions/pnpm-cache/save - - name: Setup headless browser - uses: ./.github/actions/setup-headless-browser + - name: Setup headless browser + uses: ./.github/actions/setup-headless-browser - - name: Build Chrome extension projects - run: pnpm exec nx run-many --target=build --projects="@midscene/report,chrome-extension" --skip-nx-cache + - name: Build Chrome extension projects + run: pnpm exec nx run-many --target=build --projects="@midscene/report,chrome-extension" --skip-nx-cache - - name: Run Recorder mode tests - run: AI_TEST_TYPE=computer npx nx test @midscene/computer -- tests/ai/chrome-extension-recorder.test.ts - timeout-minutes: 40 - id: test-recorder - continue-on-error: true + - name: Run Recorder mode tests + run: AI_TEST_TYPE=computer npx nx test @midscene/computer -- tests/ai/chrome-extension-recorder.test.ts + timeout-minutes: 40 + id: test-recorder + continue-on-error: true - - name: Upload test report - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: chrome-extension-recorder-report - path: packages/computer/midscene_run/report - if-no-files-found: ignore + - name: Upload test report + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: chrome-extension-recorder-report + path: packages/computer/midscene_run/report + if-no-files-found: ignore - - name: Check test result - if: steps.test-recorder.outcome == 'failure' - run: exit 1 + - name: Check test result + if: steps.test-recorder.outcome == 'failure' + run: exit 1 chrome-extension-settings: runs-on: ${{ fromJSON(vars.LINUX_SELF_HOSTED_RUNNER_LABELS || '"ubuntu-22.04"') }} @@ -347,52 +514,52 @@ jobs: MIDSCENE_MODEL_BASE_URL: ${{ vars.MIDSCENE_MODEL_BASE_URL }} MIDSCENE_MODEL_NAME: ${{ vars.MIDSCENE_MODEL_NAME }} MIDSCENE_MODEL_FAMILY: ${{ vars.MIDSCENE_MODEL_FAMILY }} - MIDSCENE_MODEL_RETRY_COUNT: "2" - MIDSCENE_MODEL_RETRY_INTERVAL: "60000" - MIDSCENE_REPORT_QUIET: "true" - MIDSCENE_COMPUTER_HEADLESS_LINUX: "true" - CI: "1" - MIDSCENE_USE_QWEN3_VL: "1" + MIDSCENE_MODEL_RETRY_COUNT: '2' + MIDSCENE_MODEL_RETRY_INTERVAL: '60000' + MIDSCENE_REPORT_QUIET: 'true' + MIDSCENE_COMPUTER_HEADLESS_LINUX: 'true' + CI: '1' + MIDSCENE_USE_QWEN3_VL: '1' steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - with: - ref: ${{ github.event.inputs.branch || github.ref }} - - - name: Setup Node.js and pnpm - uses: ./.github/actions/setup-node-pnpm - - - name: Cache pnpm store - id: pnpm-cache - uses: ./.github/actions/pnpm-cache/restore - - - name: Install dependencies - run: pnpm install --frozen-lockfile --ignore-scripts - - - name: Save pnpm store - if: steps.pnpm-cache.outputs.cache-hit != 'true' - uses: ./.github/actions/pnpm-cache/save - - - name: Setup headless browser - uses: ./.github/actions/setup-headless-browser - - - name: Build Chrome extension projects - run: pnpm exec nx run-many --target=build --projects="@midscene/report,chrome-extension" --skip-nx-cache - - - name: Run Settings and cross-mode tests - run: AI_TEST_TYPE=computer npx nx test @midscene/computer -- tests/ai/chrome-extension-settings.test.ts - timeout-minutes: 30 - id: test-settings - continue-on-error: true - - - name: Upload test report - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: chrome-extension-settings-report - path: packages/computer/midscene_run/report - if-no-files-found: ignore - - - name: Check test result - if: steps.test-settings.outcome == 'failure' - run: exit 1 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + ref: ${{ github.event.inputs.branch || github.ref }} + + - name: Setup Node.js and pnpm + uses: ./.github/actions/setup-node-pnpm + + - name: Cache pnpm store + id: pnpm-cache + uses: ./.github/actions/pnpm-cache/restore + + - name: Install dependencies + run: pnpm install --frozen-lockfile --ignore-scripts + + - name: Save pnpm store + if: steps.pnpm-cache.outputs.cache-hit != 'true' + uses: ./.github/actions/pnpm-cache/save + + - name: Setup headless browser + uses: ./.github/actions/setup-headless-browser + + - name: Build Chrome extension projects + run: pnpm exec nx run-many --target=build --projects="@midscene/report,chrome-extension" --skip-nx-cache + + - name: Run Settings and cross-mode tests + run: AI_TEST_TYPE=computer npx nx test @midscene/computer -- tests/ai/chrome-extension-settings.test.ts + timeout-minutes: 30 + id: test-settings + continue-on-error: true + + - name: Upload test report + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: chrome-extension-settings-report + path: packages/computer/midscene_run/report + if-no-files-found: ignore + + - name: Check test result + if: steps.test-settings.outcome == 'failure' + run: exit 1 From 54fe9f02822a631b254d366710e03372ba0187a8 Mon Sep 17 00:00:00 2001 From: quanru Date: Fri, 28 Aug 2026 10:42:24 +0800 Subject: [PATCH 2/5] test(workflow): invoke local computer binary in acceptance --- .github/workflows/headless-linux.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/headless-linux.yml b/.github/workflows/headless-linux.yml index a3d4b2ff5c..bd3a7491b9 100644 --- a/.github/workflows/headless-linux.yml +++ b/.github/workflows/headless-linux.yml @@ -162,7 +162,7 @@ jobs: touch "$record_log" find /tmp -maxdepth 1 -name '.X*-lock' -print | sort > "$before_locks" - record_command="pnpm --filter @midscene/computer exec midscene-computer record start --output '${manifest}' --interval-ms 500 --max-frames 12 --watchdog-ms 90000 --headless --verbose=jsonl" + record_command="pnpm --filter @midscene/computer exec ./bin/midscene-computer record start --output '${manifest}' --interval-ms 500 --max-frames 12 --watchdog-ms 90000 --headless --verbose=jsonl" set +e ( @@ -248,7 +248,7 @@ jobs: cmp "$before_locks" "$after_locks" echo "COMPUTER_RECORDER_XVFB_CLEANUP_OK" - pnpm --filter @midscene/computer exec midscene-computer assert \ + pnpm --filter @midscene/computer exec ./bin/midscene-computer assert \ --record "$manifest" \ --prompt "The observation shows the virtual desktop changing from a dark background to a blue background." \ 2>&1 | tee "$assert_log" From 613528d31309e45f363d66edef1d97ced5e569e2 Mon Sep 17 00:00:00 2001 From: quanru Date: Fri, 28 Aug 2026 11:08:13 +0800 Subject: [PATCH 3/5] fix(computer): defer CLI Xvfb cleanup until process exit --- packages/computer/src/agent-tools.ts | 12 ++++++++ packages/computer/src/agent.ts | 1 + packages/computer/src/cli.ts | 4 ++- packages/computer/src/device.ts | 17 +++++++++-- .../tests/unit-test/agent-tools.test.ts | 20 +++++++++++++ .../computer/tests/unit-test/device.test.ts | 30 ++++++++++++++++++- 6 files changed, 80 insertions(+), 4 deletions(-) diff --git a/packages/computer/src/agent-tools.ts b/packages/computer/src/agent-tools.ts index d32099fd02..8917f57ee4 100644 --- a/packages/computer/src/agent-tools.ts +++ b/packages/computer/src/agent-tools.ts @@ -131,6 +131,11 @@ type ExtractedComputerInitArgs = Partial< AgentBehaviorInitArgs >; +export interface ComputerMidsceneToolsOptions { + /** Keep CLI-owned Xvfb alive until process exit so Xlib clients stay valid. */ + keepXvfbAliveUntilProcessExit?: boolean; +} + function adaptComputerInitArgs( extracted: ExtractedComputerInitArgs | undefined, ): ComputerInitArgs | undefined { @@ -186,6 +191,10 @@ export class ComputerMidsceneTools extends BaseMidsceneTools< > { private lastInitArgsSignature?: string; + constructor(private readonly options: ComputerMidsceneToolsOptions = {}) { + super(); + } + protected getCliReportSessionName() { return 'midscene-computer'; } @@ -246,6 +255,9 @@ export class ComputerMidsceneTools extends BaseMidsceneTools< ...(displayId ? { displayId } : {}), ...(headless !== undefined ? { headless } : {}), ...(keyboardTypeDelay !== undefined ? { keyboardTypeDelay } : {}), + ...(this.options.keepXvfbAliveUntilProcessExit + ? { keepXvfbAliveUntilProcessExit: true } + : {}), ...(extractAgentBehaviorInitArgs(opts) ?? {}), ...(reportOptions ?? {}), }; diff --git a/packages/computer/src/agent.ts b/packages/computer/src/agent.ts index 5d07ef91e5..fb90329be7 100644 --- a/packages/computer/src/agent.ts +++ b/packages/computer/src/agent.ts @@ -33,6 +33,7 @@ function createLocalComputerDevice( keyboardDriver: opts?.keyboardDriver, headless: opts?.headless, xvfbResolution: opts?.xvfbResolution, + keepXvfbAliveUntilProcessExit: opts?.keepXvfbAliveUntilProcessExit, }); } diff --git a/packages/computer/src/cli.ts b/packages/computer/src/cli.ts index ccd732b4cd..e696b45c5a 100644 --- a/packages/computer/src/cli.ts +++ b/packages/computer/src/cli.ts @@ -3,7 +3,9 @@ import { reportCLIError, runToolsCLI } from '@midscene/shared/cli'; import { ComputerMidsceneTools } from './agent-tools'; declare const __VERSION__: string; -const tools = new ComputerMidsceneTools(); +const tools = new ComputerMidsceneTools({ + keepXvfbAliveUntilProcessExit: true, +}); runToolsCLI(tools, 'midscene-computer', { stripPrefix: 'computer_', version: __VERSION__, diff --git a/packages/computer/src/device.ts b/packages/computer/src/device.ts index 55e29ba187..3975c6523d 100644 --- a/packages/computer/src/device.ts +++ b/packages/computer/src/device.ts @@ -732,6 +732,14 @@ export interface ComputerDeviceOpt extends ComputerDeviceInputOpt { * Resolution for Xvfb virtual display (default '1920x1080x24') */ xvfbResolution?: string; + /** + * Keep a managed Xvfb server alive until process exit. + * + * @internal The foreground CLI uses this because libnut keeps a process-wide + * X11 connection open. Stopping Xvfb during normal CLI teardown would make + * Xlib terminate an otherwise successful command with exit code 1. + */ + keepXvfbAliveUntilProcessExit?: boolean; } export class ComputerDevice implements AbstractInterface { @@ -971,6 +979,9 @@ export class ComputerDevice implements AbstractInterface { this.xvfbInstance = await startXvfb({ resolution: this.options?.xvfbResolution, }); + if (this.options?.keepXvfbAliveUntilProcessExit) { + this.xvfbInstance.process.unref(); + } process.env.DISPLAY = this.xvfbInstance.display; debugDevice(`Xvfb started on display ${this.xvfbInstance.display}`); @@ -1591,11 +1602,13 @@ $g.Dispose(); $bmp.Dispose(); $ms.Dispose() this.destroyed = true; this.inputDriver.destroy(); - if (this.xvfbInstance) { + const keepXvfbAliveUntilProcessExit = + this.options?.keepXvfbAliveUntilProcessExit === true; + if (this.xvfbInstance && !keepXvfbAliveUntilProcessExit) { this.xvfbInstance.stop(); this.xvfbInstance = undefined; } - if (this.xvfbCleanup) { + if (this.xvfbCleanup && !keepXvfbAliveUntilProcessExit) { process.removeListener('exit', this.xvfbCleanup); this.xvfbCleanup = undefined; } diff --git a/packages/computer/tests/unit-test/agent-tools.test.ts b/packages/computer/tests/unit-test/agent-tools.test.ts index 11a7724388..c77b7d7250 100644 --- a/packages/computer/tests/unit-test/agent-tools.test.ts +++ b/packages/computer/tests/unit-test/agent-tools.test.ts @@ -77,6 +77,26 @@ describe('ComputerMidsceneTools', () => { }); }); + it('keeps CLI-owned Xvfb alive until process exit when configured', async () => { + const tools = new ComputerMidsceneTools({ + keepXvfbAliveUntilProcessExit: true, + }); + await tools.initTools(); + + const takeScreenshotTool = tools + .getToolDefinitions() + .find((tool) => tool.name === 'take_screenshot'); + + await takeScreenshotTool?.handler({ + computer: { headless: true }, + }); + + expect(agentFromComputer).toHaveBeenCalledWith({ + headless: true, + keepXvfbAliveUntilProcessExit: true, + }); + }); + it('passes common agent behavior args to local agent creation', async () => { const tools = new ComputerMidsceneTools(); await tools.initTools(); diff --git a/packages/computer/tests/unit-test/device.test.ts b/packages/computer/tests/unit-test/device.test.ts index ad58ad5306..a50c0cf6dc 100644 --- a/packages/computer/tests/unit-test/device.test.ts +++ b/packages/computer/tests/unit-test/device.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from '@rstest/core'; +import { describe, expect, it, rs } from '@rstest/core'; import { ComputerDevice, checkComputerEnvironment } from '../../src'; const needsDisplay = process.platform === 'linux' && !process.env.DISPLAY; @@ -15,6 +15,34 @@ describe('ComputerDevice', () => { expect(device).toBeDefined(); }); + it('leaves CLI-owned Xvfb for the process exit cleanup', async () => { + const device = new ComputerDevice({ + keepXvfbAliveUntilProcessExit: true, + }); + const stop = rs.fn(); + const deviceInternals = device as unknown as { + xvfbInstance?: { stop(): void }; + }; + deviceInternals.xvfbInstance = { stop }; + + await device.destroy(); + + expect(stop).not.toHaveBeenCalled(); + }); + + it('stops API-owned Xvfb during normal device teardown', async () => { + const device = new ComputerDevice({}); + const stop = rs.fn(); + const deviceInternals = device as unknown as { + xvfbInstance?: { stop(): void }; + }; + deviceInternals.xvfbInstance = { stop }; + + await device.destroy(); + + expect(stop).toHaveBeenCalledOnce(); + }); + it.skipIf(needsDisplay)('should list displays', async () => { const displays = await ComputerDevice.listDisplays(); expect(Array.isArray(displays)).toBe(true); From 76eb0deea6f64d404a743844c72bec4644f0a5b7 Mon Sep 17 00:00:00 2001 From: quanru Date: Fri, 28 Aug 2026 11:08:36 +0800 Subject: [PATCH 4/5] test(workflow): stop PTY writer after recorder exit --- .github/workflows/headless-linux.yml | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/.github/workflows/headless-linux.yml b/.github/workflows/headless-linux.yml index bd3a7491b9..d34ae7424f 100644 --- a/.github/workflows/headless-linux.yml +++ b/.github/workflows/headless-linux.yml @@ -197,11 +197,13 @@ jobs: # stdin is a real PTY here. The first Ctrl+C must be consumed only by # the Midscene child so pnpm stays alive while artifacts are saved. - printf '\003' - while true; do - sleep 1 - printf '\000' - done + printf '\003' + while true; do + sleep 1 + if ! printf '\000'; then + exit 0 + fi + done ) | timeout --signal=TERM 120s script --quiet --return --flush --command "$record_command" "$record_log" recorder_status=${PIPESTATUS[1]} set -e @@ -244,6 +246,13 @@ jobs: console.log(`COMPUTER_RECORDER_MANIFEST_OK frames=${record.frames.length}`); NODE + for _ in $(seq 1 20); do + find /tmp -maxdepth 1 -name '.X*-lock' -print | sort > "$after_locks" + if cmp --silent "$before_locks" "$after_locks"; then + break + fi + sleep 0.25 + done find /tmp -maxdepth 1 -name '.X*-lock' -print | sort > "$after_locks" cmp "$before_locks" "$after_locks" echo "COMPUTER_RECORDER_XVFB_CLEANUP_OK" From e3cbee754d7cc8e29fb2f8e43c5c849ef1ac25ee Mon Sep 17 00:00:00 2001 From: quanru Date: Fri, 28 Aug 2026 11:16:21 +0800 Subject: [PATCH 5/5] fix(computer): stop CLI Xvfb from detached monitor --- packages/computer/src/device.ts | 41 ++++++++------ packages/computer/src/xvfb.ts | 55 +++++++++++++++++++ .../computer/tests/unit-test/xvfb.test.ts | 32 +++++++++++ 3 files changed, 111 insertions(+), 17 deletions(-) diff --git a/packages/computer/src/device.ts b/packages/computer/src/device.ts index 3975c6523d..bfca125aa5 100644 --- a/packages/computer/src/device.ts +++ b/packages/computer/src/device.ts @@ -31,6 +31,7 @@ import { checkXvfbInstalled, createXvfbSignalCleanup, needsXvfb, + scheduleXvfbStopAfterProcessExit, startXvfb, } from './xvfb'; @@ -980,24 +981,26 @@ export class ComputerDevice implements AbstractInterface { resolution: this.options?.xvfbResolution, }); if (this.options?.keepXvfbAliveUntilProcessExit) { - this.xvfbInstance.process.unref(); + scheduleXvfbStopAfterProcessExit(this.xvfbInstance); } process.env.DISPLAY = this.xvfbInstance.display; debugDevice(`Xvfb started on display ${this.xvfbInstance.display}`); - // Clean up Xvfb on process exit (stored for removal in destroy()) - this.xvfbCleanup = () => { - if (this.xvfbInstance) { - this.xvfbInstance.stop(); - this.xvfbInstance = undefined; - } - }; - this.xvfbSignalCleanup = createXvfbSignalCleanup(() => - this.xvfbCleanup?.(), - ); - process.on('exit', this.xvfbCleanup); - process.on('SIGINT', this.xvfbSignalCleanup); - process.on('SIGTERM', this.xvfbSignalCleanup); + if (!this.options?.keepXvfbAliveUntilProcessExit) { + // Clean up SDK-owned Xvfb during device teardown or process exit. + this.xvfbCleanup = () => { + if (this.xvfbInstance) { + this.xvfbInstance.stop(); + this.xvfbInstance = undefined; + } + }; + this.xvfbSignalCleanup = createXvfbSignalCleanup(() => + this.xvfbCleanup?.(), + ); + process.on('exit', this.xvfbCleanup); + process.on('SIGINT', this.xvfbSignalCleanup); + process.on('SIGTERM', this.xvfbSignalCleanup); + } } // Load libnut on first connect @@ -1024,7 +1027,9 @@ Available Displays: ${displays.length > 0 ? displays.map((d) => d.name).join(', } catch (error) { // Clean up Xvfb on connection failure if (this.xvfbInstance) { - this.xvfbInstance.stop(); + if (!this.options?.keepXvfbAliveUntilProcessExit) { + this.xvfbInstance.stop(); + } this.xvfbInstance = undefined; } if (this.xvfbCleanup) { @@ -1604,8 +1609,10 @@ $g.Dispose(); $bmp.Dispose(); $ms.Dispose() const keepXvfbAliveUntilProcessExit = this.options?.keepXvfbAliveUntilProcessExit === true; - if (this.xvfbInstance && !keepXvfbAliveUntilProcessExit) { - this.xvfbInstance.stop(); + if (this.xvfbInstance) { + if (!keepXvfbAliveUntilProcessExit) { + this.xvfbInstance.stop(); + } this.xvfbInstance = undefined; } if (this.xvfbCleanup && !keepXvfbAliveUntilProcessExit) { diff --git a/packages/computer/src/xvfb.ts b/packages/computer/src/xvfb.ts index 619b5785d7..6f392bd0c9 100644 --- a/packages/computer/src/xvfb.ts +++ b/packages/computer/src/xvfb.ts @@ -20,6 +20,61 @@ export interface XvfbInstance { stop(): void; } +const xvfbCleanupMonitorScript = String.raw` +const parentPid = Number(process.argv[1]); +const xvfbPid = Number(process.argv[2]); +const timer = setInterval(() => { + try { + process.kill(xvfbPid, 0); + } catch { + clearInterval(timer); + process.exit(0); + } + try { + process.kill(parentPid, 0); + return; + } catch { + // The owner is gone, so its X11 clients can no longer receive XIO errors. + } + try { + process.kill(xvfbPid, 'SIGTERM'); + } catch { + // Xvfb may have already exited. + } + clearInterval(timer); +}, 100); +`; + +/** + * Let a detached monitor stop Xvfb only after the owning process has exited. + * + * libnut keeps a process-wide X11 connection open and exposes no close API. + * Killing Xvfb from that same process makes Xlib call exit(1), even after a + * successful CLI command. The monitor runs outside the owner, waits until its + * X11 sockets have closed with process exit, and then stops the server. + */ +export function scheduleXvfbStopAfterProcessExit( + instance: XvfbInstance, + parentPid = process.pid, +): ChildProcess { + const xvfbPid = instance.process.pid; + if (!xvfbPid) { + throw new Error('Cannot schedule Xvfb cleanup before its process starts'); + } + + const monitor = spawn( + process.execPath, + ['-e', xvfbCleanupMonitorScript, String(parentPid), String(xvfbPid)], + { detached: true, stdio: 'ignore' }, + ); + monitor.on('error', (error) => { + debugXvfb(`Xvfb cleanup monitor failed: ${error.message}`); + }); + instance.process.unref(); + monitor.unref(); + return monitor; +} + /** * Keep Xvfb alive while the foreground recorder handles a termination signal * and saves its artifact. Other signal listeners do not defer cleanup. diff --git a/packages/computer/tests/unit-test/xvfb.test.ts b/packages/computer/tests/unit-test/xvfb.test.ts index 5f8fad10aa..61645a2e33 100644 --- a/packages/computer/tests/unit-test/xvfb.test.ts +++ b/packages/computer/tests/unit-test/xvfb.test.ts @@ -1,3 +1,4 @@ +import { spawn } from 'node:child_process'; import { EventEmitter } from 'node:events'; import { existsSync } from 'node:fs'; import { @@ -10,6 +11,7 @@ import { createXvfbSignalCleanup, findAvailableDisplay, needsXvfb, + scheduleXvfbStopAfterProcessExit, } from '../../src/xvfb'; rs.mock('node:fs', () => ({ @@ -91,6 +93,36 @@ describe('checkXvfbInstalled', () => { }); }); +describe('scheduleXvfbStopAfterProcessExit', () => { + it('unrefs Xvfb and a detached process-exit monitor', () => { + const xvfbUnref = rs.fn(); + const monitorUnref = rs.fn(); + const monitorOn = rs.fn(); + rs.mocked(spawn).mockReturnValueOnce({ + on: monitorOn, + unref: monitorUnref, + } as never); + + scheduleXvfbStopAfterProcessExit( + { + display: ':99', + process: { pid: 4321, unref: xvfbUnref } as never, + stop: rs.fn(), + }, + 1234, + ); + + expect(spawn).toHaveBeenCalledWith( + process.execPath, + ['-e', expect.any(String), '1234', '4321'], + { detached: true, stdio: 'ignore' }, + ); + expect(monitorOn).toHaveBeenCalledWith('error', expect.any(Function)); + expect(xvfbUnref).toHaveBeenCalledOnce(); + expect(monitorUnref).toHaveBeenCalledOnce(); + }); +}); + describe('createXvfbSignalCleanup', () => { it('cleans up when the host only has unrelated SIGINT listeners', () => { const source = new EventEmitter();