diff --git a/.bazelrc b/.bazelrc index 39bceea20986c..8c40b86e5d8ff 100644 --- a/.bazelrc +++ b/.bazelrc @@ -154,8 +154,14 @@ test:node_debug --test_output=streamed --test_strategy=exclusive --test_timeout= test:ruby_debug --test_output=streamed --test_env=RUBY_DEBUG_FORK_MODE=parent --run_under="@bundle//bin:rdbg --nonstop --open --command" +# @llvm defaults to 14.0. +build --macos_minimum_os=11.0 + build:release --stamp build:release --compilation_mode=opt +# Match [profile.release] in rust/Cargo.toml, which Bazel doesn't read. +build:release --@rules_rust//rust/settings:lto=fat +build:release --@rules_rust//rust/settings:extra_rustc_flags=-Copt-level=z,-Ccodegen-units=1,-Cpanic=abort,-Cstrip=symbols # As regular `release` but all the build work happens on the RBE build:rbe_release --config=release diff --git a/.github/workflows/check-authorization.yml b/.github/workflows/check-authorization.yml new file mode 100644 index 0000000000000..3b22b740b6bdf --- /dev/null +++ b/.github/workflows/check-authorization.yml @@ -0,0 +1,19 @@ +name: Check Authorization + +permissions: {} + +on: + workflow_call: + +jobs: + check-authorization: + name: Check Authorization + runs-on: ubuntu-latest + steps: + - name: Fail for unauthorized actor + if: ${{ !contains(fromJSON('["AutomatedTester","selenium-ci","jimevans","p0deje","titusfortner","bonigarcia","diemol","pujagani","harsha509"]'), github.actor) }} + env: + ACTOR: ${{ github.actor }} + run: | + echo "::error::@${ACTOR} is not authorized to run this workflow" + exit 1 diff --git a/.github/workflows/ci-ruby.yml b/.github/workflows/ci-ruby.yml index aa20c4e12368c..8b6e2801a535b 100644 --- a/.github/workflows/ci-ruby.yml +++ b/.github/workflows/ci-ruby.yml @@ -57,6 +57,7 @@ jobs: ruby-version: ${{ matrix.ruby-version }} run: > bazel test + --build_tests_only --keep_going --test_size_filters small //rb/... @@ -82,6 +83,7 @@ jobs: run: > [ -s bazel-targets-rb.txt ] || echo //rb/... > bazel-targets-rb.txt; ./scripts/github-actions/bazel-test-if-targets.sh + --build_tests_only --keep_going --flaky_test_attempts 2 --local_test_jobs 1 @@ -114,6 +116,7 @@ jobs: rerun-with-debug: true run: > bazel test + --build_tests_only --keep_going --flaky_test_attempts 2 --local_test_jobs 1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c9569c386fe48..9237d8e7bdcda 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,37 +25,46 @@ jobs: if: (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/trunk' uses: ./.github/workflows/ci-build-index.yml + targets-mode: + name: Targets Mode + runs-on: ubuntu-latest + outputs: + mode: ${{ steps.mode.outputs.mode }} + steps: + - name: Decide whether to compute affected targets + id: mode + run: | + if [ "${{ github.event.repository.fork }}" = "true" ] || [ "${{ startsWith(github.head_ref, 'renovate/') }}" = "true" ]; then + mode=none + elif { [ "${{ github.event_name }}" != "pull_request" ] && [ "${{ github.event_name }}" != "push" ]; } || [ "${{ startsWith(github.head_ref, 'release-preparation-') }}" = "true" ]; then + mode=all + else + mode=check + fi + echo "mode=$mode" >> "$GITHUB_OUTPUT" + check: name: Check Targets - if: > - github.event.repository.fork == false && - (startsWith(github.head_ref, 'renovate/') != true || github.event_name == 'workflow_call') + needs: targets-mode + if: needs.targets-mode.outputs.mode == 'check' uses: ./.github/workflows/bazel.yml with: name: Check Targets cache-name: bazel-test-file-index run: | - if [ "${{ github.event_name }}" == "schedule" ] || \ - [ "${{ github.event_name }}" == "workflow_call" ] || \ - [ "${{ github.event_name }}" == "workflow_dispatch" ] || \ - [ "${{ startsWith(github.head_ref, 'release-preparation-') }}" == "true" ]; then - echo "Running all targets for ${{ github.event_name }} event" - echo "//java/... //py/... //rb/... //dotnet/... //rust/... //javascript/..." > bazel-targets.txt + if [ -n "${{ github.event.pull_request.base.sha }}" ]; then + BASE_SHA="HEAD^1" + elif [ "${{ github.ref }}" = "refs/heads/trunk" ]; then + BASE_SHA="${{ github.event.before }}" else - if [ -n "${{ github.event.pull_request.base.sha }}" ]; then - BASE_SHA="HEAD^1" - elif [ "${{ github.ref }}" = "refs/heads/trunk" ]; then - BASE_SHA="${{ github.event.before }}" - else - ahead=$(gh api "repos/${{ github.repository }}/compare/trunk...${{ github.ref_name }}" --jq .ahead_by) - git fetch --no-tags --depth=$((ahead + 1)) origin "${{ github.ref_name }}" - BASE_SHA="HEAD~$ahead" - fi - if git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then - ./go bazel:affected_targets "${BASE_SHA}..HEAD" bazel-test-file-index - else - ./go bazel:affected_targets bazel-test-file-index - fi + ahead=$(gh api "repos/${{ github.repository }}/compare/trunk...${{ github.ref_name }}" --jq .ahead_by) + git fetch --no-tags --depth=$((ahead + 1)) origin "${{ github.ref_name }}" + BASE_SHA="HEAD~$ahead" + fi + if git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then + ./go bazel:affected_targets "${BASE_SHA}..HEAD" bazel-test-file-index + else + ./go bazel:affected_targets bazel-test-file-index fi artifact-name: check-targets artifact-path: | @@ -64,7 +73,8 @@ jobs: read-targets: name: Read Targets - needs: check + needs: [targets-mode, check] + if: "!cancelled() && (needs.targets-mode.outputs.mode == 'all' || needs.check.result == 'success')" runs-on: ubuntu-latest outputs: java: ${{ steps.read.outputs.java }} @@ -77,9 +87,13 @@ jobs: compile-manager: ${{ steps.read.outputs.compile-manager }} steps: - name: Download targets + if: needs.targets-mode.outputs.mode == 'check' uses: actions/download-artifact@v8 with: name: check-targets + - name: Use every target + if: needs.targets-mode.outputs.mode == 'all' + run: echo "//java/... //py/... //rb/... //dotnet/... //rust/... //javascript/..." > bazel-targets.txt - name: Read targets id: read env: @@ -194,7 +208,7 @@ jobs: ci-success: name: CI Success if: always() - needs: [check, read-targets, dotnet, java, grid, python, ruby, rust, javascript] + needs: [targets-mode, check, read-targets, dotnet, java, grid, python, ruby, rust, javascript] runs-on: ubuntu-latest steps: - name: Verify required jobs succeeded diff --git a/.github/workflows/get-approval.yml b/.github/workflows/get-approval.yml deleted file mode 100644 index 794bb11cda7e6..0000000000000 --- a/.github/workflows/get-approval.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: Get Approval - -permissions: {} - -on: - workflow_call: - inputs: - title: - description: 'Notification title' - required: true - type: string - message: - description: 'Notification message' - required: true - type: string - secrets: - SLACK_WEBHOOK_URL: - required: true - -jobs: - needs-authorization: - name: Check Authorization - runs-on: ubuntu-latest - outputs: - required: ${{ !contains(fromJSON('["AutomatedTester","selenium-ci","jimevans","p0deje","titusfortner","bonigarcia","diemol","pujagani","harsha509"]'), github.actor) }} - steps: - - run: echo "Triggered by ${{ github.actor }}" - - notify: - name: Send Notification - needs: needs-authorization - if: needs.needs-authorization.outputs.required == 'true' - runs-on: ubuntu-latest - steps: - - name: Slack Notification - uses: rtCamp/action-slack-notify@v2 - env: - SLACK_ICON_EMOJI: ':raised_hand:' - SLACK_COLOR: warning - SLACK_CHANNEL: selenium-tlc - SLACK_USERNAME: GitHub Workflows - SLACK_TITLE: ${{ inputs.title }} - SLACK_MESSAGE: "${{ inputs.message }} (requested by @${{ github.actor }})" - MSG_MINIMAL: actions url - SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_URL }} - - authorize: - name: Authorize - needs: [needs-authorization, notify] - if: needs.needs-authorization.outputs.required == 'true' - runs-on: ubuntu-latest - environment: production - steps: - - name: Approved - run: echo "Approved" diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml index 2c0c0a26145d3..c1893ea899863 100644 --- a/.github/workflows/pre-release.yml +++ b/.github/workflows/pre-release.yml @@ -85,12 +85,11 @@ jobs: needs: [parse-tag, verify-cdp] uses: ./.github/workflows/restrict-trunk.yml with: - restrict: true message: | Trunk branch has been restricted for release preparation (${{ inputs.tag }}). To abandon the release, close the release preparation PR without merging and trunk will unlock automatically. - If trunk is still locked, run the "Unlock Trunk" workflow: - https://github.com/${{ github.repository }}/actions/workflows/unlock-trunk.yml + If trunk is still locked, run the "Manage Trunk Restrictions" workflow, which unlocks by default: + https://github.com/${{ github.repository }}/actions/workflows/restrict-trunk.yml secrets: SELENIUM_CI_TOKEN: ${{ secrets.SELENIUM_CI_TOKEN }} SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} @@ -302,8 +301,7 @@ jobs: if: always() && needs.restrict-trunk.result == 'success' && needs.create-pr.result != 'success' uses: ./.github/workflows/restrict-trunk.yml with: - restrict: false - skip_approval: true + unlock: true secrets: SELENIUM_CI_TOKEN: ${{ secrets.SELENIUM_CI_TOKEN }} SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index feccae9dd8b07..9ca3543596907 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,7 +32,7 @@ jobs: github.event.pull_request.merged == false uses: ./.github/workflows/restrict-trunk.yml with: - restrict: false + unlock: true message: "Trunk has been unlocked: ${{ github.event.pull_request.head.ref }} was closed without merging." secrets: SELENIUM_CI_TOKEN: ${{ secrets.SELENIUM_CI_TOKEN }} @@ -71,19 +71,14 @@ jobs: with: tag: ${{ needs.extract-tag.outputs.tag }} - get-approval: - name: Get Approval - needs: parse-tag - uses: ./.github/workflows/get-approval.yml - with: - title: Release approval required - message: "Approval is required to publish ${{ needs.parse-tag.outputs.tag }}." - secrets: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + check-authorization: + name: Check Authorization + needs: extract-tag + uses: ./.github/workflows/check-authorization.yml create-tag: name: Create Tag - needs: [parse-tag, get-approval] + needs: [parse-tag, check-authorization] runs-on: ubuntu-latest permissions: contents: write @@ -104,7 +99,7 @@ jobs: github-release-draft: name: GitHub Release Draft - needs: [parse-tag, get-approval] + needs: [parse-tag, check-authorization] if: needs.parse-tag.outputs.language == 'all' runs-on: ubuntu-latest permissions: @@ -143,7 +138,7 @@ jobs: publish: name: Build and Publish ${{ matrix.language }} - needs: [parse-tag, get-approval] + needs: [parse-tag, check-authorization] permissions: contents: read id-token: write @@ -169,7 +164,7 @@ jobs: build-python: name: Build Python - needs: [parse-tag, get-approval] + needs: [parse-tag, check-authorization] if: needs.parse-tag.outputs.language == 'all' || needs.parse-tag.outputs.language == 'python' uses: ./.github/workflows/bazel.yml with: @@ -282,7 +277,7 @@ jobs: reset-version: name: Generate Nightly Versions - needs: [parse-tag, get-approval] + needs: [parse-tag, check-authorization] uses: ./.github/workflows/bazel.yml with: name: Reset Versions @@ -312,7 +307,7 @@ jobs: # The build can run any time; only publish-nightly-grid has to sit between those two jobs. nightly-grid: name: Build Nightly Grid - needs: [parse-tag, get-approval] + needs: [parse-tag, check-authorization] if: needs.parse-tag.outputs.language == 'all' uses: ./.github/workflows/bazel.yml with: @@ -349,7 +344,7 @@ jobs: (needs.mirror.result == 'success' || needs.mirror.result == 'skipped') uses: ./.github/workflows/restrict-trunk.yml with: - restrict: false + unlock: true secrets: SELENIUM_CI_TOKEN: ${{ secrets.SELENIUM_CI_TOKEN }} SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} @@ -357,8 +352,8 @@ jobs: on-release-complete: name: On Release Complete runs-on: ubuntu-latest - needs: [extract-tag, publish, publish-python, docs, create-tag, github-release-draft, github-release-publish, reset-version, update-version, publish-nightly-grid, mirror, verify, unrestrict-trunk] - if: always() && !cancelled() && needs.extract-tag.result != 'skipped' + needs: [extract-tag, check-authorization, publish, publish-python, docs, create-tag, github-release-draft, github-release-publish, reset-version, update-version, publish-nightly-grid, mirror, verify, unrestrict-trunk] + if: always() && !cancelled() && needs.extract-tag.result != 'skipped' && needs.check-authorization.result != 'failure' steps: - uses: actions/checkout@v6 - name: Slack Notification diff --git a/.github/workflows/restrict-trunk.yml b/.github/workflows/restrict-trunk.yml index 28dde6011c989..a2cf9eb71e33e 100644 --- a/.github/workflows/restrict-trunk.yml +++ b/.github/workflows/restrict-trunk.yml @@ -9,26 +9,22 @@ concurrency: on: workflow_dispatch: inputs: - restrict: - description: 'Restrict trunk branch' - required: true + unlock: + description: 'Unlock trunk (uncheck to lock it)' type: boolean + default: true workflow_call: inputs: - restrict: - description: 'Restrict trunk branch' - required: true + unlock: + description: 'Unlock trunk instead of locking it' + required: false type: boolean + default: false message: description: 'Slack message override (optional)' required: false type: string default: '' - skip_approval: - description: 'Skip the approval step (used for automatic failure recovery)' - required: false - type: boolean - default: false secrets: SELENIUM_CI_TOKEN: required: true @@ -36,21 +32,16 @@ on: required: true jobs: - get-approval: - name: Get Approval - if: ${{ !inputs.skip_approval && (inputs.restrict || github.event_name == 'workflow_dispatch') }} - uses: ./.github/workflows/get-approval.yml - with: - title: ${{ inputs.restrict && 'Trunk branch locking' || 'Trunk branch unlocking' }} - message: ${{ inputs.restrict && 'Approval is required to begin release process.' || 'Approval is required to unlock trunk.' }} - secrets: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + check-authorization: + name: Check Authorization + if: ${{ !inputs.unlock || github.event_name == 'workflow_dispatch' }} + uses: ./.github/workflows/check-authorization.yml manage-trunk: name: Manage Trunk Branch - needs: [get-approval] + needs: [check-authorization] runs-on: ubuntu-latest - if: always() && (needs.get-approval.result == 'success' || inputs.skip_approval || (!inputs.restrict && github.event_name != 'workflow_dispatch')) + if: ${{ !cancelled() && needs.check-authorization.result != 'failure' }} env: GH_TOKEN: ${{ secrets.SELENIUM_CI_TOKEN }} GH_REPO: ${{ github.repository }} @@ -61,7 +52,7 @@ jobs: sparse-checkout: .github/rulesets sparse-checkout-cone-mode: false - name: Create release rulesets - if: inputs.restrict + if: ${{ !inputs.unlock }} shell: bash run: | set -euo pipefail @@ -76,7 +67,7 @@ jobs: fi done - name: Delete release rulesets - if: ${{ !inputs.restrict }} + if: inputs.unlock shell: bash run: | set -euo pipefail @@ -98,11 +89,11 @@ jobs: - name: Slack Notification uses: rtCamp/action-slack-notify@v2 env: - SLACK_ICON_EMOJI: ${{ inputs.restrict && ':lock:' || ':unlock:' }} - SLACK_COLOR: ${{ inputs.restrict && 'danger' || 'good' }} + SLACK_ICON_EMOJI: ${{ inputs.unlock && ':unlock:' || ':lock:' }} + SLACK_COLOR: ${{ inputs.unlock && 'good' || 'danger' }} SLACK_CHANNEL: selenium-tlc SLACK_USERNAME: GitHub Workflows - SLACK_TITLE: ${{ inputs.restrict && 'Trunk locked' || 'Trunk unlocked' }} - SLACK_MESSAGE: ${{ inputs.message != '' && inputs.message || (inputs.restrict && 'Trunk has been locked.' || 'Trunk has been unlocked.') }} + SLACK_TITLE: ${{ inputs.unlock && 'Trunk unlocked' || 'Trunk locked' }} + SLACK_MESSAGE: ${{ inputs.message != '' && inputs.message || (inputs.unlock && 'Trunk has been unlocked.' || 'Trunk has been locked.') }} MSG_MINIMAL: actions url SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_URL }} diff --git a/.github/workflows/unlock-trunk.yml b/.github/workflows/unlock-trunk.yml deleted file mode 100644 index 75900410a3701..0000000000000 --- a/.github/workflows/unlock-trunk.yml +++ /dev/null @@ -1,16 +0,0 @@ -name: Unlock Trunk - -on: - workflow_dispatch: - -permissions: {} - -jobs: - unrestrict-trunk: - name: Unrestrict Trunk Branch - uses: ./.github/workflows/restrict-trunk.yml - with: - restrict: false - secrets: - SELENIUM_CI_TOKEN: ${{ secrets.SELENIUM_CI_TOKEN }} - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} diff --git a/MODULE.bazel b/MODULE.bazel index 6dcfd432f0a99..0035e9f855e30 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -50,6 +50,11 @@ single_version_override( ], ) +# Build the `*-sys` C sources with the LLVM cc toolchain so they cross-compile to musl. +bazel_dep(name = "bzip2", version = "1.0.8.bcr.3") +bazel_dep(name = "xz", version = "5.4.5.bcr.8") +bazel_dep(name = "zstd", version = "1.5.7.bcr.1") + single_version_override( module_name = "rules_jvm_external", patch_strip = 1, @@ -429,6 +434,14 @@ llvm_toolchains.exec( arch = "x86_64", os = "macos", ) +llvm_toolchains.exec( + arch = "x86_64", + os = "linux", +) +llvm_toolchains.exec( + arch = "aarch64", + os = "linux", +) use_repo(llvm_toolchains, "llvm_toolchains") # Registered only for Windows in .bazelrc so it cannot shadow @llvm elsewhere. @@ -446,23 +459,36 @@ crate.from_cargo( "aarch64-apple-darwin", "aarch64-pc-windows-msvc", "aarch64-unknown-linux-gnu", + "aarch64-unknown-linux-musl", "x86_64-apple-darwin", + "x86_64-pc-windows-gnullvm", "x86_64-pc-windows-msvc", "x86_64-unknown-linux-gnu", + "x86_64-unknown-linux-musl", ], ) crate.annotation( crate = "bzip2-sys", - gen_build_script = "on", + gen_build_script = "off", + deps = ["@bzip2//:bz2"], ) crate.annotation( crate = "lzma-sys", - gen_build_script = "on", + gen_build_script = "off", + deps = ["@xz//:lzma"], ) crate.annotation( crate = "zstd-sys", - gen_build_script = "on", + gen_build_script = "off", + deps = ["@zstd"], ) + +inject_repo(crate, "bzip2") + +inject_repo(crate, "xz") + +inject_repo(crate, "zstd") + use_repo(crate, "crates") selenium_manager_artifacts = use_extension("//common:selenium_manager.bzl", "selenium_manager_artifacts") diff --git a/common/BUILD.bazel b/common/BUILD.bazel index 64570467d1e18..1b7171d07d22e 100644 --- a/common/BUILD.bazel +++ b/common/BUILD.bazel @@ -20,6 +20,7 @@ string_flag( values = [ "download", "host", + "all", ], ) @@ -30,6 +31,13 @@ config_setting( }, ) +config_setting( + name = "manager_all", + flag_values = { + ":manager": "all", + }, +) + bool_flag( name = "headless", build_setting_default = False, diff --git a/common/manager/BUILD.bazel b/common/manager/BUILD.bazel index 9a852a0ad0401..5fc994fc2433f 100644 --- a/common/manager/BUILD.bazel +++ b/common/manager/BUILD.bazel @@ -12,6 +12,7 @@ package( alias( name = "selenium-manager-linux-x86_64", actual = select({ + "//common:manager_all": "//rust:_xc_file-linux-x86_64", "//common:manager_host": "//rust:selenium-manager-linux-x86_64", "//conditions:default": "@download_sm_linux_x86_64//file", }), @@ -20,6 +21,7 @@ alias( alias( name = "selenium-manager-linux-arm64", actual = select({ + "//common:manager_all": "//rust:_xc_file-linux-arm64", "//common:manager_host": "//rust:selenium-manager-linux-arm64", "//conditions:default": "@download_sm_linux_arm64//file", }), @@ -28,6 +30,7 @@ alias( alias( name = "selenium-manager-macos", actual = select({ + "//common:manager_all": "//rust:_xc_file-macos", "//common:manager_host": "//rust:selenium-manager-macos", "//conditions:default": "@download_sm_macos//file", }), @@ -36,6 +39,7 @@ alias( alias( name = "selenium-manager-windows", actual = select({ + "//common:manager_all": "//rust:_xc_file-windows", "//common:manager_host": "//rust:selenium-manager-windows", "//conditions:default": "@download_sm_windows//file", }), diff --git a/common/selenium_manager.bzl b/common/selenium_manager.bzl index 381a77027f246..a9a23eb05a127 100644 --- a/common/selenium_manager.bzl +++ b/common/selenium_manager.bzl @@ -8,40 +8,40 @@ def selenium_manager(): name = "download_sm_linux_x86_64", executable = True, sha256 = "3bad0443dbf99e7268ee9c10cce57c12afd6dd8433f57bf103aec196afbb65b3", - url = "https://github.com/SeleniumHQ/selenium_manager_artifacts/releases/download/selenium-manager-231a1fa/selenium-manager-linux-x86_64", + url = "https://github.com/SeleniumHQ/selenium_manager_artifacts/releases/download/selenium-manager-df880d7/selenium-manager-linux-x86_64", ) http_file( name = "download_sm_linux_arm64", executable = True, sha256 = "6218628f021bb52b93bf23a3c6f165a51674f61faa6377a5951dff177ca6cf4b", - url = "https://github.com/SeleniumHQ/selenium_manager_artifacts/releases/download/selenium-manager-231a1fa/selenium-manager-linux-arm64", + url = "https://github.com/SeleniumHQ/selenium_manager_artifacts/releases/download/selenium-manager-df880d7/selenium-manager-linux-arm64", ) http_file( name = "download_sm_macos", executable = True, sha256 = "77f80894c26591ee0ff08e26c34a558b40eab96686e9ef5b70645560569532f1", - url = "https://github.com/SeleniumHQ/selenium_manager_artifacts/releases/download/selenium-manager-231a1fa/selenium-manager-macos", + url = "https://github.com/SeleniumHQ/selenium_manager_artifacts/releases/download/selenium-manager-df880d7/selenium-manager-macos", ) http_file( name = "download_sm_windows", executable = True, - sha256 = "b64edc4dfcd91372fa05cc4640b0fac9824fd385b4769bdbadf33a84d1a92bad", - url = "https://github.com/SeleniumHQ/selenium_manager_artifacts/releases/download/selenium-manager-231a1fa/selenium-manager-windows.exe", + sha256 = "4f1eadc04d05068acbd809bd18267003b827a4ea60afd2bbeb9cab3c1169e5e0", + url = "https://github.com/SeleniumHQ/selenium_manager_artifacts/releases/download/selenium-manager-df880d7/selenium-manager-windows.exe", ) http_file( name = "download_sm_sbom", - sha256 = "8ac5ea4dd0087f3688899138da43634392484d451d802f6b0b256e4097505049", - url = "https://github.com/SeleniumHQ/selenium_manager_artifacts/releases/download/selenium-manager-231a1fa/selenium-manager.cdx.json", + sha256 = "5a6eb129adca3cdc3b2dc57c0ed39d8e33ba9880961c5e0ed767917fed883557", + url = "https://github.com/SeleniumHQ/selenium_manager_artifacts/releases/download/selenium-manager-df880d7/selenium-manager.cdx.json", ) http_file( name = "download_sm_notice", sha256 = "1282ec9b00345e42ca99f0709f65f2c81378ddb960cfc06f51b7b9cae9deb210", - url = "https://github.com/SeleniumHQ/selenium_manager_artifacts/releases/download/selenium-manager-231a1fa/selenium-manager-THIRD-PARTY-NOTICES.txt", + url = "https://github.com/SeleniumHQ/selenium_manager_artifacts/releases/download/selenium-manager-df880d7/selenium-manager-THIRD-PARTY-NOTICES.txt", ) def _selenium_manager_artifacts_impl(_ctx): diff --git a/rb/lib/selenium/webdriver/common.rb b/rb/lib/selenium/webdriver/common.rb index 0412195a74266..609dcc8788bac 100644 --- a/rb/lib/selenium/webdriver/common.rb +++ b/rb/lib/selenium/webdriver/common.rb @@ -94,6 +94,7 @@ require 'selenium/webdriver/common/takes_screenshot' require 'selenium/webdriver/common/driver' require 'selenium/webdriver/common/element' +require 'selenium/webdriver/common/web_extension' require 'selenium/webdriver/common/shadow_root' require 'selenium/webdriver/common/websocket_connection' require 'selenium/webdriver/common/child_process' diff --git a/rb/lib/selenium/webdriver/common/driver.rb b/rb/lib/selenium/webdriver/common/driver.rb index 3a9de28b24805..21ed1a240f7d1 100644 --- a/rb/lib/selenium/webdriver/common/driver.rb +++ b/rb/lib/selenium/webdriver/common/driver.rb @@ -277,6 +277,30 @@ def network @network ||= WebDriver::Network.new(bridge) end + # + # Installs a browser extension from an unpacked directory, a packed extension (.xpi/.crx/.zip), + # or base64-encoded bytes; works with remote (Grid) sessions. + # + # @note Chromium requires a BiDi session and installs only unpacked directories + # (SeleniumHQ/selenium#16541); Firefox falls back to the classic endpoint without BiDi. + # @param [String] path directory, packed extension, or base64-encoded bytes + # @return [WebExtension] the installed extension + # + + def install_web_extension(...) + bridge.install_web_extension(...) + end + + # + # Uninstalls a browser extension installed with {#install_web_extension}. + # + # @param [WebExtension] extension the extension returned by {#install_web_extension} + # + + def uninstall_web_extension(extension) + bridge.uninstall_web_extension(extension.id) + end + #-------------------------------- sugar -------------------------------- # diff --git a/rb/lib/selenium/webdriver/common/driver_extensions/has_addons.rb b/rb/lib/selenium/webdriver/common/driver_extensions/has_addons.rb index dbe6def2672a1..c3fcc04a642a2 100644 --- a/rb/lib/selenium/webdriver/common/driver_extensions/has_addons.rb +++ b/rb/lib/selenium/webdriver/common/driver_extensions/has_addons.rb @@ -30,6 +30,7 @@ module HasAddons # def install_addon(path, temporary = nil) + WebDriver.logger.deprecate('#install_addon', '#install_web_extension', id: :install_addon) @bridge.install_addon(path, temporary) end @@ -40,6 +41,7 @@ def install_addon(path, temporary = nil) # def uninstall_addon(id) + WebDriver.logger.deprecate('#uninstall_addon', '#uninstall_web_extension', id: :uninstall_addon) @bridge.uninstall_addon(id) end end # HasAddons diff --git a/rb/lib/selenium/webdriver/common/web_extension.rb b/rb/lib/selenium/webdriver/common/web_extension.rb new file mode 100644 index 0000000000000..c7dca09349936 --- /dev/null +++ b/rb/lib/selenium/webdriver/common/web_extension.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +module Selenium + module WebDriver + # + # A browser extension installed via Driver#install_web_extension. + # Wraps the identifier the browser assigned; pass it to Driver#uninstall_web_extension. + # + class WebExtension + # + # @return [String] identifier assigned to the extension by the browser + # + + attr_reader :id + + # + # @api private + # + + def initialize(id) + @id = id + end + end # WebExtension + end # WebDriver +end # Selenium diff --git a/rb/lib/selenium/webdriver/common/websocket_connection.rb b/rb/lib/selenium/webdriver/common/websocket_connection.rb index 4b98c17e958cb..d83a0fc22275c 100644 --- a/rb/lib/selenium/webdriver/common/websocket_connection.rb +++ b/rb/lib/selenium/webdriver/common/websocket_connection.rb @@ -105,7 +105,7 @@ def send_cmd(**payload) id = next_id data = payload.merge(id: id) - WebDriver.logger.debug "WebSocket -> #{data}"[...MAX_LOG_MESSAGE_SIZE], id: :ws + WebDriver.logger.debug('WebSocket ->', id: :ws) { data.to_s[...MAX_LOG_MESSAGE_SIZE] } data = JSON.generate(data) out_frame = WebSocket::Frame::Outgoing::Client.new(version: ws.version, data: data, type: 'text') @@ -200,7 +200,7 @@ def process_frame(frame) msg = JSON.parse(message) @messages_mtx.synchronize { messages[msg['id']] = msg if msg.key?('id') } - WebDriver.logger.debug "WebSocket <- #{msg}"[...MAX_LOG_MESSAGE_SIZE], id: :ws + WebDriver.logger.debug('WebSocket <-', id: :ws) { msg.to_s[...MAX_LOG_MESSAGE_SIZE] } msg end diff --git a/rb/lib/selenium/webdriver/common/zipper.rb b/rb/lib/selenium/webdriver/common/zipper.rb index 2197068effcf0..c328d0665eb34 100644 --- a/rb/lib/selenium/webdriver/common/zipper.rb +++ b/rb/lib/selenium/webdriver/common/zipper.rb @@ -56,27 +56,31 @@ def unzip(path) end def zip(path) - with_tmp_zip do |zip| - ::Find.find(path) do |file| - add_zip_entry zip, file, file.sub("#{path}/", '') unless File.directory?(file) - end + encode_zip(path, path) + end - zip.commit - File.open(zip.name, 'rb') { |io| Base64.strict_encode64 io.read } - end + # Keeps +path+ (a file or directory) as the archive's single top-level entry, unlike #zip + # which flattens a directory's contents; the Grid upload endpoint returns that one entry's path. + def zip_root(path) + encode_zip(path, File.dirname(path)) end - def zip_file(path) + # Backwards-compatible name for the file-only behavior #zip_root now subsumes. + alias zip_file zip_root + + private + + def encode_zip(path, base) with_tmp_zip do |zip| - add_zip_entry zip, path, File.basename(path) + ::Find.find(path) do |file| + add_zip_entry zip, file, file.sub("#{base}/", '') unless File.directory?(file) + end zip.commit File.open(zip.name, 'rb') { |io| Base64.strict_encode64 io.read } end end - private - def with_tmp_zip(&blk) # Don't use Tempfile since it lacks rb_file_s_rename permission on Windows. Dir.mktmpdir do |tmp_dir| diff --git a/rb/lib/selenium/webdriver/firefox/features.rb b/rb/lib/selenium/webdriver/firefox/features.rb index e20138ef7aa41..4a655e222e3ad 100644 --- a/rb/lib/selenium/webdriver/firefox/features.rb +++ b/rb/lib/selenium/webdriver/firefox/features.rb @@ -38,13 +38,7 @@ def commands(command) end def install_addon(path, temporary) - addon = if File.directory?(path) - Zipper.zip(path) - else - File.open(path, 'rb') { |crx_file| Base64.strict_encode64 crx_file.read } - end - - payload = {addon: addon} + payload = {addon: encode_extension(path)} payload[:temporary] = temporary unless temporary.nil? execute :install_addon, {}, payload end @@ -53,6 +47,23 @@ def uninstall_addon(id) execute :uninstall_addon, {}, {id: id} end + def install_web_extension(path, allow_private_browsing: nil, permanent: nil) + unless bidi? + temporary = !permanent unless permanent.nil? + options = {temporary: temporary, allowPrivateBrowsing: allow_private_browsing}.compact + return WebDriver::WebExtension.new(execute(:install_addon, {}, {addon: encode_extension(path), **options})) + end + + options = {allow_private_browsing:, permanent:}.compact + result = web_extension.moz.install(extension_data: web_extension_data(path), **options) + WebDriver::WebExtension.new(result.extension) + end + + def uninstall_web_extension(extension_id) + bidi? ? web_extension.uninstall(extension: extension_id) : uninstall_addon(extension_id) + nil + end + def full_screenshot execute :full_page_screenshot end diff --git a/rb/lib/selenium/webdriver/remote/bidi_bridge.rb b/rb/lib/selenium/webdriver/remote/bidi_bridge.rb index 70efdeece164f..339213af40d1b 100644 --- a/rb/lib/selenium/webdriver/remote/bidi_bridge.rb +++ b/rb/lib/selenium/webdriver/remote/bidi_bridge.rb @@ -45,6 +45,16 @@ def create_session(capabilities) end end + def install_web_extension(path) + result = web_extension.install(extension_data: web_extension_data(path)) + WebExtension.new(result.extension) + end + + def uninstall_web_extension(id) + web_extension.uninstall(extension: id) + nil + end + def get(url) browsing_context.navigate(context: window_handle, url: url, wait: readiness_state) nil @@ -91,6 +101,19 @@ def browsing_context @browsing_context ||= BiDi::Protocol::BrowsingContext.new(connection) end + def web_extension + @web_extension ||= BiDi::Protocol::WebExtension.new(connection) + end + + # A directory only resolves on the machine running the browser, so upload it to the remote + # end and reference the returned path; archives and base64 bytes travel inline. + def web_extension_data(path) + return web_extension.extension_base64_encoded(value: encode_extension(path)) unless File.directory?(path) + + path = upload(path) if respond_to?(:upload) + web_extension.extension_path(path: path) + end + def readiness_state READINESS_STATE.fetch(capabilities[:page_load_strategy] || 'normal') end diff --git a/rb/lib/selenium/webdriver/remote/bridge.rb b/rb/lib/selenium/webdriver/remote/bridge.rb index f45fde9e7a558..1f17cf0893ce5 100644 --- a/rb/lib/selenium/webdriver/remote/bridge.rb +++ b/rb/lib/selenium/webdriver/remote/bridge.rb @@ -593,14 +593,18 @@ def click_fedcm_dialog_button execute :click_fedcm_dialog_button, {}, {dialogButton: 'ConfirmIdpLoginContinue'} end - def bidi - msg = 'BiDi must be enabled by setting #web_socket_url to true in options class' - raise(WebDriver::Error::WebDriverError, msg) + def bidi(*) + raise WebDriver::Error::WebDriverError, + 'BiDi must be enabled by setting #web_socket_url to true in options class' end + alias connection bidi + alias web_extension bidi + alias install_web_extension bidi + alias uninstall_web_extension bidi + private :web_extension - def connection - msg = 'BiDi must be enabled by setting #web_socket_url to true in options class' - raise(WebDriver::Error::WebDriverError, msg) + def bidi? + !@bidi.nil? end def command_list @@ -609,6 +613,16 @@ def command_list private + def encode_extension(path) + if File.directory?(path) + Zipper.zip(path) + elsif File.file?(path) + File.open(path, 'rb') { |file| Base64.strict_encode64(file.read) } + else + path # already base64-encoded bytes + end + end + # # executes a command on the remote server. # diff --git a/rb/lib/selenium/webdriver/remote/features.rb b/rb/lib/selenium/webdriver/remote/features.rb index 8951f4a239365..e3af68575a55f 100644 --- a/rb/lib/selenium/webdriver/remote/features.rb +++ b/rb/lib/selenium/webdriver/remote/features.rb @@ -42,20 +42,21 @@ def commands(command) end def upload(local_file) - unless File.file?(local_file) - WebDriver.logger.error("File detector only works with files. #{local_file.inspect} isn`t a file!", - id: :file_detector) - raise Error::WebDriverError, "You are trying to upload something that isn't a file." - end - - execute :upload_file, {}, {file: Zipper.zip_file(local_file)} + execute :upload_file, {}, {file: Zipper.zip_root(local_file)} end def upload_if_necessary(keys) local_files = keys.first&.split("\n")&.filter_map { |key| @file_detector.call(Array(key)) } return keys unless local_files&.any? - keys = local_files.map { |local_file| upload(local_file) } + keys = local_files.map do |local_file| + unless File.file?(local_file) + WebDriver.logger.error("File detector only works with files. #{local_file.inspect} isn`t a file!", + id: :file_detector) + raise Error::WebDriverError, "You are trying to upload something that isn't a file." + end + upload(local_file) + end Array(keys.join("\n")) end diff --git a/rb/sig/interfaces/bridge.rbs b/rb/sig/interfaces/bridge.rbs index bba953138841c..0a4092474e3fe 100644 --- a/rb/sig/interfaces/bridge.rbs +++ b/rb/sig/interfaces/bridge.rbs @@ -18,4 +18,12 @@ interface _Bridge def execute: (untyped command, ?Hash[untyped, untyped] opts, ?untyped? command_hash) -> untyped + + def bidi?: () -> bool + + def web_extension: () -> Selenium::WebDriver::BiDi::Protocol::WebExtension + + def web_extension_data: (String path) -> untyped + + def encode_extension: (String path) -> String end diff --git a/rb/sig/lib/selenium/webdriver/common/driver.rbs b/rb/sig/lib/selenium/webdriver/common/driver.rbs index f7294f63ed787..202af58ee23d2 100644 --- a/rb/sig/lib/selenium/webdriver/common/driver.rbs +++ b/rb/sig/lib/selenium/webdriver/common/driver.rbs @@ -71,6 +71,10 @@ module Selenium def add_virtual_authenticator: (untyped options) -> VirtualAuthenticator + def install_web_extension: (String path, **untyped options) -> WebExtension + + def uninstall_web_extension: (WebExtension extension) -> void + alias first find_element alias all find_elements diff --git a/rb/sig/lib/selenium/webdriver/common/web_extension.rbs b/rb/sig/lib/selenium/webdriver/common/web_extension.rbs new file mode 100644 index 0000000000000..822eaf995ecfa --- /dev/null +++ b/rb/sig/lib/selenium/webdriver/common/web_extension.rbs @@ -0,0 +1,29 @@ +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + + +module Selenium + module WebDriver + class WebExtension + @id: String + + attr_reader id: String + + def initialize: (String id) -> void + end + end +end diff --git a/rb/sig/lib/selenium/webdriver/common/zipper.rbs b/rb/sig/lib/selenium/webdriver/common/zipper.rbs index d01306ecd81d5..2e80f11a747bd 100644 --- a/rb/sig/lib/selenium/webdriver/common/zipper.rbs +++ b/rb/sig/lib/selenium/webdriver/common/zipper.rbs @@ -27,10 +27,14 @@ module Selenium def self.zip: (untyped path) -> untyped - def self.zip_file: (untyped path) -> untyped + def self.zip_root: (untyped path) -> untyped + + alias self.zip_file self.zip_root private + def self.encode_zip: (untyped path, untyped base) -> untyped + def self.with_tmp_zip: () { () -> untyped } -> untyped def self.add_zip_entry: (untyped zip, untyped file, untyped entry_name) -> untyped diff --git a/rb/sig/lib/selenium/webdriver/firefox/features.rbs b/rb/sig/lib/selenium/webdriver/firefox/features.rbs index 50f679d1ebb14..7c407c9a78078 100644 --- a/rb/sig/lib/selenium/webdriver/firefox/features.rbs +++ b/rb/sig/lib/selenium/webdriver/firefox/features.rbs @@ -32,6 +32,10 @@ module Selenium def uninstall_addon: (untyped id) -> untyped + def install_web_extension: (String path, ?allow_private_browsing: bool?, ?permanent: bool?) -> Selenium::WebDriver::WebExtension + + def uninstall_web_extension: (String extension_id) -> void + def full_screenshot: () -> untyped def context=: (untyped context) -> untyped diff --git a/rb/sig/lib/selenium/webdriver/remote/bidi_bridge.rbs b/rb/sig/lib/selenium/webdriver/remote/bidi_bridge.rbs index 6c7ec99d4c1b0..624e5045b10e9 100644 --- a/rb/sig/lib/selenium/webdriver/remote/bidi_bridge.rbs +++ b/rb/sig/lib/selenium/webdriver/remote/bidi_bridge.rbs @@ -26,12 +26,18 @@ module Selenium @connection: untyped + @web_extension: BiDi::Protocol::WebExtension + attr_reader bidi: BiDi attr_reader connection: untyped def create_session: (untyped capabilities) -> void + def install_web_extension: (String path) -> WebExtension + + def uninstall_web_extension: (String id) -> void + def get: (String url) -> void def go_back: () -> void @@ -50,6 +56,10 @@ module Selenium def browsing_context: () -> BiDi::Protocol::BrowsingContext + def web_extension: () -> BiDi::Protocol::WebExtension + + def web_extension_data: (String path) -> untyped + def readiness_state: () -> Symbol end end diff --git a/rb/sig/lib/selenium/webdriver/remote/bridge.rbs b/rb/sig/lib/selenium/webdriver/remote/bridge.rbs index 7c6f83190f8fa..a145336e83043 100644 --- a/rb/sig/lib/selenium/webdriver/remote/bridge.rbs +++ b/rb/sig/lib/selenium/webdriver/remote/bridge.rbs @@ -52,8 +52,14 @@ module Selenium def bidi: -> BiDi + def bidi?: () -> bool + def connection: -> untyped + def install_web_extension: (String path) -> WebExtension + + def uninstall_web_extension: (String id) -> void + def cancel_fedcm_dialog: -> nil def click_fedcm_dialog_button: -> nil @@ -252,6 +258,10 @@ module Selenium private + def web_extension: () -> WebDriver::BiDi::Protocol::WebExtension + + def encode_extension: (String path) -> String + def execute: (untyped command, ?::Hash[untyped, untyped] opts, ?untyped? command_hash) -> String def escaper: () -> untyped diff --git a/rb/spec/integration/selenium/webdriver/BUILD.bazel b/rb/spec/integration/selenium/webdriver/BUILD.bazel index 4d7344d50df9a..c094e197974c5 100644 --- a/rb/spec/integration/selenium/webdriver/BUILD.bazel +++ b/rb/spec/integration/selenium/webdriver/BUILD.bazel @@ -47,6 +47,11 @@ _BIDI_IMPLEMENTATIONS = [ "navigation_spec.rb", ] +# bidi implementations that must also run over a Grid +_GRID_BIDI = [ + "driver_spec.rb", +] + # tests that require bidi enabled _BIDI_ONLY = [ "bidi_spec.rb", @@ -67,7 +72,7 @@ _NO_GRID = ["driver_finder_spec.rb"] ) for file in glob( ["*_spec.rb"], - exclude = _OS_SENSITIVE + _BIDI_IMPLEMENTATIONS + _BIDI_ONLY + _DEVTOOLS + _NO_GRID, + exclude = _OS_SENSITIVE + _BIDI_IMPLEMENTATIONS + _GRID_BIDI + _BIDI_ONLY + _DEVTOOLS + _NO_GRID, ) ] @@ -108,6 +113,20 @@ _NO_GRID = ["driver_finder_spec.rb"] for f in _BIDI_IMPLEMENTATIONS ] +[ + rb_integration_test( + name = f[:-8], + srcs = [f], + bidi = True, + data = ["//common/extensions"], + grid_bidi = True, + deps = [ + "//rb/lib/selenium/webdriver:bidi", + ], + ) + for f in _GRID_BIDI +] + [ rb_integration_test( name = f[:-8], diff --git a/rb/spec/integration/selenium/webdriver/chrome/BUILD.bazel b/rb/spec/integration/selenium/webdriver/chrome/BUILD.bazel index 96ce1333d3c8e..2d09b46d1f2f5 100644 --- a/rb/spec/integration/selenium/webdriver/chrome/BUILD.bazel +++ b/rb/spec/integration/selenium/webdriver/chrome/BUILD.bazel @@ -1,5 +1,10 @@ load("//rb/spec:tests.bzl", "rb_integration_test") +_BROWSERS = [ + "chrome", + "chrome-beta", +] + filegroup( name = "all_srcs", testonly = True, @@ -11,24 +16,29 @@ filegroup( rb_integration_test( name = file[:-8], srcs = [file], - browsers = [ - "chrome", - "chrome-beta", - ], + browsers = _BROWSERS, data = ["//common/extensions"], ) for file in glob( ["*_spec.rb"], - exclude = ["service_spec.rb"], + exclude = [ + "driver_spec.rb", + "service_spec.rb", + ], ) ] +rb_integration_test( + name = "driver", + srcs = ["driver_spec.rb"], + bidi = True, + browsers = _BROWSERS, + data = ["//common/extensions"], +) + rb_integration_test( name = "service", srcs = ["service_spec.rb"], - browsers = [ - "chrome", - "chrome-beta", - ], + browsers = _BROWSERS, grid = False, ) diff --git a/rb/spec/integration/selenium/webdriver/chrome/driver_spec.rb b/rb/spec/integration/selenium/webdriver/chrome/driver_spec.rb index 4e7c354444a7f..5762752a41ece 100644 --- a/rb/spec/integration/selenium/webdriver/chrome/driver_spec.rb +++ b/rb/spec/integration/selenium/webdriver/chrome/driver_spec.rb @@ -17,171 +17,205 @@ # specific language governing permissions and limitations # under the License. +require 'tmpdir' + require_relative '../spec_helper' module Selenium module WebDriver module Chrome - describe Driver, skip_unless: [{bidi: false, reason: 'Not yet implemented with BiDi'}, {browser: :chrome}] do - it 'gets and sets network conditions' do - driver.network_conditions = {offline: false, latency: 56, throughput: 789} - expect(driver.network_conditions).to eq( - 'offline' => false, - 'latency' => 56, - 'download_throughput' => 789, - 'upload_throughput' => 789 - ) - end + describe Driver, skip_unless: {browser: :chrome} do + context 'when BiDi is not enabled', skip_unless: {bidi: false, reason: 'Not yet implemented with BiDi'} do + it 'gets and sets network conditions' do + driver.network_conditions = {offline: false, latency: 56, throughput: 789} + expect(driver.network_conditions).to eq( + 'offline' => false, + 'latency' => 56, + 'download_throughput' => 789, + 'upload_throughput' => 789 + ) + end - it 'sets download path' do - expect { driver.download_path = File.expand_path(__dir__) }.not_to raise_exception - end + it 'sets download path' do + expect { driver.download_path = File.expand_path(__dir__) }.not_to raise_exception + end - it 'can execute CDP commands' do - res = driver.execute_cdp('Page.addScriptToEvaluateOnNewDocument', source: 'window.was_here="TW";') - expect(res).to have_key('identifier') + it 'can execute CDP commands' do + res = driver.execute_cdp('Page.addScriptToEvaluateOnNewDocument', source: 'window.was_here="TW";') + expect(res).to have_key('identifier') - begin - driver.navigate.to url_for('formPage.html') + begin + driver.navigate.to url_for('formPage.html') - tw = driver.execute_script('return window.was_here') - expect(tw).to eq('TW') - ensure - driver.execute_cdp('Page.removeScriptToEvaluateOnNewDocument', identifier: res['identifier']) + tw = driver.execute_script('return window.was_here') + expect(tw).to eq('TW') + ensure + driver.execute_cdp('Page.removeScriptToEvaluateOnNewDocument', identifier: res['identifier']) + end end - end - describe 'PrintsPage' do - before(:all) { @headless = ENV.delete('HEADLESS') } - before { reset_driver!(args: ['--headless']) } + describe 'PrintsPage' do + before(:all) { @headless = ENV.delete('HEADLESS') } + before { reset_driver!(args: ['--headless']) } - after(:all) do - quit_driver - ENV['HEADLESS'] = @headless - end + after(:all) do + quit_driver + ENV['HEADLESS'] = @headless + end - let(:magic_number) { 'JVBER' } + let(:magic_number) { 'JVBER' } - it 'returns base64 for print command' do - driver.navigate.to url_for('printPage.html') - expect(driver.print_page).to include(magic_number) - end + it 'returns base64 for print command' do + driver.navigate.to url_for('printPage.html') + expect(driver.print_page).to include(magic_number) + end - it 'prints with valid params' do - driver.navigate.to url_for('printPage.html') - expect(driver.print_page(orientation: 'landscape', - page_ranges: ['1-2'], - page: {width: 30})).to include(magic_number) - end + it 'prints with valid params' do + driver.navigate.to url_for('printPage.html') + expect(driver.print_page(orientation: 'landscape', + page_ranges: ['1-2'], + page: {width: 30})).to include(magic_number) + end - it 'saves pdf' do - driver.navigate.to url_for('printPage.html') + it 'saves pdf' do + driver.navigate.to url_for('printPage.html') - path = "#{Dir.tmpdir}/test#{SecureRandom.urlsafe_base64}.pdf" + path = "#{Dir.tmpdir}/test#{SecureRandom.urlsafe_base64}.pdf" - driver.save_print_page path + driver.save_print_page path - expect(File.exist?(path)).to be true - expect(File.size(path)).to be_positive - ensure - FileUtils.rm_rf(path) + expect(File.exist?(path)).to be true + expect(File.size(path)).to be_positive + ensure + FileUtils.rm_rf(path) + end end - end - describe '#logs' do - before do - reset_driver!(logging_prefs: {browser: 'ALL', - driver: 'ALL', - performance: 'ALL'}) - driver.navigate.to url_for('errors.html') + describe '#logs' do + before do + reset_driver!(logging_prefs: {browser: 'ALL', + driver: 'ALL', + performance: 'ALL'}) + driver.navigate.to url_for('errors.html') + end + + after(:all) { reset_driver! } + + it 'can fetch available log types' do + expect(driver.logs.available_types).to include(:performance, :browser, :driver) + end + + it 'can get the browser log' do + driver.find_element(tag_name: 'input').click + + entries = driver.logs.get(:browser) + expect(entries).not_to be_empty + expect(entries.first).to be_a(LogEntry) + end + + it 'can get the driver log' do + entries = driver.logs.get(:driver) + expect(entries).not_to be_empty + expect(entries.first).to be_a(LogEntry) + end + + it 'can get the performance log' do + entries = driver.logs.get(:performance) + expect(entries).not_to be_empty + expect(entries.first).to be_a(LogEntry) + end end - after(:all) { reset_driver! } - - it 'can fetch available log types' do - expect(driver.logs.available_types).to include(:performance, :browser, :driver) - end + it 'manages network features' do + driver.network_conditions = {offline: false, latency: 56, download_throughput: 789, upload_throughput: 600} + conditions = driver.network_conditions + expect(conditions['offline']).to be false + expect(conditions['latency']).to eq 56 + expect(conditions['download_throughput']).to eq 789 + expect(conditions['upload_throughput']).to eq 600 + driver.delete_network_conditions - it 'can get the browser log' do - driver.find_element(tag_name: 'input').click + error = /network conditions must be set before it can be retrieved/ + expect { driver.network_conditions }.to raise_error(Error::UnknownError, error) - entries = driver.logs.get(:browser) - expect(entries).not_to be_empty - expect(entries.first).to be_a(LogEntry) + # Need to reset because https://bugs.chromium.org/p/chromedriver/issues/detail?id=4790 + reset_driver! end - it 'can get the driver log' do - entries = driver.logs.get(:driver) - expect(entries).not_to be_empty - expect(entries.first).to be_a(LogEntry) + # This requires cast sinks to run + it 'casts' do + # Does not get list correctly the first time for some reason + driver.cast_sinks + sleep 2 + sinks = driver.cast_sinks + unless sinks.empty? + device_name = sinks.first['name'] + driver.start_cast_tab_mirroring(device_name) + expect { driver.stop_casting(device_name) }.not_to raise_exception + end end - it 'can get the performance log' do - entries = driver.logs.get(:performance) - expect(entries).not_to be_empty - expect(entries.first).to be_a(LogEntry) + def get_permission(name) + driver.execute_async_script('callback = arguments[arguments.length - 1];' \ + 'callback(navigator.permissions.query({name: arguments[0]}));', name)['state'] end - end - - it 'manages network features' do - driver.network_conditions = {offline: false, latency: 56, download_throughput: 789, upload_throughput: 600} - conditions = driver.network_conditions - expect(conditions['offline']).to be false - expect(conditions['latency']).to eq 56 - expect(conditions['download_throughput']).to eq 789 - expect(conditions['upload_throughput']).to eq 600 - driver.delete_network_conditions - error = /network conditions must be set before it can be retrieved/ - expect { driver.network_conditions }.to raise_error(Error::UnknownError, error) + it 'can set single permissions' do + driver.navigate.to url_for('xhtmlTest.html') - # Need to reset because https://bugs.chromium.org/p/chromedriver/issues/detail?id=4790 - reset_driver! - end + expect(get_permission('clipboard-read')).to eq('prompt') + expect(get_permission('clipboard-write')).to eq('granted') - # This requires cast sinks to run - it 'casts' do - # Does not get list correctly the first time for some reason - driver.cast_sinks - sleep 2 - sinks = driver.cast_sinks - unless sinks.empty? - device_name = sinks.first['name'] - driver.start_cast_tab_mirroring(device_name) - expect { driver.stop_casting(device_name) }.not_to raise_exception - end - end + driver.add_permission('clipboard-read', 'denied') + driver.add_permission('clipboard-write', 'prompt') - def get_permission(name) - driver.execute_async_script('callback = arguments[arguments.length - 1];' \ - 'callback(navigator.permissions.query({name: arguments[0]}));', name)['state'] - end + expect(get_permission('clipboard-read')).to eq('denied') + expect(get_permission('clipboard-write')).to eq('prompt') - it 'can set single permissions' do - driver.navigate.to url_for('xhtmlTest.html') + reset_driver! + end - expect(get_permission('clipboard-read')).to eq('prompt') - expect(get_permission('clipboard-write')).to eq('granted') + it 'can set multiple permissions' do + driver.navigate.to url_for('xhtmlTest.html') - driver.add_permission('clipboard-read', 'denied') - driver.add_permission('clipboard-write', 'prompt') + expect(get_permission('clipboard-read')).to eq('prompt') + expect(get_permission('clipboard-write')).to eq('granted') - expect(get_permission('clipboard-read')).to eq('denied') - expect(get_permission('clipboard-write')).to eq('prompt') + driver.add_permissions('clipboard-read' => 'denied', 'clipboard-write' => 'prompt') - reset_driver! + expect(get_permission('clipboard-read')).to eq('denied') + expect(get_permission('clipboard-write')).to eq('prompt') + end end - it 'can set multiple permissions' do - driver.navigate.to url_for('xhtmlTest.html') - - expect(get_permission('clipboard-read')).to eq('prompt') - expect(get_permission('clipboard-write')).to eq('granted') - - driver.add_permissions('clipboard-read' => 'denied', 'clipboard-write' => 'prompt') + context 'when BiDi is enabled', + skip_unless: {bidi: true, reason: 'web extensions install over the webExtension BiDi command'} do + let(:directory) do + File.expand_path('../../../../../../common/extensions/webextensions-selenium-example', __dir__) + end - expect(get_permission('clipboard-read')).to eq('denied') - expect(get_permission('clipboard-write')).to eq('prompt') + describe '#install_web_extension' do + it 'installs a packed archive', + pending_if: {exception: {class: Error::UnsupportedOperationError}, + reason: 'chromium-bidi installs only unpacked directories (SeleniumHQ/selenium#16541)'} do + Dir.mktmpdir do |dir| + archive = File.join(dir, 'extension.zip') + File.binwrite(archive, Base64.decode64(Zipper.zip_root(directory))) + + extension = driver.install_web_extension(archive) + expect(extension.id).not_to be_empty + driver.uninstall_web_extension(extension) + end + end + + it 'installs base64-encoded bytes', + pending_if: {exception: {class: Error::UnsupportedOperationError}, + reason: 'chromium-bidi installs only unpacked directories (SeleniumHQ/selenium#16541)'} do + extension = driver.install_web_extension(Zipper.zip_root(directory)) + expect(extension.id).not_to be_empty + driver.uninstall_web_extension(extension) + end + end end end end # Chrome diff --git a/rb/spec/integration/selenium/webdriver/driver_spec.rb b/rb/spec/integration/selenium/webdriver/driver_spec.rb index 5e2562d708c7e..e7c26b45829cf 100644 --- a/rb/spec/integration/selenium/webdriver/driver_spec.rb +++ b/rb/spec/integration/selenium/webdriver/driver_spec.rb @@ -361,5 +361,41 @@ module WebDriver end end end + + describe Driver do + context 'when BiDi is enabled', + skip_unless: {bidi: true, reason: 'extensions install over the webExtension BiDi command'} do + let(:extensions) { '../../../../../common/extensions/' } + + after { |example| reset_driver!(example: example) } + + describe '#install_web_extension' do + it 'installs an unpacked directory on any browser' do + ext = File.expand_path("#{extensions}/webextensions-selenium-example-signed", __dir__) + extension = driver.install_web_extension(ext) + expect(extension.id).not_to be_empty + + driver.navigate.to url_for('blank.html') + injected = driver.find_element(id: 'webextensions-selenium-example') + expect(injected.text).to eq 'Content injected by webextensions-selenium-example' + end + end + + describe '#uninstall_web_extension' do + it 'removes an installed extension on any browser' do + ext = File.expand_path("#{extensions}/webextensions-selenium-example-signed", __dir__) + extension = driver.install_web_extension(ext) + + driver.navigate.to url_for('blank.html') + injected = driver.find_element(id: 'webextensions-selenium-example') + expect(injected.text).to eq 'Content injected by webextensions-selenium-example' + + driver.uninstall_web_extension(extension) + driver.navigate.refresh + expect(driver.find_elements(id: 'webextensions-selenium-example')).to be_empty + end + end + end + end end # WebDriver end # Selenium diff --git a/rb/spec/integration/selenium/webdriver/edge/BUILD.bazel b/rb/spec/integration/selenium/webdriver/edge/BUILD.bazel index 59b72ae83ec78..106bdae903cab 100644 --- a/rb/spec/integration/selenium/webdriver/edge/BUILD.bazel +++ b/rb/spec/integration/selenium/webdriver/edge/BUILD.bazel @@ -1,5 +1,7 @@ load("//rb/spec:tests.bzl", "rb_integration_test") +_BROWSERS = ["edge"] + filegroup( name = "all_srcs", testonly = True, @@ -11,18 +13,29 @@ filegroup( rb_integration_test( name = file[:-8], srcs = [file], - browsers = ["edge"], + browsers = _BROWSERS, data = ["//common/extensions"], ) for file in glob( ["*_spec.rb"], - exclude = ["service_spec.rb"], + exclude = [ + "driver_spec.rb", + "service_spec.rb", + ], ) ] +rb_integration_test( + name = "driver", + srcs = ["driver_spec.rb"], + bidi = True, + browsers = _BROWSERS, + data = ["//common/extensions"], +) + rb_integration_test( name = "service", srcs = ["service_spec.rb"], - browsers = ["edge"], + browsers = _BROWSERS, grid = False, ) diff --git a/rb/spec/integration/selenium/webdriver/edge/driver_spec.rb b/rb/spec/integration/selenium/webdriver/edge/driver_spec.rb index e868fc31d12cc..039e40184c03b 100644 --- a/rb/spec/integration/selenium/webdriver/edge/driver_spec.rb +++ b/rb/spec/integration/selenium/webdriver/edge/driver_spec.rb @@ -17,100 +17,134 @@ # specific language governing permissions and limitations # under the License. +require 'tmpdir' + require_relative '../spec_helper' module Selenium module WebDriver module Edge - describe Driver, skip_unless: [{bidi: false, reason: 'Not yet implemented with BiDi'}, {browser: :edge}] do - it 'gets and sets network conditions' do - driver.network_conditions = {offline: false, latency: 56, throughput: 789} - expect(driver.network_conditions).to eq( - 'offline' => false, - 'latency' => 56, - 'download_throughput' => 789, - 'upload_throughput' => 789 - ) - driver.delete_network_conditions - end - - it 'supports default network conditions' do - driver.network_conditions = {latency: 56} - expect(driver.network_conditions).to eq( - 'offline' => false, - 'latency' => 56, - 'download_throughput' => -1, - 'upload_throughput' => -1 - ) - driver.delete_network_conditions - - # Need to reset because https://bugs.chromium.org/p/chromedriver/issues/detail?id=4790 - reset_driver! - end - - it 'sets download path' do - expect { driver.download_path = File.expand_path(__dir__) }.not_to raise_exception - end - - it 'can execute CDP commands' do - res = driver.execute_cdp('Page.addScriptToEvaluateOnNewDocument', source: 'window.was_here="TW";') - expect(res).to have_key('identifier') - - begin - driver.navigate.to url_for('formPage.html') - - tw = driver.execute_script('return window.was_here') - expect(tw).to eq('TW') - ensure - driver.execute_cdp('Page.removeScriptToEvaluateOnNewDocument', identifier: res['identifier']) + describe Driver, skip_unless: {browser: :edge} do + context 'when BiDi is not enabled', skip_unless: {bidi: false, reason: 'Not yet implemented with BiDi'} do + it 'gets and sets network conditions' do + driver.network_conditions = {offline: false, latency: 56, throughput: 789} + expect(driver.network_conditions).to eq( + 'offline' => false, + 'latency' => 56, + 'download_throughput' => 789, + 'upload_throughput' => 789 + ) + driver.delete_network_conditions end - end - describe '#logs' do - before do - reset_driver!(logging_prefs: {browser: 'ALL', - driver: 'ALL', - performance: 'ALL'}) - driver.navigate.to url_for('errors.html') + it 'supports default network conditions' do + driver.network_conditions = {latency: 56} + expect(driver.network_conditions).to eq( + 'offline' => false, + 'latency' => 56, + 'download_throughput' => -1, + 'upload_throughput' => -1 + ) + driver.delete_network_conditions + + # Need to reset because https://bugs.chromium.org/p/chromedriver/issues/detail?id=4790 + reset_driver! end - after(:all) { reset_driver! } - - it 'can fetch available log types' do - expect(driver.logs.available_types).to include(:performance, :browser, :driver) + it 'sets download path' do + expect { driver.download_path = File.expand_path(__dir__) }.not_to raise_exception end - it 'can get the browser log' do - driver.find_element(tag_name: 'input').click + it 'can execute CDP commands' do + res = driver.execute_cdp('Page.addScriptToEvaluateOnNewDocument', source: 'window.was_here="TW";') + expect(res).to have_key('identifier') - entries = driver.logs.get(:browser) - expect(entries).not_to be_empty - expect(entries.first).to be_a(LogEntry) + begin + driver.navigate.to url_for('formPage.html') + + tw = driver.execute_script('return window.was_here') + expect(tw).to eq('TW') + ensure + driver.execute_cdp('Page.removeScriptToEvaluateOnNewDocument', identifier: res['identifier']) + end end - it 'can get the driver log' do - entries = driver.logs.get(:driver) - expect(entries).not_to be_empty - expect(entries.first).to be_a(LogEntry) + describe '#logs' do + before do + reset_driver!(logging_prefs: {browser: 'ALL', + driver: 'ALL', + performance: 'ALL'}) + driver.navigate.to url_for('errors.html') + end + + after(:all) { reset_driver! } + + it 'can fetch available log types' do + expect(driver.logs.available_types).to include(:performance, :browser, :driver) + end + + it 'can get the browser log' do + driver.find_element(tag_name: 'input').click + + entries = driver.logs.get(:browser) + expect(entries).not_to be_empty + expect(entries.first).to be_a(LogEntry) + end + + it 'can get the driver log' do + entries = driver.logs.get(:driver) + expect(entries).not_to be_empty + expect(entries.first).to be_a(LogEntry) + end + + it 'can get the performance log' do + entries = driver.logs.get(:performance) + expect(entries).not_to be_empty + expect(entries.first).to be_a(LogEntry) + end end - it 'can get the performance log' do - entries = driver.logs.get(:performance) - expect(entries).not_to be_empty - expect(entries.first).to be_a(LogEntry) + # This requires cast sinks to run + it 'casts' do + # Does not get list correctly the first time for some reason + driver.cast_sinks + sleep 2 + sinks = driver.cast_sinks + unless sinks.empty? + device_name = sinks.first['name'] + driver.start_cast_tab_mirroring(device_name) + expect { driver.stop_casting(device_name) }.not_to raise_exception + end end end - # This requires cast sinks to run - it 'casts' do - # Does not get list correctly the first time for some reason - driver.cast_sinks - sleep 2 - sinks = driver.cast_sinks - unless sinks.empty? - device_name = sinks.first['name'] - driver.start_cast_tab_mirroring(device_name) - expect { driver.stop_casting(device_name) }.not_to raise_exception + context 'when BiDi is enabled', + skip_unless: {bidi: true, reason: 'web extensions install over the webExtension BiDi command'} do + let(:directory) do + File.expand_path('../../../../../../common/extensions/webextensions-selenium-example', __dir__) + end + + describe '#install_web_extension' do + it 'installs a packed archive', + pending_if: {exception: {class: Error::UnsupportedOperationError}, + reason: 'chromium-bidi installs only unpacked directories (SeleniumHQ/selenium#16541)'} do + Dir.mktmpdir do |dir| + archive = File.join(dir, 'extension.zip') + File.binwrite(archive, Base64.decode64(Zipper.zip_root(directory))) + + extension = driver.install_web_extension(archive) + expect(extension.id).not_to be_empty + driver.uninstall_web_extension(extension) + end + end + + it 'installs base64-encoded bytes', + pending_if: {exception: {class: Error::UnsupportedOperationError}, + reason: 'chromium-bidi installs only unpacked directories (SeleniumHQ/selenium#16541)'} do + extension = driver.install_web_extension(Zipper.zip_root(directory)) + expect(extension.id).not_to be_empty + driver.uninstall_web_extension(extension) + end end end end diff --git a/rb/spec/integration/selenium/webdriver/firefox/BUILD.bazel b/rb/spec/integration/selenium/webdriver/firefox/BUILD.bazel index f5782d96949f9..6cf668dcf6ce3 100644 --- a/rb/spec/integration/selenium/webdriver/firefox/BUILD.bazel +++ b/rb/spec/integration/selenium/webdriver/firefox/BUILD.bazel @@ -1,5 +1,10 @@ load("//rb/spec:tests.bzl", "rb_integration_test") +_BROWSERS = [ + "firefox", + "firefox-beta", +] + filegroup( name = "all_srcs", testonly = True, @@ -11,24 +16,29 @@ filegroup( rb_integration_test( name = file[:-8], srcs = [file], - browsers = [ - "firefox", - "firefox-beta", - ], + browsers = _BROWSERS, data = ["//common/extensions"], ) for file in glob( ["*_spec.rb"], - exclude = ["service_spec.rb"], + exclude = [ + "driver_spec.rb", + "service_spec.rb", + ], ) ] +rb_integration_test( + name = "driver", + srcs = ["driver_spec.rb"], + bidi = True, + browsers = _BROWSERS, + data = ["//common/extensions"], +) + rb_integration_test( name = "service", srcs = ["service_spec.rb"], - browsers = [ - "firefox", - "firefox-beta", - ], + browsers = _BROWSERS, grid = False, ) diff --git a/rb/spec/integration/selenium/webdriver/firefox/driver_spec.rb b/rb/spec/integration/selenium/webdriver/firefox/driver_spec.rb index a92f81ebd6806..6188874c67650 100644 --- a/rb/spec/integration/selenium/webdriver/firefox/driver_spec.rb +++ b/rb/spec/integration/selenium/webdriver/firefox/driver_spec.rb @@ -22,48 +22,66 @@ module Selenium module WebDriver module Firefox - describe Driver, skip_unless: [{bidi: false, reason: 'Not yet implemented with BiDi'}, {browser: :firefox}] do + describe Driver, skip_unless: {browser: :firefox} do let(:extensions) { '../../../../../../common/extensions/' } - describe '#print_options' do - let(:magic_number) { 'JVBER' } + context 'when BiDi is not enabled', skip_unless: {bidi: false, reason: 'Not yet implemented with BiDi'} do + describe '#print_options' do + let(:magic_number) { 'JVBER' } - before { driver.navigate.to url_for('printPage.html') } + before { driver.navigate.to url_for('printPage.html') } - it 'returns base64 for print command' do - expect(driver.print_page).to include(magic_number) - end + it 'returns base64 for print command' do + expect(driver.print_page).to include(magic_number) + end - it 'prints with orientation' do - expect(driver.print_page(orientation: 'landscape')).to include(magic_number) - end + it 'prints with orientation' do + expect(driver.print_page(orientation: 'landscape')).to include(magic_number) + end + + it 'prints with valid params' do + expect(driver.print_page(orientation: 'landscape', + page_ranges: ['1-2'], + page: {width: 30})).to include(magic_number) + end + + it 'prints full page', pending_if: [{platform: :macosx, + reason: 'showing half resolution of what expected'}] do + viewport_width = driver.execute_script('return window.innerWidth;') + viewport_height = driver.execute_script('return window.innerHeight;') - it 'prints with valid params' do - expect(driver.print_page(orientation: 'landscape', - page_ranges: ['1-2'], - page: {width: 30})).to include(magic_number) + path = "#{Dir.tmpdir}/test#{SecureRandom.urlsafe_base64}.png" + screenshot = driver.save_full_page_screenshot(path) + width, height = png_size(screenshot) + + expect(width).to be >= viewport_width + expect(height).to be > viewport_height + ensure + FileUtils.rm_rf(path) + end end - it 'prints full page', pending_if: [{platform: :macosx, - reason: 'showing half resolution of what expected'}] do - viewport_width = driver.execute_script('return window.innerWidth;') - viewport_height = driver.execute_script('return window.innerHeight;') + it 'can get and set context', + skip_if: {driver: :remote, reason: 'system access cannot be granted per-session on Grid'} do + service = WebDriver::Service.firefox(args: ['--allow-system-access']) + reset_driver!(service: service, prefs: {'browser.download.dir': 'foo/bar'}) do |driver| + expect(driver.context).to eq 'content' - path = "#{Dir.tmpdir}/test#{SecureRandom.urlsafe_base64}.png" - screenshot = driver.save_full_page_screenshot(path) - width, height = png_size(screenshot) + driver.context = 'chrome' + expect(driver.context).to eq 'chrome' - expect(width).to be >= viewport_width - expect(height).to be > viewport_height - ensure - FileUtils.rm_rf(path) + # This call can not be made when context is set to 'content' + dir = driver.execute_script("return Services.prefs.getStringPref('browser.download.dir')") + expect(dir).to eq 'foo/bar' + end end end - describe '#install_addon' do + describe '#install_addon', skip_unless: {bidi: false, reason: 'classic moz/addon endpoint'} do it 'install and uninstall xpi file' do ext = File.expand_path("#{extensions}/webextensions-selenium-example.xpi", __dir__) - id = driver.install_addon(ext) + id = nil + expect { id = driver.install_addon(ext) }.to have_deprecated(:install_addon) expect(id).to eq 'webextensions-selenium-example-v3@example.com' driver.navigate.to url_for('blank.html') @@ -140,18 +158,116 @@ module Firefox end end - it 'can get and set context', - skip_if: {driver: :remote, reason: 'system access cannot be granted per-session on Grid'} do - service = WebDriver::Service.firefox(args: ['--allow-system-access']) - reset_driver!(service: service, prefs: {'browser.download.dir': 'foo/bar'}) do |driver| - expect(driver.context).to eq 'content' + describe '#install_web_extension', skip_unless: {bidi: false, reason: 'classic moz/addon fallback'} do + it 'installs and uninstalls without BiDi enabled' do + ext = File.expand_path("#{extensions}/webextensions-selenium-example.xpi", __dir__) + extension = driver.install_web_extension(ext) + expect(extension.id).to eq 'webextensions-selenium-example-v3@example.com' + + driver.navigate.to url_for('blank.html') + injected = driver.find_element(id: 'webextensions-selenium-example') + expect(injected.text).to eq 'Content injected by webextensions-selenium-example' + + driver.uninstall_web_extension(extension) + driver.navigate.refresh + expect(driver.find_elements(id: 'webextensions-selenium-example')).to be_empty + end + + it 'installs an unsigned directory with permanent: false' do + ext = File.expand_path("#{extensions}/webextensions-selenium-example", __dir__) + extension = driver.install_web_extension(ext, permanent: false) + expect(extension.id).to eq 'webextensions-selenium-example-v3@example.com' + + driver.navigate.to url_for('blank.html') + injected = driver.find_element(id: 'webextensions-selenium-example') + expect(injected.text).to eq 'Content injected by webextensions-selenium-example' + + driver.uninstall_web_extension(extension) + end + + context 'with private browsing' do + let(:ext) { File.expand_path("#{extensions}/webextensions-selenium-example-signed", __dir__) } + + it 'runs in a private window when allowed' do + reset_driver!(prefs: {'browser.privatebrowsing.autostart': true}) do |driver| + driver.install_web_extension(ext, allow_private_browsing: true) + driver.navigate.to url_for('blank.html') + + injected = driver.find_element(id: 'webextensions-selenium-example') + expect(injected.text).to eq 'Content injected by webextensions-selenium-example' + end + end + + it 'does not run in a private window when disabled' do + reset_driver!(prefs: {'browser.privatebrowsing.autostart': true}) do |driver| + driver.install_web_extension(ext, allow_private_browsing: false) + driver.navigate.to url_for('blank.html') + expect(driver.find_elements(id: 'webextensions-selenium-example')).to be_empty + end + end + end + end + + describe '#install_web_extension', skip_unless: {bidi: true, reason: 'moz webExtension BiDi command'} do + after { |example| reset_driver!(example: example) } + + it 'installs and removes an xpi file' do + ext = File.expand_path("#{extensions}/webextensions-selenium-example.xpi", __dir__) + extension = driver.install_web_extension(ext) + expect(extension.id).to eq 'webextensions-selenium-example-v3@example.com' + + driver.navigate.to url_for('blank.html') + injected = driver.find_element(id: 'webextensions-selenium-example') + expect(injected.text).to eq 'Content injected by webextensions-selenium-example' + + driver.uninstall_web_extension(extension) + end + + it 'installs and removes base64-encoded bytes' do + xpi = File.expand_path("#{extensions}/webextensions-selenium-example.xpi", __dir__) + extension = driver.install_web_extension(Base64.strict_encode64(File.binread(xpi))) + expect(extension.id).to eq 'webextensions-selenium-example-v3@example.com' + + driver.navigate.to url_for('blank.html') + injected = driver.find_element(id: 'webextensions-selenium-example') + expect(injected.text).to eq 'Content injected by webextensions-selenium-example' + + driver.uninstall_web_extension(extension) + end + + it 'installs an unsigned directory with permanent: false' do + ext = File.expand_path("#{extensions}/webextensions-selenium-example", __dir__) + extension = driver.install_web_extension(ext, permanent: false) + expect(extension.id).to eq 'webextensions-selenium-example-v3@example.com' + + driver.navigate.to url_for('blank.html') + injected = driver.find_element(id: 'webextensions-selenium-example') + expect(injected.text).to eq 'Content injected by webextensions-selenium-example' + + driver.uninstall_web_extension(extension) + end + + context 'with allow_private_browsing enabled' do + let(:ext) { File.expand_path("#{extensions}/webextensions-selenium-example-signed", __dir__) } + + it 'runs in a private window when allowed' do + reset_driver!(prefs: {'browser.privatebrowsing.autostart': true}) do |driver| + driver.install_web_extension(ext, allow_private_browsing: true) + driver.navigate.to url_for('blank.html') + + injected = driver.find_element(id: 'webextensions-selenium-example') + expect(injected.text).to eq 'Content injected by webextensions-selenium-example' + end + end - driver.context = 'chrome' - expect(driver.context).to eq 'chrome' + it 'does not run in a private window by default' do + reset_driver!(prefs: {'browser.privatebrowsing.autostart': true}) do |driver| + driver.install_web_extension(ext) + driver.navigate.to url_for('blank.html') - # This call can not be made when context is set to 'content' - dir = driver.execute_script("return Services.prefs.getStringPref('browser.download.dir')") - expect(dir).to eq 'foo/bar' + expect(driver.find_elements(id: 'webextensions-selenium-example')).to be_empty + end + end end end end diff --git a/rb/spec/tests.bzl b/rb/spec/tests.bzl index 1aa8eed64e4e5..c855ef5a4ea63 100644 --- a/rb/spec/tests.bzl +++ b/rb/spec/tests.bzl @@ -191,7 +191,8 @@ def rb_integration_test( tags = [], bidi = False, classic = True, - grid = True): + grid = True, + grid_bidi = False): for browser in browsers: generate_classic = BROWSERS[browser].get("classic", True) generate_bidi = BROWSERS[browser].get("bidi", False) @@ -261,3 +262,32 @@ def rb_integration_test( visibility = ["//rb:__subpackages__"], target_compatible_with = BROWSERS[browser]["target_compatible_with"], ) + + # Bidi over a Grid, for specs that must exercise remote-end behavior. + if grid_bidi: + rb_test( + name = "{}-{}-remote-bidi".format(name, browser), + size = "large", + srcs = srcs, + args = ["rb/spec/"], + data = BROWSERS[browser]["data"] + data + [ + "//common/src/web", + "//java/src/org/openqa/selenium/grid:selenium_server_deploy.jar", + "//rb/spec:java-location", + "@bazel_tools//tools/jdk:current_java_runtime", + ], + env = BROWSERS[browser]["env"] | { + "WD_BAZEL_JAVA_LOCATION": "$(rootpath //rb/spec:java-location)", + "WD_SPEC_DRIVER": "remote", + "WEBDRIVER_BIDI": "true", + }, + main = "@bundle//bin:rspec", + tags = COMMON_TAGS + BROWSERS[browser]["tags"] + universal_tags + ["bidi", "{}-remote".format(browser)] + family_tags, + deps = {d: True for d in ( + ["//rb/spec/integration/selenium/webdriver:spec_helper", "//rb/lib/selenium/webdriver:bidi"] + + BROWSERS[browser]["deps"] + + deps + )}.keys(), + visibility = ["//rb:__subpackages__"], + target_compatible_with = BROWSERS[browser]["target_compatible_with"], + ) diff --git a/rb/spec/unit/selenium/webdriver/common/web_extension_spec.rb b/rb/spec/unit/selenium/webdriver/common/web_extension_spec.rb new file mode 100644 index 0000000000000..2b291ffd45fa4 --- /dev/null +++ b/rb/spec/unit/selenium/webdriver/common/web_extension_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +# Licensed to the Software Freedom Conservancy (SFC) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The SFC licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +require File.expand_path('../spec_helper', __dir__) + +module Selenium + module WebDriver + describe WebExtension do + let(:extension) { described_class.new('installed-extension-id') } + + it 'exposes the identifier assigned by the browser' do + expect(extension.id).to eq 'installed-extension-id' + end + end + end +end diff --git a/rb/spec/unit/selenium/webdriver/common/websocket_connection_spec.rb b/rb/spec/unit/selenium/webdriver/common/websocket_connection_spec.rb index 49e038c3b2cf0..f17675fe3cb2c 100644 --- a/rb/spec/unit/selenium/webdriver/common/websocket_connection_spec.rb +++ b/rb/spec/unit/selenium/webdriver/common/websocket_connection_spec.rb @@ -80,6 +80,38 @@ module WebDriver end end + describe '#process_frame' do + let(:frame) { {'id' => 1, 'result' => {}} } + + before do + connection.instance_variable_set(:@messages_mtx, Mutex.new) + allow(JSON).to receive(:parse).and_return(frame) + end + + it 'does not render the frame to a string when debug logging is off' do + allow(frame).to receive(:to_s) + + connection.send(:process_frame, '{"id":1,"result":{}}') + + expect(frame).not_to have_received(:to_s) + end + + it 'still records the frame for the waiting command' do + connection.send(:process_frame, '{"id":1,"result":{}}') + + expect(connection.send(:messages)).to eq(1 => frame) + end + + it 'renders the frame when the logger is at debug level' do + allow(frame).to receive(:to_s).and_return('rendered') + allow(WebDriver.logger).to receive(:debug).and_yield + + connection.send(:process_frame, '{"id":1,"result":{}}') + + expect(frame).to have_received(:to_s) + end + end + describe '#frame_dropped?' do let(:incoming_frame) { WebSocket::Frame::Incoming::Client.new(version: 13) } let(:socket) { StringIO.new } diff --git a/rb/spec/unit/selenium/webdriver/remote/bridge_spec.rb b/rb/spec/unit/selenium/webdriver/remote/bridge_spec.rb index 3dc58f2984e39..c29b9740d738f 100644 --- a/rb/spec/unit/selenium/webdriver/remote/bridge_spec.rb +++ b/rb/spec/unit/selenium/webdriver/remote/bridge_spec.rb @@ -125,12 +125,39 @@ module Remote end end - describe '#upload' do - it 'raises WebDriverError if uploading non-files' do - expect { - bridge.extend(WebDriver::Remote::Features) - bridge.upload('NotAFile') - }.to raise_error(Error::WebDriverError) + describe '#upload_if_necessary' do + before do + bridge.extend(WebDriver::Remote::Features) + bridge.file_detector = ->((file)) { file } + end + + it 'raises WebDriverError when the detected path is not a file' do + expect { bridge.upload_if_necessary(['NotAFile']) } + .to raise_error(Error::WebDriverError, /isn't a file/) + end + end + + describe '#install_web_extension' do + context 'when BiDi is not enabled' do + it 'raises a helpful error telling the user to enable BiDi' do + expect { bridge.install_web_extension('/tmp/ext') } + .to raise_error(Error::WebDriverError, /must be enabled/) + end + + it 'raises for a Chromium session, which has no classic install path' do + bridge.extend(WebDriver::Chrome::Features) + expect { bridge.install_web_extension('/tmp/ext') } + .to raise_error(Error::WebDriverError, /must be enabled/) + end + end + end + + describe '#uninstall_web_extension' do + context 'when BiDi is not enabled' do + it 'raises a helpful error telling the user to enable BiDi' do + expect { bridge.uninstall_web_extension('an-id') } + .to raise_error(Error::WebDriverError, /must be enabled/) + end end end diff --git a/rb/spec/unit/selenium/webdriver/zipper_spec.rb b/rb/spec/unit/selenium/webdriver/zipper_spec.rb index 5623886ddbeb6..27f5809bc6e44 100644 --- a/rb/spec/unit/selenium/webdriver/zipper_spec.rb +++ b/rb/spec/unit/selenium/webdriver/zipper_spec.rb @@ -38,14 +38,6 @@ def create_file after { FileUtils.rm_rf tmp_dir } describe '#zip' do - it 'a file' do - File.open(zip_file, 'wb') do |io| - io << Base64.decode64(described_class.zip_file(create_file)) - end - - expect(File).to exist(zip_file) - end - it 'a folder' do create_file @@ -69,10 +61,50 @@ def create_file end end + describe '#zip_root' do + it 'wraps a file as a single top-level entry' do + File.open(zip_file, 'wb') do |io| + io << Base64.decode64(described_class.zip_root(create_file)) + end + + unzipped = described_class.unzip(zip_file) + expect(Dir.children(unzipped)).to eq([base_file_name]) + end + + it 'wraps a directory as a single top-level folder named for it' do + create_file + + File.open(zip_file, 'wb') do |io| + io << Base64.decode64(described_class.zip_root(dir_to_zip)) + end + + unzipped = described_class.unzip(zip_file) + expect(Dir.children(unzipped)).to eq([File.basename(dir_to_zip)]) + expect(File.read(File.join(unzipped, File.basename(dir_to_zip), base_file_name))).to eq(file_content) + end + + it 'keeps the folder name when the path has a trailing separator' do + create_file + + File.open(zip_file, 'wb') do |io| + io << Base64.decode64(described_class.zip_root("#{dir_to_zip}/")) + end + + unzipped = described_class.unzip(zip_file) + expect(Dir.children(unzipped)).to eq([File.basename(dir_to_zip)]) + end + end + + describe '#zip_file' do + it 'is a backwards-compatible alias for #zip_root' do + expect(described_class.method(:zip_file)).to eq(described_class.method(:zip_root)) + end + end + describe '#unzip' do it 'a file' do File.open(zip_file, 'wb') do |io| - io << Base64.decode64(described_class.zip_file(create_file)) + io << Base64.decode64(described_class.zip_root(create_file)) end unzipped = described_class.unzip(zip_file) diff --git a/rust/BUILD.bazel b/rust/BUILD.bazel index 63f8caa1e9b6f..fbd7275a896d7 100644 --- a/rust/BUILD.bazel +++ b/rust/BUILD.bazel @@ -1,3 +1,5 @@ +load("@aspect_bazel_lib//lib:output_files.bzl", "output_files") +load("@aspect_bazel_lib//lib:transitions.bzl", "platform_transition_binary") load("@crates//:defs.bzl", "aliases", "all_crate_deps") load("//rust:defs.bzl", "rust_binary", "rust_library", "rust_test", "rustfmt_config") @@ -19,6 +21,60 @@ SELENIUM_MANAGER_PLATFORMS = [ "windows", ] +# musl: Rust's glibc stdlib needs `-lgcc_s`, which @llvm doesn't ship. +# gnullvm: @llvm links a PE binary without a Windows SDK. +SELENIUM_MANAGER_TRIPLES = { + "linux-arm64": "aarch64-unknown-linux-musl", + "linux-x86_64": "x86_64-unknown-linux-musl", + "macos-arm64": "aarch64-apple-darwin", + "macos-x86_64": "x86_64-apple-darwin", + "windows": "x86_64-pc-windows-gnullvm", +} + +[ + platform_transition_binary( + name = "_xc-%s" % target, + basename = "selenium-manager.exe" if target == "windows" else "selenium-manager", + binary = ":selenium-manager", + tags = ["manual"], + target_platform = "@rules_rs//rs/platforms:%s" % triple, + ) + for (target, triple) in SELENIUM_MANAGER_TRIPLES.items() +] + +# platform_transition_binary also forwards the rust_binary at a colliding path; keep only the wrapper. +# Windows hosts can't cross-compile until upstream fixes ship. +[ + output_files( + name = "_xc_file-%s" % target, + paths = ["rust/_xc-%s/selenium-manager%s" % ( + target, + ".exe" if target == "windows" else "", + )], + tags = ["manual"], + target = ":_xc-%s" % target, + target_compatible_with = select({ + "//common:windows": ["@platforms//:incompatible"], + "//conditions:default": [], + }), + visibility = ["//common/manager:__pkg__"], + ) + for target in SELENIUM_MANAGER_TRIPLES +] + +genrule( + name = "_xc_file-macos", + srcs = [ + ":_xc_file-macos-arm64", + ":_xc_file-macos-x86_64", + ], + outs = ["_xc-macos/selenium-manager"], + cmd = "$(execpath @llvm//tools:llvm-lipo) -create -output $@ $(execpath :_xc_file-macos-arm64) $(execpath :_xc_file-macos-x86_64)", + tags = ["manual"], + tools = ["@llvm//tools:llvm-lipo"], + visibility = ["//common/manager:__pkg__"], +) + [ alias( name = "selenium-manager-%s" % platform, diff --git a/scripts/github-actions/update_browsers.sh b/scripts/github-actions/update_browsers.sh index 5be91df743602..68754075b9ee3 100755 --- a/scripts/github-actions/update_browsers.sh +++ b/scripts/github-actions/update_browsers.sh @@ -13,9 +13,10 @@ git show HEAD:common/repositories.bzl > "$old" bazel run //scripts:pinned_browsers -# Sorted-unique major versions of one family in a repositories.bzl; $1 = file, $2 = ERE matching -# "". -majors_for() { grep -oE "$2" "$1" | grep -oE '[0-9]+$' | sort -un; } +# Stable major version of one family in a repositories.bzl; $1 = file, $2 = ERE matching +# "". Stable is the lowest pinned major (beta runs ahead), so a +# beta-only bump never counts. +stable_major_for() { grep -oE "$2" "$1" | grep -oE '[0-9]+$' | sort -un | head -n1; } # Only Chrome and Firefox majors warrant the full matrix: they ship ~monthly and are the likeliest # to break the bindings. Edge tracks Chromium; driver-only and build/patch bumps do not count. Each @@ -25,10 +26,9 @@ declare -A families=( [firefox]='(firefox/releases/|Firefox%20)[0-9]+' ) -# New stable Chrome is the lowest pinned major (beta runs ahead). Tolerate a no-match (|| true) so a -# marker/format change in repositories.bzl fails with a clear message, not a bare pipefail exit. -chrome_majors=$(majors_for common/repositories.bzl "${families[chrome]}") || true -chrome=${chrome_majors%%$'\n'*} +# Tolerate a no-match (|| true) so a marker/format change in repositories.bzl fails with a clear +# message, not a bare pipefail exit. +chrome=$(stable_major_for common/repositories.bzl "${families[chrome]}") || true if [ -z "$chrome" ]; then echo "::error::Could not parse a stable Chrome major from common/repositories.bzl (pattern: ${families[chrome]})" >&2 exit 1 @@ -55,7 +55,7 @@ fi major=false for pattern in "${families[@]}"; do - if [ "$(majors_for "$old" "$pattern")" != "$(majors_for common/repositories.bzl "$pattern")" ]; then + if [ "$(stable_major_for "$old" "$pattern")" != "$(stable_major_for common/repositories.bzl "$pattern")" ]; then major=true break fi