diff --git a/.coveralls.yml b/.coveralls.yml new file mode 100644 index 000000000..5ae55a93b --- /dev/null +++ b/.coveralls.yml @@ -0,0 +1,2 @@ +coverage_clover: ./logs/clover.xml +json_path: ./logs/coveralls-upload.json diff --git a/.gitattributes b/.gitattributes index c3b6383af..2fdc4ff88 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,8 +1,15 @@ * text=auto +/scripts export-ignore /tests export-ignore +/tools export-ignore /.gitattributes export-ignore /.gitignore export-ignore -/.travis.yml export-ignore /example.php export-ignore /phpunit.xml.dist export-ignore +/.github export-ignore +/.php-cs-fixer.dist.php export-ignore +/phpstan.neon export-ignore +/.coveralls.yml export-ignore +/logs export-ignore +/mlc_config.json export-ignore diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 000000000..a79b02b1f --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,94 @@ +# Contributing to php-webdriver + +We love to have your help to make php-webdriver better! + +Feel free to open an [issue](https://github.com/php-webdriver/php-webdriver/issues) if you run into any problem, or +send a pull request (see bellow) with your contribution. + +## Before you contribute + +Do not hesitate to ask for a guidance before you implement notable change, or a new feature - use the associated [issue](https://github.com/php-webdriver/php-webdriver/issues) or use [Discussions](https://github.com/php-webdriver/php-webdriver/discussions). +Because any new code means increased effort in library maintenance (which is being done by volunteers in their free time), +please understand not every pull request is automatically accepted. This is why we recommend using the mentioned channels to discuss bigger changes in the source code first. + +When you are going to contribute, also please keep in mind that this webdriver client aims to be similar to clients in languages Java/Ruby/Python/C#. +Here is the [official documentation](https://www.selenium.dev/documentation/en/) and overview of [the official Java API](http://seleniumhq.github.io/selenium/docs/api/java/) + +## Workflow when contributing a patch + +1. Fork the project on GitHub +2. Implement your code changes into separate branch +3. Make sure all PHPUnit tests passes and code-style matches PSR-2 (see below). We also have CI builds which will automatically run tests on your pull request. Make sure to fix any reported issues reported by these automated tests. +4. When implementing a notable change, fix or a new feature, add record to the Unreleased section of [CHANGELOG.md](../CHANGELOG.md) +5. Submit your [pull request](https://github.com/php-webdriver/php-webdriver/pulls) against `main` branch + +### Run automated code checks + +To make sure your code comply with [PSR-2](http://www.php-fig.org/psr/psr-2/) coding style, tests passes and to execute other automated checks, run locally: + +```sh +composer all +``` + +To run functional tests locally there is some additional setup needed - see below. Without this setup, functional tests will be skipped. + + +For easier development there are also few other prepared commands: +- `composer fix` - to auto-fix the codestyle and composer.json +- `composer analyze` - to run only code analysis (without tests) +- `composer test` - to run all tests + +### Unit tests + +There are two test-suites: one with **unit tests** only (`unit`), and second with **functional tests** (`functional`), +which requires running Selenium server and local PHP server. + +To execute **all tests** in both suites run: + +```sh +composer test +``` + +If you want to execute **just the unit tests**, run: + +```sh +composer test -- --testsuite unit +``` + +**Functional tests** are run against a real browser. It means they take a bit longer and also require an additional setup: +you must first [download](https://www.selenium.dev/downloads/) and start the Selenium standalone server, +then start the local PHP server which will serve the test pages and then run the `functional` test suite: + +```sh +export BROWSER_NAME="htmlunit" # see below for other browsers +java -jar selenium-server-X.XX.0.jar standalone --log selenium.log & +php -S localhost:8000 -t tests/functional/web/ & +# Use following to run both unit and functional tests +composer all +# Or this to run only functional tests: +composer test -- --testsuite functional +``` + +If you want to run tests in different browser then "htmlunit" (Chrome or Firefox), you need to set up the browser driver (Chromedriver/Geckodriver), as it is [explained in wiki](https://github.com/php-webdriver/php-webdriver/wiki/Chrome) +and then the `BROWSER_NAME` environment variable: + +```sh +... +export BROWSER_NAME="chrome" +composer all +``` + +To test with Firefox/Geckodriver, you must also set `GECKODRIVER` environment variable: + +```sh +export GECKODRIVER=1 +export BROWSER_NAME="firefox" +composer all +``` + +To see the tests as they are happening (in the browser window), you can disable headless mode. This is useful eg. when debugging the tests or writing a new one: + +```sh +export DISABLE_HEADLESS="1" +composer all +``` diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 000000000..bac315d1d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,124 @@ +name: 🐛 Bug report +description: Create a bug report to help us improve php-webdriver +labels: [ "bug" ] +body: + - type: markdown + attributes: + value: | + If you have a question, [ask in Discussions](https://github.com/php-webdriver/php-webdriver/discussions) instead of filling a bug. + + If you are reporting a bug, please **fill as much as possible information**, otherwise the community and maintainers cannot provide a prompt feedback and help solving the issue. + - type: textarea + id: bug-description + attributes: + label: Bug description + description: | + A clear description of what the bug is. + validations: + required: true + + - type: textarea + id: steps-to-reproduce + attributes: + label: How could the issue be reproduced + description: | + Provide steps to reproduce the behavior. Please include everything relevant - the PHP code you use to initialize driver instance, the PHP code causing the error, HTML snippet or URL of the page where you encounter the issue etc. + This will be automatically formatted into code, so no need for backticks ```. + placeholder: | + // For example you can provide how you create WebDriver instance: + $capabilities = DesiredCapabilities::chrome(); + $driver = RemoteWebDriver::create('http://localhost:4444/', $capabilities); + // And the code you use to execute the php-webdriver commands, for example: + $driver->get('http://site.localhost/foo.html'); + $button = $driver->findElement(WebDriverBy::cssSelector('#foo')); + $button->click(); + +
+ +
+ + render: shell + validations: + required: true + + - type: textarea + id: expected-behavior + attributes: + label: Expected behavior + description: | + A clear and concise description of what you expected to happen. + validations: + required: false + + - type: input + id: php-webdriver-version + attributes: + label: Php-webdriver version + description: You can run `composer show php-webdriver/webdriver` to find the version number + placeholder: | + For example: 1.13.0 + validations: + required: true + + - type: input + id: php-version + attributes: + label: PHP version + description: You can run `php -v` to find the version + placeholder: | + For example: 8.1.11 + validations: + required: true + + - type: input + id: how-start + attributes: + label: How do you start the browser driver or Selenium server + description: | + For example: Selenium server jar, Selenium in Docker, chromedriver command, Laravel Dusk, SauceLabs etc. + If relevant, provide the complete command you use to start the browser driver or Selenium server + validations: + required: true + + - type: input + id: selenium-version + attributes: + label: Selenium server / Selenium Docker image version + description: Relevant only if you use Selenium server / Selenium in Docker + validations: + required: false + + - type: input + id: browser-driver + attributes: + label: Browser driver (chromedriver/geckodriver...) version + description: You can run `chromedriver --version` or `geckodriver --version` to find the version + placeholder: | + For example: geckodriver 0.31.0 + validations: + required: false + + - type: input + id: browser + attributes: + label: Browser name and version + placeholder: | + For example: Firefox 105.0.2 + validations: + required: false + + - type: input + id: operating-system + attributes: + label: Operating system + validations: + required: false + + - type: textarea + id: additional-context + attributes: + label: Additional context + description: | + Add any other context or you notes about the problem here. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..e9a259980 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: ❓ Questions and Help + url: https://github.com/php-webdriver/php-webdriver/discussions + about: Please ask and answer questions here + - name: 💡 Ideas and feature requests + url: https://github.com/php-webdriver/php-webdriver/discussions + about: Suggest an idea for php-webdriver diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml new file mode 100644 index 000000000..50112e87a --- /dev/null +++ b/.github/dependabot.yaml @@ -0,0 +1,7 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: daily + time: "04:00" diff --git a/.github/workflows/coveralls-workaround.yaml b/.github/workflows/coveralls-workaround.yaml new file mode 100644 index 000000000..d6044861d --- /dev/null +++ b/.github/workflows/coveralls-workaround.yaml @@ -0,0 +1,48 @@ +name: Coveralls coverage +# Must be run in separate workflow to have access to repository secrets even for PR from forks. +# See https://securitylab.github.com/research/github-actions-preventing-pwn-requests/ + +permissions: + contents: read + +on: + workflow_run: + workflows: [ "Tests" ] + types: + - completed + +jobs: + coveralls: + name: Coveralls coverage workaround + runs-on: ubuntu-latest + if: > + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.conclusion == 'success' + steps: + # see https://securitylab.github.com/research/github-actions-preventing-pwn-requests/ + - name: 'Download artifact' + uses: actions/github-script@v8 + with: + script: | + var artifacts = await github.rest.actions.listWorkflowRunArtifacts({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: ${{github.event.workflow_run.id }}, + }); + var matchArtifact = artifacts.data.artifacts.filter((artifact) => { + return artifact.name == "data" + })[0]; + var download = await github.rest.actions.downloadArtifact({ + owner: context.repo.owner, + repo: context.repo.repo, + artifact_id: matchArtifact.id, + archive_format: 'zip', + }); + var fs = require('fs'); + fs.writeFileSync('${{github.workspace}}/data.zip', Buffer.from(download.data)); + - run: unzip data.zip + - name: Coveralls coverage workaround + # see https://github.com/lemurheavy/coveralls-public/issues/1653#issuecomment-1251587119 + run: | + BUILD_NUM=$(cat run_id) + curl --location --request GET "https://coveralls.io/rerun_build?repo_token=${{%20secrets.COVERALLS_REPO_TOKEN%20}}&build_num=$BUILD_NUM" diff --git a/.github/workflows/docs-lint.yml b/.github/workflows/docs-lint.yml new file mode 100644 index 000000000..42baa0000 --- /dev/null +++ b/.github/workflows/docs-lint.yml @@ -0,0 +1,26 @@ +name: Lint PHP documentation + +permissions: + contents: read + +on: + push: + pull_request: + branches: + - 'main' + +jobs: + lint-docs: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Lint PHP documentation + uses: sudo-bot/action-doctum@v5 + with: + config-file: scripts/doctum.php + method: 'parse' + cli-args: '--output-format=github --no-ansi --no-progress -v --ignore-parse-errors' diff --git a/.github/workflows/docs-publish.yml b/.github/workflows/docs-publish.yml new file mode 100644 index 000000000..d0da12f8b --- /dev/null +++ b/.github/workflows/docs-publish.yml @@ -0,0 +1,38 @@ +name: Publish API documentation + +permissions: + contents: read + +on: + repository_dispatch: + types: [ run-build-api-docs ] + workflow_dispatch: + schedule: + - cron: "00 12 * * *" + +jobs: + publish-pages: + environment: + name: API documentation + url: https://php-webdriver.github.io/php-webdriver/ + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ssh-key: ${{ secrets.SSH_KEY_DEPLOY }} + + - name: Build PHP documentation + uses: sudo-bot/action-doctum@v5 + with: + config-file: scripts/doctum.php + method: 'update' + cli-args: '--output-format=github --no-ansi --no-progress -v --ignore-parse-errors' + + - name: Set commit author + run: | + git config user.name "Automated" + git config user.email "actions@users.noreply.github.com" + - name: Push the changes + run: ./scripts/update-built-docs.sh diff --git a/.github/workflows/no-response.yaml b/.github/workflows/no-response.yaml new file mode 100644 index 000000000..7147e3792 --- /dev/null +++ b/.github/workflows/no-response.yaml @@ -0,0 +1,29 @@ +name: No Response + +permissions: + issues: write + +# Both `issue_comment` and `scheduled` event types are required for this Action to work properly. +on: + issue_comment: + types: [created] + schedule: + - cron: '* */8 * * *' # every hour at :33 + +jobs: + noResponse: + runs-on: ubuntu-latest + steps: + - uses: lee-dohm/no-response@v0.5.0 + with: + token: ${{ github.token }} + daysUntilClose: 14 + responseRequiredLabel: 'waiting for reaction' + closeComment: > + This issue has been automatically closed because there has been no response + to our request for more information from the original author. With only the + information that is currently in the issue, we don't have enough information + to take action. + + If the original issue author adds comment with more information, + this issue will be automatically reopened and we can investigate further. diff --git a/.github/workflows/sauce-labs.yaml b/.github/workflows/sauce-labs.yaml new file mode 100644 index 000000000..812fbfe25 --- /dev/null +++ b/.github/workflows/sauce-labs.yaml @@ -0,0 +1,74 @@ +name: Sauce Labs + +permissions: + contents: read + +on: + push: + schedule: + - cron: '0 3 * * *' + +jobs: + tests: + runs-on: ubuntu-latest + # Source: https://github.community/t/do-not-run-cron-workflows-in-forks/17636/2 + if: (github.event_name == 'schedule' && github.repository == 'php-webdriver/php-webdriver') || (github.event_name != 'schedule') + env: + SAUCELABS: 1 + SAUCE_USERNAME: ${{ secrets.SAUCE_USERNAME }} + SAUCE_ACCESS_KEY: ${{ secrets.SAUCE_ACCESS_KEY }} + strategy: + fail-fast: false + matrix: + include: + # Chrome 74 is the last version which doesn't use W3C WebDriver by default and rather use OSS protocol + - { name: "Chrome 74, OSS protocol", BROWSER_NAME: "chrome", VERSION: "74.0", PLATFORM: "Windows 11", w3c: false, tunnel-name: "gh-1-chrome-oss-legacy" } + - { name: "Chrome latest, W3C protocol", BROWSER_NAME: "chrome", VERSION: "latest", PLATFORM: "Windows 11", w3c: true, tunnel-name: "gh-2-chrome-w3c" } + - { name: "Edge latest, W3C protocol", BROWSER_NAME: "MicrosoftEdge", VERSION: "latest", PLATFORM: "Windows 11", w3c: true, tunnel-name: "gh-3-MicrosoftEdge" } + + name: ${{ matrix.name }} (${{ matrix.tunnel-name }}) + steps: + - uses: actions/checkout@v6 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: mbstring, intl, zip + coverage: none + + - name: Install PHP dependencies + run: composer update --no-interaction + + - name: Start local PHP server + run: | + php -S 127.0.0.1:8000 -t tests/functional/web/ &>>./logs/php-server.log & + + - name: Start Sauce Connect + uses: saucelabs/sauce-connect-action@v3 + with: + username: ${{ secrets.SAUCE_USERNAME }} + accessKey: ${{ secrets.SAUCE_ACCESS_KEY }} + tunnelName: ${{ matrix.tunnel-name }} + proxyLocalhost: allow + region: 'us-west-1' + + - name: Run tests + env: + BROWSER_NAME: ${{ matrix.BROWSER_NAME }} + VERSION: ${{ matrix.VERSION }} + PLATFORM: ${{ matrix.PLATFORM }} + DISABLE_W3C_PROTOCOL: "${{ matrix.w3c && '0' || '1' }}" + SAUCE_TUNNEL_NAME: ${{ matrix.tunnel-name }} + run: | + if [ -n "$SAUCELABS" ]; then EXCLUDE_GROUP+="exclude-saucelabs,"; fi + if [ "$BROWSER_NAME" = "MicrosoftEdge" ]; then EXCLUDE_GROUP+="exclude-edge,"; fi + if [ "$BROWSER_NAME" = "firefox" ]; then EXCLUDE_GROUP+="exclude-firefox,"; fi + if [ "$BROWSER_NAME" = "chrome" ]; then EXCLUDE_GROUP+="exclude-chrome,"; fi + if [ -n "$EXCLUDE_GROUP" ]; then EXTRA_PARAMS+=" --exclude-group $EXCLUDE_GROUP"; fi + ./vendor/bin/phpunit --testsuite functional $EXTRA_PARAMS + + - name: Print logs + if: ${{ always() }} + run: | + if [ -f ./logs/php-server.log ]; then cat ./logs/php-server.log; fi diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 000000000..d215e92ce --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,214 @@ +name: Tests + +permissions: + contents: read + +on: + push: + pull_request: + schedule: + - cron: '0 3 * * *' + +jobs: + analyze: + name: "Code style and static analysis" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: mbstring, intl, zip + + - name: Install PHP dependencies + run: composer update --no-interaction + + - name: Lint + run: composer lint + + - name: Run analysis + run: composer analyze + + markdown-link-check: + name: "Markdown link check" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: tcort/github-action-markdown-link-check@v1 + with: + use-verbose-mode: 'yes' + + unit-tests: + name: "Unit tests" + runs-on: ubuntu-latest + + strategy: + matrix: + php-version: ['7.3', '7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5'] + dependencies: [''] + include: + - { php-version: '7.3', dependencies: '--prefer-lowest' } + + steps: + - uses: actions/checkout@v6 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + extensions: mbstring, intl, zip + coverage: xdebug + ini-values: ${{ matrix.xdebug-ini-values }} + + - name: Install PHP dependencies + run: composer update --no-interaction ${{ matrix.dependencies }} + + - name: Run tests + run: vendor/bin/phpunit --testsuite unit --colors=always --coverage-clover ./logs/clover.xml + + - name: Submit coverage to Coveralls + # We use php-coveralls library for this, as the official Coveralls GitHub Action lacks support for clover reports: + # https://github.com/coverallsapp/github-action/issues/15 + env: + COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} + COVERALLS_PARALLEL: true + COVERALLS_FLAG_NAME: ${{ github.job }}-PHP-${{ matrix.php-version }} ${{ matrix.dependencies }} + run: | + composer global require php-coveralls/php-coveralls + ~/.composer/vendor/bin/php-coveralls -v + + functional-tests: + runs-on: ${{ matrix.os }} + env: + SELENIUM_SERVER_DOWNLOAD_URL: https://github.com/SeleniumHQ/selenium/releases/download/selenium-4.38.0/selenium-server-4.38.0.jar + + strategy: + fail-fast: false + matrix: + os: ['ubuntu-latest'] + browser: ['chrome', 'firefox'] + selenium-server: [true, false] # Whether to run via Selenium server or directly via browser driver + w3c: [true] # Although all builds negotiate protocol by default, it implies W3C protocol for both Chromedriver and Geckodriver + include: + - { browser: 'safari', os: 'macos-latest', selenium-server: false, w3c: true } + # Force OSS (JsonWire) protocol on ChromeDriver - to make sure we keep compatibility: + - { browser: 'chrome', os: 'ubuntu-latest', selenium-server: false, w3c: false } + + name: "Functional tests (${{ matrix.browser }}, Selenium server: ${{ matrix.selenium-server }}, W3C: ${{ matrix.w3c }})" + + steps: + - uses: actions/checkout@v6 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: mbstring, intl, zip + coverage: xdebug + + - name: Install PHP dependencies + run: composer update --no-interaction + + - name: Start Selenium standalone server + # If you want to run your Selenium WebDriver tests on GitHub actions, we recommend using service containers + # with eg. selenium/standalone-chrome image. See https://docs.github.com/en/actions/guides/about-service-containers + # But for the purpose of testing this library itself, we need more control, so we set everything up manually. + if: ${{ matrix.selenium-server }} + run: | + mkdir -p build logs + wget -q -t 3 -O build/selenium-server.jar $SELENIUM_SERVER_DOWNLOAD_URL + java -jar build/selenium-server.jar standalone --version + xvfb-run --server-args="-screen 0, 1280x720x24" --auto-servernum java -jar build/selenium-server.jar standalone --log logs/selenium-server.log & + + - name: Start ChromeDriver + if: ${{ !matrix.selenium-server && matrix.browser == 'chrome' }} + run: | + google-chrome --version + xvfb-run --server-args="-screen 0, 1280x720x24" --auto-servernum \ + chromedriver --port=4444 &> ./logs/chromedriver.log & + + - name: Start GeckoDriver + if: ${{ !matrix.selenium-server && matrix.browser == 'firefox' }} + run: | + firefox --version + geckodriver --version + xvfb-run --server-args="-screen 0, 1280x720x24" --auto-servernum \ + geckodriver &> ./logs/geckodriver.log & + + - name: Start SafariDriver + if: ${{ !matrix.selenium-server && matrix.browser == 'safari' }} + run: | + defaults read /Applications/Safari.app/Contents/Info CFBundleShortVersionString + /usr/bin/safaridriver -p 4444 --diagnose & + + - name: Start local PHP server + run: | + php -S 127.0.0.1:8000 -t tests/functional/web/ &> ./logs/php-server.log & + + - name: Wait for browser & PHP to start + timeout-minutes: 1 + run: | + while ! nc -z localhost 4444 ./data/run_id + - uses: actions/upload-artifact@v6 + with: + name: data + path: data/ diff --git a/.gitignore b/.gitignore index 2444076bd..a9384110d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,13 @@ composer.phar composer.lock vendor +tools/php-cs-fixer/vendor .php_cs.cache +.php-cs-fixer.cache +.phpunit.result.cache +phpunit.xml +logs/ +build/ # generic files to ignore *.lock @@ -9,3 +15,4 @@ vendor *~ *.swp .idea +.vscode diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 000000000..f754fb8b7 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,139 @@ +notPath('Firefox/FirefoxProfile.php') // need to use str_* instead of mb_str_* methods + ->in([__DIR__ . '/lib', __DIR__ . '/tests']); + +return (new PhpCsFixer\Config()) + ->setRules([ + '@PSR2' => true, + 'array_syntax' => ['syntax' => 'short'], + 'binary_operator_spaces' => true, + 'blank_line_before_statement' => ['statements' => ['return', 'try']], + 'braces' => ['allow_single_line_anonymous_class_with_empty_body' => true, 'allow_single_line_closure' => true], + 'cast_spaces' => true, + 'class_attributes_separation' => ['elements' => ['method' => 'one', 'trait_import' => 'none']], + 'clean_namespace' => true, + 'combine_nested_dirname' => true, + 'compact_nullable_typehint' => true, + 'concat_space' => ['spacing' => 'one'], + 'declare_equal_normalize' => true, + //'declare_strict_types' => true, // TODO: used only in tests, use in lib in next major version + 'fopen_flag_order' => true, + 'fopen_flags' => true, + 'full_opening_tag' => true, + 'function_typehint_space' => true, + 'heredoc_indentation' => ['indentation' => 'same_as_start'], + 'implode_call' => true, + 'is_null' => true, + 'lambda_not_used_import' => true, + 'list_syntax' => true, + 'lowercase_cast' => true, + 'lowercase_static_reference' => true, + 'magic_constant_casing' => true, + 'magic_method_casing' => true, + 'mb_str_functions' => true, + 'method_argument_space' => ['after_heredoc' => true], + 'native_function_casing' => true, + 'native_function_type_declaration_casing' => true, + 'new_with_braces' => true, + 'no_alias_functions' => true, + 'no_blank_lines_after_class_opening' => true, + 'no_blank_lines_after_phpdoc' => true, + 'no_empty_comment' => true, + 'no_empty_phpdoc' => true, + 'no_empty_statement' => true, + 'normalize_index_brace' => true, + 'no_extra_blank_lines' => [ + 'tokens' => [ + 'break', + 'case', + 'continue', + 'curly_brace_block', + 'default', + 'extra', + 'parenthesis_brace_block', + 'return', + 'square_brace_block', + 'switch', + 'throw', + 'use', + ], + ], + 'no_leading_import_slash' => true, + 'no_leading_namespace_whitespace' => true, + 'no_singleline_whitespace_before_semicolons' => true, + 'no_superfluous_phpdoc_tags' => [ + 'allow_mixed' => true, + 'remove_inheritdoc' => true, + 'allow_unused_params' => true, // Used in RemoteWebDriver::createBySessionID to maintain BC + ], + 'no_trailing_comma_in_singleline' => true, + 'no_unreachable_default_argument_value' => true, + 'no_unused_imports' => true, + 'no_useless_else' => true, + 'no_useless_return' => true, + 'no_useless_sprintf' => true, + 'no_whitespace_before_comma_in_array' => ['after_heredoc' => true], + 'no_whitespace_in_blank_line' => true, + 'non_printable_character' => true, + 'nullable_type_declaration' => [ + 'syntax' => 'question_mark', + ], + 'nullable_type_declaration_for_default_null_value' => true, + 'object_operator_without_whitespace' => true, + 'ordered_class_elements' => true, + 'ordered_imports' => true, + 'php_unit_construct' => true, + 'php_unit_dedicate_assert' => true, + 'php_unit_dedicate_assert_internal_type' => true, + 'php_unit_expectation' => ['target' => '8.4'], + 'php_unit_method_casing' => ['case' => 'camel_case'], + 'php_unit_mock_short_will_return' => true, + 'php_unit_mock' => true, + 'php_unit_namespaced' => ['target' => '6.0'], + 'php_unit_no_expectation_annotation' => true, + 'php_unit_set_up_tear_down_visibility' => true, + 'php_unit_test_case_static_method_calls' => ['call_type' => 'this'], + 'phpdoc_add_missing_param_annotation' => true, + 'phpdoc_indent' => true, + 'phpdoc_no_access' => true, + // 'phpdoc_no_empty_return' => true, // disabled to allow forward compatibility with PHP 8.1 + 'phpdoc_no_package' => true, + 'phpdoc_order_by_value' => ['annotations' => ['covers', 'group', 'throws']], + 'phpdoc_order' => true, + 'phpdoc_return_self_reference' => true, + 'phpdoc_scalar' => true, + 'phpdoc_single_line_var_spacing' => true, + 'phpdoc_trim' => true, + //'phpdoc_to_param_type' => true, // TODO: used only in tests, use in lib in next major version + //'phpdoc_to_return_type' => true, // TODO: used only in tests, use in lib in next major version + 'phpdoc_types' => true, + 'phpdoc_var_annotation_correct_order' => true, + 'pow_to_exponentiation' => true, + 'psr_autoloading' => true, + 'random_api_migration' => true, + 'self_accessor' => true, + 'set_type_to_cast' => true, + 'short_scalar_cast' => true, + 'single_blank_line_before_namespace' => true, + 'single_quote' => true, + 'single_space_after_construct' => true, + 'single_trait_insert_per_statement' => true, + 'space_after_semicolon' => true, + 'standardize_not_equals' => true, + 'strict_param' => true, + 'switch_continue_to_break' => true, + 'ternary_operator_spaces' => true, + 'ternary_to_elvis_operator' => true, + 'ternary_to_null_coalescing' => true, + 'trailing_comma_in_multiline' => ['elements' => ['arrays'], 'after_heredoc' => true], + 'trim_array_spaces' => true, + 'unary_operator_spaces' => true, + 'visibility_required' => ['elements' => ['method', 'property', 'const']], + //'void_return' => true, // TODO: used only in tests, use in lib in next major version + 'whitespace_after_comma_in_array' => true, + 'yoda_style' => ['equal' => false, 'identical' => false, 'less_and_greater' => false], + ]) + ->setRiskyAllowed(true) + ->setFinder($finder); diff --git a/.php_cs b/.php_cs deleted file mode 100644 index 0466edfbe..000000000 --- a/.php_cs +++ /dev/null @@ -1,65 +0,0 @@ -in([__DIR__ . '/lib', __DIR__ . '/tests']); - -return Symfony\CS\Config\Config::create() - ->fixers([ - 'array_element_white_space_after_comma', - 'duplicate_semicolon', - 'extra_empty_lines', - 'function_typehint_space', - 'lowercase_cast', - 'method_argument_default_value', - 'multiline_array_trailing_comma', - 'namespace_no_leading_whitespace', - 'native_function_casing', - 'new_with_braces', - 'no_blank_lines_after_class_opening', - 'no_empty_lines_after_phpdocs', - 'no_empty_phpdoc', - 'no_empty_statement', - 'object_operator', - 'operators_spaces', - 'trim_array_spaces', - 'phpdoc_indent', - 'phpdoc_no_access', - 'phpdoc_no_empty_return', - 'phpdoc_no_package', - 'phpdoc_scalar', - 'phpdoc_single_line_var_spacing', - 'phpdoc_trim', - 'phpdoc_types', - 'phpdoc_order', - 'unused_use', - 'ordered_use', - 'remove_leading_slash_use', - 'remove_lines_between_uses', - 'return', - 'self_accessor', - 'single_array_no_trailing_comma', - 'single_blank_line_before_namespace', - 'single_quote', - 'spaces_after_semicolon', - 'spaces_before_semicolon', - 'spaces_cast', - 'standardize_not_equal', - 'ternary_spaces', - 'trim_array_spaces', - 'unary_operators_spaces', - 'unused_use', - 'whitespacy_lines', - - // additional contrib checks - 'concat_with_spaces', - 'newline_after_open_tag', - 'no_useless_else', - 'no_useless_return', - 'php_unit_construct', - 'php_unit_dedicate_assert', - 'phpdoc_order', - 'short_array_syntax', - ]) - ->level(Symfony\CS\FixerInterface::PSR2_LEVEL) - ->setUsingCache(true) - ->finder($finder); diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index facb0d4db..000000000 --- a/.travis.yml +++ /dev/null @@ -1,42 +0,0 @@ -language: php - -php: - - 5.5 - - 5.6 - - 7 - - 7.1 - - hhvm - -matrix: - include: - # Add PHP 7 build to check codestyle only in PHP 7 build - - php: 7 - env: CHECK_CODESTYLE=1 - -env: - global: - - DISPLAY=:99.0 - -cache: - directories: - - $HOME/.composer/cache - -before_install: - - travis_retry composer self-update - -install: - - travis_retry composer install --no-interaction --prefer-source - -before_script: - - if [ -z "$CHECK_CODESTYLE" ]; then sh -e /etc/init.d/xvfb start; fi - - if [ -z "$CHECK_CODESTYLE" ]; then wget -q -t 3 http://selenium-release.storage.googleapis.com/2.45/selenium-server-standalone-2.45.0.jar; fi - - if [ -z "$CHECK_CODESTYLE" ]; then java -jar selenium-server-standalone-2.45.0.jar -log selenium.log; fi & - - if [ -z "$CHECK_CODESTYLE" ]; then until $(echo | nc localhost 4444); do sleep 1; echo waiting for selenium-server...; done; fi - -script: - - if [ -n "$CHECK_CODESTYLE" ]; then ./vendor/bin/php-cs-fixer fix --diff --dry-run; fi - - if [ -n "$CHECK_CODESTYLE" ]; then ./vendor/bin/phpcs --standard=PSR2 ./lib/ ./tests/; fi - - if [ -z "$CHECK_CODESTYLE" ]; then ./vendor/bin/phpunit; fi - -after_script: - - cat selenium.log diff --git a/CHANGELOG.md b/CHANGELOG.md index f89047b65..a468c26f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,26 +3,264 @@ This project versioning adheres to [Semantic Versioning](http://semver.org/). ## Unreleased +## 1.15.2 - 2024-11-21 +### Fixed +- PHP 8.4 deprecation notices, especially in nullable type-hints. +- Docs: Fix static return types in RemoteWebElement phpDoc. +- Tests: Disable chrome 127+ search engine pop-up in tests +- Tests: Enable Shadow DOM tests in Geckodriver + +### Added +- Tests: Allow running tests in headfull (not headless) mode using `DISABLE_HEADLESS` environment variable. + +### Changed +- Docs: Update selenium server host URL in example. + +## 1.15.1 - 2023-10-20 +- Update `symfony/process` dependency to support upcoming Symfony 7. + +## 1.15.0 - 2023-08-29 +### Changed +- Capability key `ChromeOptions::CAPABILITY_W3C` used to set ChromeOptions is now deprecated in favor of `ChromeOptions::CAPABILITY`, which now also contains the W3C compatible value (`goog:chromeOptions`). +- ChromeOptions are now passed to the driver always as a W3C compatible key `goog:chromeOptions`, even in the deprecated OSS JsonWire payload (as ChromeDriver [supports](https://bugs.chromium.org/p/chromedriver/issues/detail?id=1786) this since 2017). +- Improve Safari compatibility for `` by its partial text (using `selectByVisiblePartialText()`). +- `XPathEscaper` helper class to quote XPaths containing both single and double quotes. +- `WebDriverSelectInterface`, to allow implementation of custom select-like components, eg. those not built around and actual select tag. + +### Changed +- `Symfony\Process` is used to start local WebDriver processes (when browsers are run directly, without Selenium server) to workaround some PHP bugs and improve portability. +- Clarified meaning of selenium server URL variable in methods of `RemoteWebDriver` class. +- Deprecated `setSessionID()` and `setCommandExecutor()` methods of `RemoteWebDriver` class; these values should be immutable and thus passed only via constructor. +- Deprecated `WebDriverExpectedCondition::textToBePresentInElement()` in favor of `elementTextContains()`. +- Throw an exception when attempting to deselect options of non-multiselect (it already didn't have any effect, but was silently ignored). +- Optimize performance of `(de)selectByIndex()` and `getAllSelectedOptions()` methods of `WebDriverSelect` when used with non-multiple select element. + +### Fixed +- XPath escaping in `select*()` and `deselect*()` methods of `WebDriverSelect`. + ## 1.2.0 - 2016-10-14 -- Added initial support of remote Microsoft Edge browser (but starting local EdgeDriver is still not supported) -- Utilize late static binding to make eg. `WebDriverBy` and `DesiredCapabilities` classes easily extensible -- PHP version at least 5.5 is required -- Fixed incompatibility with Appium, caused by redundant params present in requests to Selenium server +- Added initial support of remote Microsoft Edge browser (but starting local EdgeDriver is still not supported). +- Utilize late static binding to make eg. `WebDriverBy` and `DesiredCapabilities` classes easily extensible. +- PHP version at least 5.5 is required. +- Fixed incompatibility with Appium, caused by redundant params present in requests to Selenium server. ## 1.1.3 - 2016-08-10 -- Fixed FirefoxProfile to support installation of extensions with custom namespace prefix in their manifest file -- Comply codestyle with [PSR-2](http://www.php-fig.org/psr/psr-2/) +- Fixed FirefoxProfile to support installation of extensions with custom namespace prefix in their manifest file. +- Comply codestyle with [PSR-2](http://www.php-fig.org/psr/psr-2/). ## 1.1.2 - 2016-06-04 -- Added ext-curl to composer.json -- Added CHANGELOG.md -- Added CONTRIBUTING.md with information and rules for contributors +- Added ext-curl to composer.json. +- Added CHANGELOG.md. +- Added CONTRIBUTING.md with information and rules for contributors. ## 1.1.1 - 2015-12-31 -- Fixed strict standards error in `ChromeDriver` -- Added unit tests for `WebDriverCommand` and `DesiredCapabilities` -- Fixed retrieving temporary path name in `FirefoxDriver` when `open_basedir` restriction is in effect +- Fixed strict standards error in `ChromeDriver`. +- Added unit tests for `WebDriverCommand` and `DesiredCapabilities`. +- Fixed retrieving temporary path name in `FirefoxDriver` when `open_basedir` restriction is in effect. ## 1.1.0 - 2015-12-08 -- FirefoxProfile improved - added possibility to set RDF file and to add datas for extensions -- Fixed setting 0 second timeout of `WebDriverWait` +- FirefoxProfile improved - added possibility to set RDF file and to add datas for extensions. +- Fixed setting 0 second timeout of `WebDriverWait`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index a73c9eafd..000000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,43 +0,0 @@ -# Contributing to php-webdriver - -We love to have your help to make php-webdriver better! - -Feel free to open an [issue](https://github.com/facebook/php-webdriver/issues) if you run into any problem, or -send a pull request (see bellow) with your contribution. - -## Workflow when contributing a patch - -1. Fork the project on GitHub -2. Implement your code changes into separate branch -3. Make sure all PHPUnit tests passes and code-style matches PSR-2 (see below). We also have Travis CI build which will automatically run tests on your pull request. -4. When implementing notable change, fix or a new feature, add record to Unreleased section of [CHANGELOG.md](CHANGELOG.md) -5. Submit your [pull request](https://github.com/facebook/php-webdriver/pulls) against community branch - -Note before any pull request can be accepted, a [Contributors Licensing Agreement](http://developers.facebook.com/opensource/cla) must be signed. - -When you are going to contribute, please keep in mind that this webdriver client aims to be as close as possible to other languages Java/Ruby/Python/C#. -FYI, here is the overview of [the official Java API](http://seleniumhq.github.io/selenium/docs/api/java/) - -### Run unit tests - -There are two test-suites: one with unit tests only, second with functional tests, which require running selenium server. - -To execute all tests simply run: - - ./vendor/bin/phpunit - -If you want to execute just the unit tests, run: - - ./vendor/bin/phpunit --testsuite unit - -For the functional tests you must first download and start the selenium server, then run the `functional` test suite: - - java -jar selenium-server-standalone-2.48.2.jar -log selenium.log & - ./vendor/bin/phpunit --testsuite functional - -### Check coding style - -Your code-style should comply with [PSR-2](http://www.php-fig.org/psr/psr-2/). To make sure your code matches this requirement run: - - ./vendor/bin/php-cs-fixer fix --diff --dry-run - ./vendor/bin/phpcs --standard=PSR2 ./lib/ ./tests/ diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 000000000..611fa7390 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2004-2020 Facebook +Copyright (c) 2020-present [open-source contributors](https://github.com/php-webdriver/php-webdriver/graphs/contributors) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 8c3e2370a..f3e8d3801 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,20 @@ # php-webdriver – Selenium WebDriver bindings for PHP -[![Latest Stable Version](https://img.shields.io/packagist/v/facebook/webdriver.svg?style=flat-square)](https://packagist.org/packages/facebook/webdriver) -[![Total Downloads](https://img.shields.io/packagist/dt/facebook/webdriver.svg?style=flat-square)](https://packagist.org/packages/facebook/webdriver) -[![License](https://img.shields.io/packagist/l/facebook/webdriver.svg?style=flat-square)](https://packagist.org/packages/facebook/webdriver) +[![Latest stable version](https://img.shields.io/packagist/v/php-webdriver/webdriver.svg?style=flat-square&label=Packagist)](https://packagist.org/packages/php-webdriver/webdriver) +[![GitHub Actions build status](https://img.shields.io/github/actions/workflow/status/php-webdriver/php-webdriver/tests.yaml?style=flat-square&label=GitHub%20Actions)](https://github.com/php-webdriver/php-webdriver/actions) +[![SauceLabs test status](https://img.shields.io/github/actions/workflow/status/php-webdriver/php-webdriver/sauce-labs.yaml?style=flat-square&label=SauceLabs)](https://saucelabs.com/u/php-webdriver) +[![Total downloads](https://img.shields.io/packagist/dd/php-webdriver/webdriver.svg?style=flat-square&label=Downloads)](https://packagist.org/packages/php-webdriver/webdriver) ## Description Php-webdriver library is PHP language binding for Selenium WebDriver, which allows you to control web browsers from PHP. -This WebDriver client aims to be as close as possible to bindings in other languages. -The concepts are very similar to the Java, .NET, Python and Ruby bindings for WebDriver. +This library is compatible with Selenium server version 2.x, 3.x and 4.x. -This is new version of PHP client, rewritten from scratch starting 2013. -Using the old version? Check out Adam Goucher's fork of it at https://github.com/Element-34/php-webdriver +The library supports modern [W3C WebDriver](https://w3c.github.io/webdriver/) protocol, as well +as legacy [JsonWireProtocol](https://www.selenium.dev/documentation/legacy/json_wire_protocol/). -Looking for API documentation of php-webdriver? See http://facebook.github.io/php-webdriver/ - -Any complaint, question, idea? You can post it on the user group https://www.facebook.com/groups/phpwebdriver/. +The concepts of this library are very similar to the "official" Java, JavaScript, .NET, Python and Ruby libraries +which are developed as part of the [Selenium project](https://github.com/SeleniumHQ/selenium/). ## Installation @@ -27,65 +26,203 @@ If you don't already use Composer, you can download the `composer.phar` binary: Then install the library: - php composer.phar require facebook/webdriver + php composer.phar require php-webdriver/webdriver + +## Upgrade from version <1.8.0 + +Starting from version 1.8.0, the project has been renamed from `facebook/php-webdriver` to `php-webdriver/webdriver`. + +In order to receive the new version and future updates, **you need to rename it in your composer.json**: + +```diff +"require": { +- "facebook/webdriver": "(version you use)", ++ "php-webdriver/webdriver": "(version you use)", +} +``` + +and run `composer update`. ## Getting started -All you need as the server for this client is the `selenium-server-standalone-#.jar` file provided here: http://selenium-release.storage.googleapis.com/index.html +### 1. Start server (aka. remote end) + +To control a browser, you need to start a *remote end* (server), which will listen to the commands sent +from this library and will execute them in the respective browser. + +This could be Selenium standalone server, but for local development, you can send them directly to so-called "browser driver" like Chromedriver or Geckodriver. + +#### a) Chromedriver + +📙 Below you will find a simple example. Make sure to read our wiki for [more information on Chrome/Chromedriver](https://github.com/php-webdriver/php-webdriver/wiki/Chrome). + +Install the latest Chrome and [Chromedriver](https://sites.google.com/chromium.org/driver/downloads). +Make sure to have a compatible version of Chromedriver and Chrome! + +Run `chromedriver` binary, you can pass `port` argument, so that it listens on port 4444: + +```sh +chromedriver --port=4444 +``` + +#### b) Geckodriver + +📙 Below you will find a simple example. Make sure to read our wiki for [more information on Firefox/Geckodriver](https://github.com/php-webdriver/php-webdriver/wiki/Firefox). + +Install the latest Firefox and [Geckodriver](https://github.com/mozilla/geckodriver/releases). +Make sure to have a compatible version of Geckodriver and Firefox! + +Run `geckodriver` binary (it start to listen on port 4444 by default): + +```sh +geckodriver +``` + +#### c) Selenium standalone server + +Selenium server can be useful when you need to execute multiple tests at once, +when you run tests in several different browsers (like on your CI server), or when you need to distribute tests amongst +several machines in grid mode (where one Selenium server acts as a hub, and others connect to it as nodes). + +Selenium server then act like a proxy and takes care of distributing commands to the respective nodes. + +The latest version can be found on the [Selenium download page](https://www.selenium.dev/downloads/). -Download and run that file, replacing # with the current server version. +📙 You can find [further Selenium server information](https://github.com/php-webdriver/php-webdriver/wiki/Selenium-server) +in our wiki. - java -jar selenium-server-standalone-#.jar +#### d) Docker -Then when you create a session, be sure to pass the url to where your server is running. +Selenium server could also be started inside Docker container - see [docker-selenium project](https://github.com/SeleniumHQ/docker-selenium). + +### 2. Create a Browser Session + +When creating a browser session, be sure to pass the url of your running server. + +For example: + +```php +// Chromedriver (if started using --port=4444 as above) +$serverUrl = 'http://localhost:4444/'; +// Geckodriver +$serverUrl = 'http://localhost:4444/'; +// selenium-server-standalone-#.jar (version 2.x or 3.x) +$serverUrl = 'http://localhost:4444/wd/hub'; +// selenium-server-standalone-#.jar (version 4.x) +$serverUrl = 'http://localhost:4444/'; +``` + +Now you can start browser of your choice: ```php -// This would be the url of the host running the server-standalone.jar -$host = 'http://localhost:4444/wd/hub'; // this is the default +use Facebook\WebDriver\Remote\RemoteWebDriver; + +// Chrome +$driver = RemoteWebDriver::create($serverUrl, DesiredCapabilities::chrome()); +// Firefox +$driver = RemoteWebDriver::create($serverUrl, DesiredCapabilities::firefox()); +// Microsoft Edge +$driver = RemoteWebDriver::create($serverUrl, DesiredCapabilities::microsoftEdge()); ``` -* Launch Firefox: +### 3. Customize Desired Capabilities + +Desired capabilities define properties of the browser you are about to start. + +They can be customized: + +```php +use Facebook\WebDriver\Firefox\FirefoxOptions; +use Facebook\WebDriver\Remote\DesiredCapabilities; + +$desiredCapabilities = DesiredCapabilities::firefox(); - ```php - $driver = RemoteWebDriver::create($host, DesiredCapabilities::firefox()); - ``` +// Disable accepting SSL certificates +$desiredCapabilities->setCapability('acceptSslCerts', false); -* Launch Chrome: +// Add arguments via FirefoxOptions to start headless firefox +$firefoxOptions = new FirefoxOptions(); +$firefoxOptions->addArguments(['-headless']); +$desiredCapabilities->setCapability(FirefoxOptions::CAPABILITY, $firefoxOptions); - ```php - $driver = RemoteWebDriver::create($host, DesiredCapabilities::chrome()); - ``` +$driver = RemoteWebDriver::create($serverUrl, $desiredCapabilities); +``` + +Capabilities can also be used to [📙 configure a proxy server](https://github.com/php-webdriver/php-webdriver/wiki/HowTo-Work-with-proxy) which the browser should use. + +To configure browser-specific capabilities, you may use [📙 ChromeOptions](https://github.com/php-webdriver/php-webdriver/wiki/Chrome#chromeoptions) +or [📙 FirefoxOptions](https://github.com/php-webdriver/php-webdriver/wiki/Firefox#firefoxoptions). -You can also customize the desired capabilities: +* See [legacy JsonWire protocol](https://github.com/SeleniumHQ/selenium/wiki/DesiredCapabilities) documentation or [W3C WebDriver specification](https://w3c.github.io/webdriver/#capabilities) for more details. + +### 4. Control your browser ```php -$desired_capabilities = DesiredCapabilities::firefox(); -$desired_capabilities->setCapability('acceptSslCerts', false); -$driver = RemoteWebDriver::create($host, $desired_capabilities); +// Go to URL +$driver->get('https://en.wikipedia.org/wiki/Selenium_(software)'); + +// Find search element by its id, write 'PHP' inside and submit +$driver->findElement(WebDriverBy::id('searchInput')) // find search input element + ->sendKeys('PHP') // fill the search box + ->submit(); // submit the whole form + +// Find element of 'History' item in menu by its css selector +$historyButton = $driver->findElement( + WebDriverBy::cssSelector('#ca-history a') +); +// Read text of the element and print it to output +echo 'About to click to a button with text: ' . $historyButton->getText(); + +// Click the element to navigate to revision history page +$historyButton->click(); + +// Make sure to always call quit() at the end to terminate the browser session +$driver->quit(); ``` -* See https://github.com/SeleniumHQ/selenium/wiki/DesiredCapabilities for more details. +See [example.php](example.php) for full example scenario. +Visit our GitHub wiki for [📙 php-webdriver command reference](https://github.com/php-webdriver/php-webdriver/wiki/Example-command-reference) and further examples. -* Above snippets are not intended to be a working example by simply copy pasting. See [example.php](example.php) for working example. +**NOTE:** Above snippets are not intended to be a working example by simply copy-pasting. See [example.php](example.php) for a working example. ## Changelog For latest changes see [CHANGELOG.md](CHANGELOG.md) file. ## More information -Check out the Selenium docs and wiki at http://docs.seleniumhq.org/docs/ and https://code.google.com/p/selenium/wiki +Some basic usage example is provided in [example.php](example.php) file. + +How-tos are provided right here in [📙 our GitHub wiki](https://github.com/php-webdriver/php-webdriver/wiki). + +If you don't use IDE, you may use [API documentation of php-webdriver](https://php-webdriver.github.io/php-webdriver/latest/). -Learn how to integrate it with PHPUnit [Blogpost](http://codeception.com/11-12-2013/working-with-phpunit-and-selenium-webdriver.html) | [Demo Project](https://github.com/DavertMik/php-webdriver-demo) +You may also want to check out the Selenium project [docs](https://selenium.dev/documentation/en/) and [wiki](https://github.com/SeleniumHQ/selenium/wiki). + +## Testing framework integration + +To take advantage of automatized testing you may want to integrate php-webdriver to your testing framework. +There are some projects already providing this: + +- [Symfony Panther](https://github.com/symfony/panther) uses php-webdriver and integrates with PHPUnit using `PantherTestCase` +- [Laravel Dusk](https://laravel.com/docs/dusk) is another project using php-webdriver, could be used for testing via `DuskTestCase` +- [Steward](https://github.com/lmc-eu/steward) integrates php-webdriver directly to [PHPUnit](https://phpunit.de/), and provides parallelization +- [Codeception](https://codeception.com/) testing framework provides BDD-layer on top of php-webdriver in its [WebDriver module](https://codeception.com/docs/modules/WebDriver) +- You can also check out this [blogpost](https://codeception.com/11-12-2013/working-with-phpunit-and-selenium-webdriver.html) + [demo project](https://github.com/DavertMik/php-webdriver-demo), describing simple [PHPUnit](https://phpunit.de/) integration ## Support -We have a great community willing to try and help you! +We have a great community willing to help you! + +❓ Do you have a **question, idea or some general feedback**? Visit our [Discussions](https://github.com/php-webdriver/php-webdriver/discussions) page. +(Alternatively, you can [look for many answered questions also on StackOverflow](https://stackoverflow.com/questions/tagged/php+selenium-webdriver)). + +🐛 Something isn't working, and you want to **report a bug**? [Submit it here](https://github.com/php-webdriver/php-webdriver/issues/new) as a new issue. + +📙 Looking for a **how-to** or **reference documentation**? See [our wiki](https://github.com/php-webdriver/php-webdriver/wiki). -- **Via our Facebook Group** - If you have questions or are an active contributor consider joining our [facebook group](https://www.facebook.com/groups/phpwebdriver/) and contributing to the communal discussion and support. -- **Via StackOverflow** - You can also [ask a question](https://stackoverflow.com/questions/ask?tags=php+selenium-webdriver) or find many already answered question on StackOverflow. -- **Via GitHub** - Another option if you have a question (or bug report) is to [submit it here](https://github.com/facebook/php-webdriver/issues/new) as an new issue. +## Contributing ❤️ -## Contributing +We love to have your help to make php-webdriver better. See [CONTRIBUTING.md](.github/CONTRIBUTING.md) for more information about contributing and developing php-webdriver. -We love to have your help to make php-webdriver better. See [CONTRIBUTING.md](CONTRIBUTING.md) for more information -about contributing and developing php-webdriver. +Php-webdriver is community project - if you want to join the effort with maintaining and developing this library, the best is to look on [issues marked with "help wanted"](https://github.com/php-webdriver/php-webdriver/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22) +label. Let us know in the issue comments if you want to contribute and if you want any guidance, and we will be delighted to help you to prepare your pull request. diff --git a/composer.json b/composer.json index a8eb267e0..f44663100 100644 --- a/composer.json +++ b/composer.json @@ -1,38 +1,98 @@ { - "name": "facebook/webdriver", - "description": "A PHP client for WebDriver", - "keywords": ["webdriver", "selenium", "php", "facebook"], - "homepage": "https://github.com/facebook/php-webdriver", + "name": "php-webdriver/webdriver", + "description": "A PHP client for Selenium WebDriver. Previously facebook/webdriver.", + "license": "MIT", "type": "library", - "license": "Apache-2.0", - "support": { - "issues": "https://github.com/facebook/php-webdriver/issues", - "forum": "https://www.facebook.com/groups/phpwebdriver/", - "source": "https://github.com/facebook/php-webdriver" - }, + "keywords": [ + "webdriver", + "selenium", + "php", + "geckodriver", + "chromedriver" + ], + "homepage": "https://github.com/php-webdriver/php-webdriver", "require": { - "php": "^5.5 || ~7.0", - "symfony/process": "^2.8 || ^3.1", - "ext-curl": "*" + "php": "^7.3 || ^8.0", + "ext-curl": "*", + "ext-json": "*", + "ext-zip": "*", + "symfony/polyfill-mbstring": "^1.12", + "symfony/process": "^5.0 || ^6.0 || ^7.0 || ^8.0" }, "require-dev": { - "phpunit/phpunit": "4.6.* || ~5.0", - "friendsofphp/php-cs-fixer": "^1.11", - "squizlabs/php_codesniffer": "^2.6", - "php-mock/php-mock-phpunit": "^1.1" + "ergebnis/composer-normalize": "^2.20.0", + "ondram/ci-detector": "^4.0", + "php-coveralls/php-coveralls": "^2.4", + "php-mock/php-mock-phpunit": "^2.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpunit/phpunit": "^9.3", + "squizlabs/php_codesniffer": "^3.5", + "symfony/var-dumper": "^5.0 || ^6.0 || ^7.0 || ^8.0" + }, + "replace": { + "facebook/webdriver": "*" }, "suggest": { - "phpdocumentor/phpdocumentor": "2.*" + "ext-simplexml": "For Firefox profile creation" }, + "minimum-stability": "dev", + "prefer-stable": true, "autoload": { "psr-4": { "Facebook\\WebDriver\\": "lib/" - } + }, + "files": [ + "lib/Exception/TimeoutException.php" + ] }, "autoload-dev": { "psr-4": { - "Facebook\\WebDriver\\": "tests/unit" + "Facebook\\WebDriver\\": [ + "tests/unit", + "tests/functional" + ] }, - "classmap": ["tests/functional/"] + "classmap": [ + "tests/functional/" + ] + }, + "config": { + "allow-plugins": { + "ergebnis/composer-normalize": true + }, + "sort-packages": true + }, + "scripts": { + "post-install-cmd": [ + "@composer install --working-dir=tools/php-cs-fixer --no-progress --no-interaction", + "@composer install --working-dir=tools/phpstan --no-progress --no-interaction" + ], + "post-update-cmd": [ + "@composer update --working-dir=tools/php-cs-fixer --no-progress --no-interaction", + "@composer update --working-dir=tools/phpstan --no-progress --no-interaction" + ], + "all": [ + "@lint", + "@analyze", + "@test" + ], + "analyze": [ + "@php tools/phpstan/vendor/bin/phpstan analyze -c phpstan.neon --ansi", + "@php tools/php-cs-fixer/vendor/bin/php-cs-fixer fix --diff --dry-run -vvv --ansi", + "@php vendor/bin/phpcs --standard=PSR2 --ignore=*.js ./lib/ ./tests/" + ], + "fix": [ + "@composer normalize", + "@php tools/php-cs-fixer/vendor/bin/php-cs-fixer fix --diff -vvv || exit 0", + "@php vendor/bin/phpcbf --standard=PSR2 --ignore=*.js ./lib/ ./tests/" + ], + "lint": [ + "@php vendor/bin/parallel-lint -j 10 ./lib ./tests example.php", + "@composer validate", + "@composer normalize --dry-run" + ], + "test": [ + "@php vendor/bin/phpunit --colors=always" + ] } } diff --git a/example.php b/example.php index 03b16728e..92cd431d6 100644 --- a/example.php +++ b/example.php @@ -1,5 +1,7 @@ get('http://docs.seleniumhq.org/'); +$capabilities = DesiredCapabilities::chrome(); -// adding cookie -$driver->manage()->deleteAllCookies(); -$driver->manage()->addCookie([ - 'name' => 'cookie_name', - 'value' => 'cookie_value', -]); -$cookies = $driver->manage()->getCookies(); -print_r($cookies); +$driver = RemoteWebDriver::create($host, $capabilities); + +// navigate to Selenium page on Wikipedia +$driver->get('https://en.wikipedia.org/wiki/Selenium_(software)'); + +// write 'PHP' in the search box +$driver->findElement(WebDriverBy::id('searchInput')) // find search input element + ->sendKeys('PHP') // fill the search box + ->submit(); // submit the whole form + +// wait until 'PHP' is shown in the page heading element +$driver->wait()->until( + WebDriverExpectedCondition::elementTextContains(WebDriverBy::id('firstHeading'), 'PHP') +); + +// print title of the current page to output +echo "The title is '" . $driver->getTitle() . "'\n"; + +// print URL of current page to output +echo "The current URL is '" . $driver->getCurrentURL() . "'\n"; -// click the link 'About' -$link = $driver->findElement( - WebDriverBy::id('menu_about') +// find element of 'History' item in menu +$historyButton = $driver->findElement( + WebDriverBy::cssSelector('#ca-history a') +); + +// read text of the element and print it to output +echo "About to click to button with text: '" . $historyButton->getText() . "'\n"; + +// click the element to navigate to revision history page +$historyButton->click(); + +// wait until the target page is loaded +$driver->wait()->until( + WebDriverExpectedCondition::titleContains('Revision history') ); -$link->click(); // print the title of the current page echo "The title is '" . $driver->getTitle() . "'\n"; // print the URI of the current page + echo "The current URI is '" . $driver->getCurrentURL() . "'\n"; -// Search 'php' in the search box -$input = $driver->findElement( - WebDriverBy::id('q') -); -$input->sendKeys('php')->submit(); +// delete all cookies +$driver->manage()->deleteAllCookies(); -// wait at most 10 seconds until at least one result is shown -$driver->wait(10)->until( - WebDriverExpectedCondition::presenceOfAllElementsLocatedBy( - WebDriverBy::className('gsc-result') - ) -); +// add new cookie +$cookie = new Cookie('cookie_set_by_selenium', 'cookie_value'); +$driver->manage()->addCookie($cookie); + +// dump current cookies to output +$cookies = $driver->manage()->getCookies(); +print_r($cookies); -// close the Firefox +// terminate the session and close the browser $driver->quit(); diff --git a/lib/AbstractWebDriverCheckboxOrRadio.php b/lib/AbstractWebDriverCheckboxOrRadio.php new file mode 100644 index 000000000..00aee242a --- /dev/null +++ b/lib/AbstractWebDriverCheckboxOrRadio.php @@ -0,0 +1,240 @@ +getTagName(); + if ($tagName !== 'input') { + throw new UnexpectedTagNameException('input', $tagName); + } + + $this->name = $element->getAttribute('name'); + if ($this->name === null) { + throw new InvalidElementStateException('The input does not have a "name" attribute.'); + } + + $this->element = $element; + } + + public function getOptions() + { + return $this->getRelatedElements(); + } + + public function getAllSelectedOptions() + { + $selectedElement = []; + foreach ($this->getRelatedElements() as $element) { + if ($element->isSelected()) { + $selectedElement[] = $element; + + if (!$this->isMultiple()) { + return $selectedElement; + } + } + } + + return $selectedElement; + } + + public function getFirstSelectedOption() + { + foreach ($this->getRelatedElements() as $element) { + if ($element->isSelected()) { + return $element; + } + } + + throw new NoSuchElementException( + sprintf('No %s are selected', $this->type === 'radio' ? 'radio buttons' : 'checkboxes') + ); + } + + public function selectByIndex($index) + { + $this->byIndex($index); + } + + public function selectByValue($value) + { + $this->byValue($value); + } + + public function selectByVisibleText($text) + { + $this->byVisibleText($text); + } + + public function selectByVisiblePartialText($text) + { + $this->byVisibleText($text, true); + } + + /** + * Selects or deselects a checkbox or a radio button by its value. + * + * @param string $value + * @param bool $select + * @throws NoSuchElementException + */ + protected function byValue($value, $select = true) + { + $matched = false; + foreach ($this->getRelatedElements($value) as $element) { + $select ? $this->selectOption($element) : $this->deselectOption($element); + if (!$this->isMultiple()) { + return; + } + + $matched = true; + } + + if (!$matched) { + throw new NoSuchElementException( + sprintf('Cannot locate %s with value: %s', $this->type, $value) + ); + } + } + + /** + * Selects or deselects a checkbox or a radio button by its index. + * + * @param int $index + * @param bool $select + * @throws NoSuchElementException + */ + protected function byIndex($index, $select = true) + { + $elements = $this->getRelatedElements(); + if (!isset($elements[$index])) { + throw new NoSuchElementException(sprintf('Cannot locate %s with index: %d', $this->type, $index)); + } + + $select ? $this->selectOption($elements[$index]) : $this->deselectOption($elements[$index]); + } + + /** + * Selects or deselects a checkbox or a radio button by its visible text. + * + * @param string $text + * @param bool $partial + * @param bool $select + */ + protected function byVisibleText($text, $partial = false, $select = true) + { + foreach ($this->getRelatedElements() as $element) { + $normalizeFilter = sprintf( + $partial ? 'contains(normalize-space(.), %s)' : 'normalize-space(.) = %s', + XPathEscaper::escapeQuotes($text) + ); + + $xpath = 'ancestor::label'; + $xpathNormalize = sprintf('%s[%s]', $xpath, $normalizeFilter); + + $id = $element->getAttribute('id'); + if ($id !== null) { + $idFilter = sprintf('@for = %s', XPathEscaper::escapeQuotes($id)); + + $xpath .= sprintf(' | //label[%s]', $idFilter); + $xpathNormalize .= sprintf(' | //label[%s and %s]', $idFilter, $normalizeFilter); + } + + try { + $element->findElement(WebDriverBy::xpath($xpathNormalize)); + } catch (NoSuchElementException $e) { + if ($partial) { + continue; + } + + try { + // Since the mechanism of getting the text in xpath is not the same as + // webdriver, use the expensive getText() to check if nothing is matched. + if ($text !== $element->findElement(WebDriverBy::xpath($xpath))->getText()) { + continue; + } + } catch (NoSuchElementException $e) { + continue; + } + } + + $select ? $this->selectOption($element) : $this->deselectOption($element); + if (!$this->isMultiple()) { + return; + } + } + } + + /** + * Gets checkboxes or radio buttons with the same name. + * + * @param string|null $value + * @return WebDriverElement[] + */ + protected function getRelatedElements($value = null) + { + $valueSelector = $value ? sprintf(' and @value = %s', XPathEscaper::escapeQuotes($value)) : ''; + $formId = $this->element->getAttribute('form'); + if ($formId === null) { + $form = $this->element->findElement(WebDriverBy::xpath('ancestor::form')); + + $formId = $form->getAttribute('id'); + if ($formId === '' || $formId === null) { + return $form->findElements(WebDriverBy::xpath( + sprintf('.//input[@name = %s%s]', XPathEscaper::escapeQuotes($this->name), $valueSelector) + )); + } + } + + // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#form + return $this->element->findElements( + WebDriverBy::xpath(sprintf( + '//form[@id = %1$s]//input[@name = %2$s%3$s' + . ' and ((boolean(@form) = true() and @form = %1$s) or boolean(@form) = false())]' + . ' | //input[@form = %1$s and @name = %2$s%3$s]', + XPathEscaper::escapeQuotes($formId), + XPathEscaper::escapeQuotes($this->name), + $valueSelector + )) + ); + } + + /** + * Selects a checkbox or a radio button. + */ + protected function selectOption(WebDriverElement $element) + { + if (!$element->isSelected()) { + $element->click(); + } + } + + /** + * Deselects a checkbox or a radio button. + */ + protected function deselectOption(WebDriverElement $element) + { + if ($element->isSelected()) { + $element->click(); + } + } +} diff --git a/lib/Chrome/ChromeDevToolsDriver.php b/lib/Chrome/ChromeDevToolsDriver.php new file mode 100644 index 000000000..2d95d274b --- /dev/null +++ b/lib/Chrome/ChromeDevToolsDriver.php @@ -0,0 +1,46 @@ + 'POST', + 'url' => '/session/:sessionId/goog/cdp/execute', + ]; + + /** + * @var RemoteWebDriver + */ + private $driver; + + public function __construct(RemoteWebDriver $driver) + { + $this->driver = $driver; + } + + /** + * Executes a Chrome DevTools command + * + * @param string $command The DevTools command to execute + * @param array $parameters Optional parameters to the command + * @return array The result of the command + */ + public function execute($command, array $parameters = []) + { + $params = ['cmd' => $command, 'params' => (object) $parameters]; + + return $this->driver->executeCustomCommand( + self::SEND_COMMAND['url'], + self::SEND_COMMAND['method'], + $params + ); + } +} diff --git a/lib/Chrome/ChromeDriver.php b/lib/Chrome/ChromeDriver.php index 32ddf8f85..e947a498e 100644 --- a/lib/Chrome/ChromeDriver.php +++ b/lib/Chrome/ChromeDriver.php @@ -1,87 +1,107 @@ setCommandExecutor($executor) - ->startSession($desired_capabilities); + $newSessionCommand = WebDriverCommand::newSession( + [ + 'capabilities' => [ + 'firstMatch' => [(object) $capabilities->toW3cCompatibleArray()], + ], + 'desiredCapabilities' => (object) $capabilities->toArray(), + ] + ); + + $response = $executor->execute($newSessionCommand); - return $driver; + /* + * TODO: in next major version we may not need to use this method, because without OSS compatibility the + * driver creation is straightforward. + */ + return static::createFromResponse($response, $executor); } + /** + * @todo Remove in next major version. The class is internally no longer used and is kept only to keep BC. + * @deprecated Use start or startUsingDriverService method instead. + * @codeCoverageIgnore + * @internal + */ public function startSession(DesiredCapabilities $desired_capabilities) { - $command = new WebDriverCommand( - null, - DriverCommand::NEW_SESSION, + $command = WebDriverCommand::newSession( [ - 'desiredCapabilities' => $desired_capabilities->toArray(), + 'capabilities' => [ + 'firstMatch' => [(object) $desired_capabilities->toW3cCompatibleArray()], + ], + 'desiredCapabilities' => (object) $desired_capabilities->toArray(), ] ); $response = $this->executor->execute($command); - $this->setSessionID($response->getSessionID()); - } + $value = $response->getValue(); - /** - * Always throws an exception. Use ChromeDriver::start() instead. - * - * @throws WebDriverException - */ - public static function create( - $selenium_server_url = 'http://localhost:4444/wd/hub', - $desired_capabilities = null, - $connection_timeout_in_ms = null, - $request_timeout_in_ms = null, - $http_proxy = null, - $http_proxy_port = null - ) { - throw new WebDriverException('Please use ChromeDriver::start() instead.'); + if (!$this->isW3cCompliant = isset($value['capabilities'])) { + $this->executor->disableW3cCompliance(); + } + + $this->sessionID = $response->getSessionID(); } /** - * Always throws an exception. Use ChromeDriver::start() instead. - * - * @param string $session_id The existing session id - * @param string $selenium_server_url The url of the remote Selenium WebDriver server - * - * @throws WebDriverException - * @return RemoteWebDriver|void + * @return ChromeDevToolsDriver */ - public static function createBySessionID( - $session_id, - $selenium_server_url = 'http://localhost:4444/wd/hub' - ) { - throw new WebDriverException('Please use ChromeDriver::start() instead.'); + public function getDevTools() + { + if ($this->devTools === null) { + $this->devTools = new ChromeDevToolsDriver($this); + } + + return $this->devTools; } } diff --git a/lib/Chrome/ChromeDriverService.php b/lib/Chrome/ChromeDriverService.php index 90b3d24f7..902a48ded 100644 --- a/lib/Chrome/ChromeDriverService.php +++ b/lib/Chrome/ChromeDriverService.php @@ -1,17 +1,4 @@ toArray(); + } + /** * Sets the path of the Chrome executable. The path should be either absolute * or relative to the location running ChromeDriver server. @@ -60,7 +64,6 @@ public function setBinary($path) } /** - * @param array $arguments * @return ChromeOptions */ public function addArguments(array $arguments) @@ -74,7 +77,6 @@ public function addArguments(array $arguments) * Add a Chrome extension to install on browser startup. Each path should be * a packed Chrome extension. * - * @param array $paths * @return ChromeOptions */ public function addExtensions(array $paths) @@ -102,6 +104,9 @@ public function addEncodedExtensions(array $encoded_extensions) /** * Sets an experimental option which has not exposed officially. * + * When using "prefs" to set Chrome preferences, please be aware they are so far not supported by + * Chrome running in headless mode, see https://bugs.chromium.org/p/chromium/issues/detail?id=775911 + * * @param string $name * @param mixed $value * @return ChromeOptions @@ -125,23 +130,25 @@ public function toCapabilities() } /** - * @return array + * @return \ArrayObject|array */ public function toArray() { - $options = $this->experimentalOptions; - // The selenium server expects a 'dictionary' instead of a 'list' when // reading the chrome option. However, an empty array in PHP will be - // converted to a 'list' instead of a 'dictionary'. To fix it, we always - // set the 'binary' to avoid returning an empty array. - $options['binary'] = $this->binary; + // converted to a 'list' instead of a 'dictionary'. To fix it, we work + // with `ArrayObject` + $options = new \ArrayObject($this->experimentalOptions); + + if (!empty($this->binary)) { + $options['binary'] = $this->binary; + } - if ($this->arguments) { + if (!empty($this->arguments)) { $options['args'] = $this->arguments; } - if ($this->extensions) { + if (!empty($this->extensions)) { $options['extensions'] = $this->extensions; } diff --git a/lib/Cookie.php b/lib/Cookie.php new file mode 100644 index 000000000..2ae257bd1 --- /dev/null +++ b/lib/Cookie.php @@ -0,0 +1,278 @@ +validateCookieName($name); + $this->validateCookieValue($value); + + $this->cookie['name'] = $name; + $this->cookie['value'] = $value; + } + + /** + * @param array $cookieArray The cookie fields; must contain name and value. + * @return Cookie + */ + public static function createFromArray(array $cookieArray) + { + if (!isset($cookieArray['name'])) { + throw LogicException::forError('Cookie name should be set'); + } + if (!isset($cookieArray['value'])) { + throw LogicException::forError('Cookie value should be set'); + } + $cookie = new self($cookieArray['name'], $cookieArray['value']); + + if (isset($cookieArray['path'])) { + $cookie->setPath($cookieArray['path']); + } + if (isset($cookieArray['domain'])) { + $cookie->setDomain($cookieArray['domain']); + } + if (isset($cookieArray['expiry'])) { + $cookie->setExpiry($cookieArray['expiry']); + } + if (isset($cookieArray['secure'])) { + $cookie->setSecure($cookieArray['secure']); + } + if (isset($cookieArray['httpOnly'])) { + $cookie->setHttpOnly($cookieArray['httpOnly']); + } + if (isset($cookieArray['sameSite'])) { + $cookie->setSameSite($cookieArray['sameSite']); + } + + return $cookie; + } + + /** + * @return string + */ + public function getName() + { + return $this->offsetGet('name'); + } + + /** + * @return string + */ + public function getValue() + { + return $this->offsetGet('value'); + } + + /** + * The path the cookie is visible to. Defaults to "/" if omitted. + * + * @param string $path + */ + public function setPath($path) + { + $this->offsetSet('path', $path); + } + + /** + * @return string|null + */ + public function getPath() + { + return $this->offsetGet('path'); + } + + /** + * The domain the cookie is visible to. Defaults to the current browsing context's document's URL domain if omitted. + * + * @param string $domain + */ + public function setDomain($domain) + { + if (mb_strpos($domain, ':') !== false) { + throw LogicException::forError(sprintf('Cookie domain "%s" should not contain a port', $domain)); + } + + $this->offsetSet('domain', $domain); + } + + /** + * @return string|null + */ + public function getDomain() + { + return $this->offsetGet('domain'); + } + + /** + * The cookie's expiration date, specified in seconds since Unix Epoch. + * + * @param int $expiry + */ + public function setExpiry($expiry) + { + $this->offsetSet('expiry', (int) $expiry); + } + + /** + * @return int|null + */ + public function getExpiry() + { + return $this->offsetGet('expiry'); + } + + /** + * Whether this cookie requires a secure connection (https). Defaults to false if omitted. + * + * @param bool $secure + */ + public function setSecure($secure) + { + $this->offsetSet('secure', $secure); + } + + /** + * @return bool|null + */ + public function isSecure() + { + return $this->offsetGet('secure'); + } + + /** + * Whether the cookie is an HTTP only cookie. Defaults to false if omitted. + * + * @param bool $httpOnly + */ + public function setHttpOnly($httpOnly) + { + $this->offsetSet('httpOnly', $httpOnly); + } + + /** + * @return bool|null + */ + public function isHttpOnly() + { + return $this->offsetGet('httpOnly'); + } + + /** + * The cookie's same-site value. + * + * @param string $sameSite + */ + public function setSameSite($sameSite) + { + $this->offsetSet('sameSite', $sameSite); + } + + /** + * @return string|null + */ + public function getSameSite() + { + return $this->offsetGet('sameSite'); + } + + /** + * @return array + */ + public function toArray() + { + $cookie = $this->cookie; + if (!isset($cookie['secure'])) { + // Passing a boolean value for the "secure" flag is mandatory when using geckodriver + $cookie['secure'] = false; + } + + return $cookie; + } + + /** + * @param mixed $offset + * @return bool + */ + #[\ReturnTypeWillChange] + public function offsetExists($offset) + { + return isset($this->cookie[$offset]); + } + + /** + * @param mixed $offset + * @return mixed + */ + #[\ReturnTypeWillChange] + public function offsetGet($offset) + { + return $this->offsetExists($offset) ? $this->cookie[$offset] : null; + } + + /** + * @param mixed $offset + * @param mixed $value + * @return void + */ + #[\ReturnTypeWillChange] + public function offsetSet($offset, $value) + { + if ($value === null) { + unset($this->cookie[$offset]); + } else { + $this->cookie[$offset] = $value; + } + } + + /** + * @param mixed $offset + * @return void + */ + #[\ReturnTypeWillChange] + public function offsetUnset($offset) + { + unset($this->cookie[$offset]); + } + + /** + * @param string $name + */ + protected function validateCookieName($name) + { + if ($name === null || $name === '') { + throw LogicException::forError('Cookie name should be non-empty'); + } + + if (mb_strpos($name, ';') !== false) { + throw LogicException::forError('Cookie name should not contain a ";"'); + } + } + + /** + * @param string $value + */ + protected function validateCookieValue($value) + { + if ($value === null) { + throw LogicException::forError('Cookie value is required when setting a cookie'); + } + } +} diff --git a/lib/Exception/DetachedShadowRootException.php b/lib/Exception/DetachedShadowRootException.php new file mode 100644 index 000000000..b9bfb39cb --- /dev/null +++ b/lib/Exception/DetachedShadowRootException.php @@ -0,0 +1,10 @@ +getCommandLine(), + $process->getErrorOutput() + ) + ); + } +} diff --git a/lib/Exception/Internal/UnexpectedResponseException.php b/lib/Exception/Internal/UnexpectedResponseException.php new file mode 100644 index 000000000..18cdc88cc --- /dev/null +++ b/lib/Exception/Internal/UnexpectedResponseException.php @@ -0,0 +1,51 @@ +getMessage() + ) + ); + } +} diff --git a/lib/Exception/Internal/WebDriverCurlException.php b/lib/Exception/Internal/WebDriverCurlException.php new file mode 100644 index 000000000..ac81f97e2 --- /dev/null +++ b/lib/Exception/Internal/WebDriverCurlException.php @@ -0,0 +1,22 @@ += 0, they are errors defined in the json wired protocol. - * For $status_code < 0, they are errors defined in php-webdriver. + * Throw WebDriverExceptions based on WebDriver status code. * - * @param int $status_code + * @param int|string $status_code * @param string $message * @param mixed $results * + * @throws ElementClickInterceptedException + * @throws ElementNotInteractableException * @throws ElementNotSelectableException * @throws ElementNotVisibleException * @throws ExpectedException * @throws IMEEngineActivationFailedException * @throws IMENotAvailableException * @throws IndexOutOfBoundsException + * @throws InsecureCertificateException + * @throws InvalidArgumentException * @throws InvalidCookieDomainException * @throws InvalidCoordinatesException * @throws InvalidElementStateException * @throws InvalidSelectorException + * @throws InvalidSessionIdException + * @throws JavascriptErrorException * @throws MoveTargetOutOfBoundsException * @throws NoAlertOpenException * @throws NoCollectionException @@ -65,7 +62,9 @@ public function getResults() * @throws NoStringException * @throws NoStringLengthException * @throws NoStringWrapperException + * @throws NoSuchAlertException * @throws NoSuchCollectionException + * @throws NoSuchCookieException * @throws NoSuchDocumentException * @throws NoSuchDriverException * @throws NoSuchElementException @@ -75,24 +74,86 @@ public function getResults() * @throws ScriptTimeoutException * @throws SessionNotCreatedException * @throws StaleElementReferenceException - * @throws TimeOutException + * @throws TimeoutException + * @throws UnableToCaptureScreenException * @throws UnableToSetCookieException * @throws UnexpectedAlertOpenException * @throws UnexpectedJavascriptException * @throws UnknownCommandException + * @throws UnknownErrorException + * @throws UnknownMethodException * @throws UnknownServerException * @throws UnrecognizedExceptionException - * @throws WebDriverCurlException + * @throws UnsupportedOperationException * @throws XPathLookupException */ public static function throwException($status_code, $message, $results) { + if (is_string($status_code)) { + // @see https://w3c.github.io/webdriver/#errors + switch ($status_code) { + case 'element click intercepted': + throw new ElementClickInterceptedException($message, $results); + case 'element not interactable': + throw new ElementNotInteractableException($message, $results); + case 'insecure certificate': + throw new InsecureCertificateException($message, $results); + case 'invalid argument': + throw new InvalidArgumentException($message, $results); + case 'invalid cookie domain': + throw new InvalidCookieDomainException($message, $results); + case 'invalid element state': + throw new InvalidElementStateException($message, $results); + case 'invalid selector': + throw new InvalidSelectorException($message, $results); + case 'invalid session id': + throw new InvalidSessionIdException($message, $results); + case 'javascript error': + throw new JavascriptErrorException($message, $results); + case 'move target out of bounds': + throw new MoveTargetOutOfBoundsException($message, $results); + case 'no such alert': + throw new NoSuchAlertException($message, $results); + case 'no such cookie': + throw new NoSuchCookieException($message, $results); + case 'no such element': + throw new NoSuchElementException($message, $results); + case 'no such frame': + throw new NoSuchFrameException($message, $results); + case 'no such window': + throw new NoSuchWindowException($message, $results); + case 'no such shadow root': + throw new NoSuchShadowRootException($message, $results); + case 'script timeout': + throw new ScriptTimeoutException($message, $results); + case 'session not created': + throw new SessionNotCreatedException($message, $results); + case 'stale element reference': + throw new StaleElementReferenceException($message, $results); + case 'detached shadow root': + throw new DetachedShadowRootException($message, $results); + case 'timeout': + throw new TimeoutException($message, $results); + case 'unable to set cookie': + throw new UnableToSetCookieException($message, $results); + case 'unable to capture screen': + throw new UnableToCaptureScreenException($message, $results); + case 'unexpected alert open': + throw new UnexpectedAlertOpenException($message, $results); + case 'unknown command': + throw new UnknownCommandException($message, $results); + case 'unknown error': + throw new UnknownErrorException($message, $results); + case 'unknown method': + throw new UnknownMethodException($message, $results); + case 'unsupported operation': + throw new UnsupportedOperationException($message, $results); + default: + throw new UnrecognizedExceptionException($message, $results); + } + } + switch ($status_code) { - case -1: - throw new WebDriverCurlException($message); - case 0: - // Success - break; case 1: throw new IndexOutOfBoundsException($message, $results); case 2: @@ -134,7 +195,7 @@ public static function throwException($status_code, $message, $results) case 20: throw new NoSuchCollectionException($message, $results); case 21: - throw new TimeOutException($message, $results); + throw new TimeoutException($message, $results); case 22: throw new NullPointerException($message, $results); case 23: diff --git a/lib/Exception/XPathLookupException.php b/lib/Exception/XPathLookupException.php index c7f589dcc..86513db58 100644 --- a/lib/Exception/XPathLookupException.php +++ b/lib/Exception/XPathLookupException.php @@ -1,20 +1,10 @@ setProfile($profile->encode()); + * $capabilities = DesiredCapabilities::firefox(); + * $capabilities->setCapability(FirefoxOptions::CAPABILITY, $firefoxOptions); + */ + public const PROFILE = 'firefox_profile'; - private function __construct() + /** + * Creates a new FirefoxDriver using default configuration. + * This includes starting a new geckodriver process each time this method is called. However this may be + * unnecessary overhead - instead, you can start the process once using FirefoxDriverService and pass + * this instance to startUsingDriverService() method. + * + * @return static + */ + public static function start(?DesiredCapabilities $capabilities = null) { + $service = FirefoxDriverService::createDefaultService(); + + return static::startUsingDriverService($service, $capabilities); + } + + /** + * Creates a new FirefoxDriver using given FirefoxDriverService. + * This is usable when you for example don't want to start new geckodriver process for each individual test + * and want to reuse the already started geckodriver, which will lower the overhead associated with spinning up + * a new process. + * + * @return static + */ + public static function startUsingDriverService( + FirefoxDriverService $service, + ?DesiredCapabilities $capabilities = null + ) { + if ($capabilities === null) { + $capabilities = DesiredCapabilities::firefox(); + } + + $executor = new DriverCommandExecutor($service); + $newSessionCommand = WebDriverCommand::newSession( + [ + 'capabilities' => [ + 'firstMatch' => [(object) $capabilities->toW3cCompatibleArray()], + ], + ] + ); + + $response = $executor->execute($newSessionCommand); + + $returnedCapabilities = DesiredCapabilities::createFromW3cCapabilities($response->getValue()['capabilities']); + $sessionId = $response->getSessionID(); + + return new static($executor, $sessionId, $returnedCapabilities, true); } } diff --git a/lib/Firefox/FirefoxDriverService.php b/lib/Firefox/FirefoxDriverService.php new file mode 100644 index 000000000..83c6a28fb --- /dev/null +++ b/lib/Firefox/FirefoxDriverService.php @@ -0,0 +1,34 @@ +setPreference(FirefoxPreferences::READER_PARSE_ON_LOAD_ENABLED, false); + // disable JSON viewer and let JSON be rendered as raw data + $this->setPreference(FirefoxPreferences::DEVTOOLS_JSONVIEW, false); + } + + /** + * Directly set firefoxOptions. + * Use `addArguments` to add command line arguments and `setPreference` to set Firefox about:config entry. + * + * @param string $name + * @param mixed $value + * @return self + */ + public function setOption($name, $value) + { + if ($name === self::OPTION_PREFS) { + throw LogicException::forError('Use setPreference() method to set Firefox preferences'); + } + if ($name === self::OPTION_ARGS) { + throw LogicException::forError('Use addArguments() method to add Firefox arguments'); + } + if ($name === self::OPTION_PROFILE) { + throw LogicException::forError('Use setProfile() method to set Firefox profile'); + } + + $this->options[$name] = $value; + + return $this; + } + + /** + * Command line arguments to pass to the Firefox binary. + * These must include the leading dash (-) where required, e.g. ['-headless']. + * + * @see https://developer.mozilla.org/en-US/docs/Web/WebDriver/Capabilities/firefoxOptions#args + * @param string[] $arguments + * @return self + */ + public function addArguments(array $arguments) + { + $this->arguments = array_merge($this->arguments, $arguments); + + return $this; + } + + /** + * Set Firefox preference (about:config entry). + * + * @see http://kb.mozillazine.org/About:config_entries + * @see https://developer.mozilla.org/en-US/docs/Web/WebDriver/Capabilities/firefoxOptions#prefs + * @param string $name + * @param string|bool|int $value + * @return self + */ + public function setPreference($name, $value) + { + $this->preferences[$name] = $value; + + return $this; + } + + /** + * @see https://github.com/php-webdriver/php-webdriver/wiki/Firefox#firefox-profile + * @return self + */ + public function setProfile(FirefoxProfile $profile) + { + $this->profile = $profile; + + return $this; + } + + /** + * @return array + */ + public function toArray() + { + $array = $this->options; + if (!empty($this->arguments)) { + $array[self::OPTION_ARGS] = $this->arguments; + } + if (!empty($this->preferences)) { + $array[self::OPTION_PREFS] = $this->preferences; + } + if (!empty($this->profile)) { + $array[self::OPTION_PROFILE] = $this->profile->encode(); + } + + return $array; + } + + #[ReturnTypeWillChange] + public function jsonSerialize() + { + return new \ArrayObject($this->toArray()); + } +} diff --git a/lib/Firefox/FirefoxPreferences.php b/lib/Firefox/FirefoxPreferences.php index b869a2667..2a33fb003 100644 --- a/lib/Firefox/FirefoxPreferences.php +++ b/lib/Firefox/FirefoxPreferences.php @@ -1,32 +1,23 @@ open($temp_zip, ZipArchive::CREATE); $dir = new RecursiveDirectoryIterator($temp_dir); @@ -195,49 +186,34 @@ public function encode() /** * @param string $extension The path to the extension. - * @param string $profile_dir The path to the profile directory. - * @return string The path to the directory of this extension. + * @param string $profileDir The path to the profile directory. + * @throws IOException */ - private function installExtension($extension, $profile_dir) + private function installExtension($extension, $profileDir) { - $temp_dir = $this->createTempDirectory('WebDriverFirefoxProfileExtension'); - $this->extractTo($extension, $temp_dir); - - // This is a hacky way to parse the id since there is no offical RDF parser library. - // Find the correct namespace for the id element. - $install_rdf_path = $temp_dir . '/install.rdf'; - $xml = simplexml_load_file($install_rdf_path); - $ns = $xml->getDocNamespaces(); - $prefix = ''; - if (!empty($ns)) { - foreach ($ns as $key => $value) { - if (strpos($value, '//www.mozilla.org/2004/em-rdf') > 0) { - if ($key != '') { - $prefix = $key . ':'; // Separate the namespace from the name. - } - break; - } - } - } - // Get the extension id from the install manifest. - $matches = []; - preg_match('#<' . $prefix . 'id>([^<]+)#', $xml->asXML(), $matches); - if (isset($matches[1])) { - $ext_dir = $profile_dir . '/extensions/' . $matches[1]; - mkdir($ext_dir, 0777, true); - $this->extractTo($extension, $ext_dir); + $extensionCommonName = $this->parseExtensionName($extension); + + // install extension to profile directory + $extensionDir = $profileDir . '/extensions/'; + if (!is_dir($extensionDir) && !mkdir($extensionDir, 0777, true) && !is_dir($extensionDir)) { + throw IOException::forFileError( + 'Cannot install Firefox extension - cannot create directory', + $extensionDir + ); } - // clean up - $this->deleteDirectory($temp_dir); - - return $ext_dir; + if (!copy($extension, $extensionDir . $extensionCommonName . '.xpi')) { + throw IOException::forFileError( + 'Cannot install Firefox extension - cannot copy file', + $extension + ); + } } /** * @param string $prefix Prefix of the temp directory. * - * @throws WebDriverException + * @throws IOException * @return string The path to the temp directory created. */ private function createTempDirectory($prefix = '') @@ -247,7 +223,10 @@ private function createTempDirectory($prefix = '') unlink($temp_dir); mkdir($temp_dir); if (!is_dir($temp_dir)) { - throw new WebDriverException('Cannot create firefox profile.'); + throw IOException::forFileError( + 'Cannot install Firefox extension - cannot create directory', + $temp_dir + ); } } @@ -277,7 +256,7 @@ private function deleteDirectory($directory) * @param string $xpi The path to the .xpi extension. * @param string $target_dir The path to the unzip directory. * - * @throws \Exception + * @throws IOException * @return FirefoxProfile */ private function extractTo($xpi, $target_dir) @@ -288,12 +267,52 @@ private function extractTo($xpi, $target_dir) $zip->extractTo($target_dir); $zip->close(); } else { - throw new \Exception("Failed to open the firefox extension. '$xpi'"); + throw IOException::forFileError('Failed to open the firefox extension.', $xpi); } } else { - throw new \Exception("Firefox extension doesn't exist. '$xpi'"); + throw IOException::forFileError('Firefox extension doesn\'t exist.', $xpi); } return $this; } + + private function parseExtensionName($extensionPath) + { + $temp_dir = $this->createTempDirectory(); + + $this->extractTo($extensionPath, $temp_dir); + + $mozillaRsaPath = $temp_dir . '/META-INF/mozilla.rsa'; + $mozillaRsaBinaryData = file_get_contents($mozillaRsaPath); + $mozillaRsaHex = bin2hex($mozillaRsaBinaryData); + + //We need to find the plugin id. This is the second occurrence of object identifier "2.5.4.3 commonName". + + //That is marker "2.5.4.3 commonName" in hex: + $objectIdentifierHexMarker = '0603550403'; + + $firstMarkerPosInHex = strpos($mozillaRsaHex, $objectIdentifierHexMarker); // phpcs:ignore + + $secondMarkerPosInHexString = + strpos($mozillaRsaHex, $objectIdentifierHexMarker, $firstMarkerPosInHex + 2); // phpcs:ignore + + if ($secondMarkerPosInHexString === false) { + throw RuntimeException::forError('Cannot install extension. Cannot fetch extension commonName'); + } + + // phpcs:ignore + $commonNameStringPositionInBinary = ($secondMarkerPosInHexString + strlen($objectIdentifierHexMarker)) / 2; + + $commonNameStringLength = ord($mozillaRsaBinaryData[$commonNameStringPositionInBinary + 1]); + // phpcs:ignore + $extensionCommonName = substr( + $mozillaRsaBinaryData, + $commonNameStringPositionInBinary + 2, + $commonNameStringLength + ); + + $this->deleteDirectory($temp_dir); + + return $extensionCommonName; + } } diff --git a/lib/Interactions/Internal/WebDriverButtonReleaseAction.php b/lib/Interactions/Internal/WebDriverButtonReleaseAction.php index 5df3cb6db..91cad24af 100644 --- a/lib/Interactions/Internal/WebDriverButtonReleaseAction.php +++ b/lib/Interactions/Internal/WebDriverButtonReleaseAction.php @@ -1,17 +1,4 @@ keyboard = $keyboard; $this->mouse = $mouse; diff --git a/lib/Interactions/Internal/WebDriverMouseAction.php b/lib/Interactions/Internal/WebDriverMouseAction.php index 2bec07386..ecb1127ee 100644 --- a/lib/Interactions/Internal/WebDriverMouseAction.php +++ b/lib/Interactions/Internal/WebDriverMouseAction.php @@ -1,17 +1,4 @@ mouse = $mouse; $this->locationProvider = $location_provider; diff --git a/lib/Interactions/Internal/WebDriverMouseMoveAction.php b/lib/Interactions/Internal/WebDriverMouseMoveAction.php index 87c6d7cbb..1969f01ba 100644 --- a/lib/Interactions/Internal/WebDriverMouseMoveAction.php +++ b/lib/Interactions/Internal/WebDriverMouseMoveAction.php @@ -1,17 +1,4 @@ key = $key; } } diff --git a/lib/Interactions/Touch/WebDriverDoubleTapAction.php b/lib/Interactions/Touch/WebDriverDoubleTapAction.php index 47664fe23..25a1761b3 100644 --- a/lib/Interactions/Touch/WebDriverDoubleTapAction.php +++ b/lib/Interactions/Touch/WebDriverDoubleTapAction.php @@ -1,17 +1,4 @@ touchScreen = $touch_screen; $this->locationProvider = $location_provider; diff --git a/lib/Interactions/Touch/WebDriverTouchScreen.php b/lib/Interactions/Touch/WebDriverTouchScreen.php index 4bb6f1100..ff9f9c4d1 100644 --- a/lib/Interactions/Touch/WebDriverTouchScreen.php +++ b/lib/Interactions/Touch/WebDriverTouchScreen.php @@ -1,17 +1,4 @@ driver = $driver; $this->keyboard = $driver->getKeyboard(); @@ -61,10 +45,9 @@ public function perform() * Mouse click. * If $element is provided, move to the middle of the element first. * - * @param WebDriverElement $element * @return WebDriverActions */ - public function click(WebDriverElement $element = null) + public function click(?WebDriverElement $element = null) { $this->action->addAction( new WebDriverClickAction($this->mouse, $element) @@ -77,10 +60,9 @@ public function click(WebDriverElement $element = null) * Mouse click and hold. * If $element is provided, move to the middle of the element first. * - * @param WebDriverElement $element * @return WebDriverActions */ - public function clickAndHold(WebDriverElement $element = null) + public function clickAndHold(?WebDriverElement $element = null) { $this->action->addAction( new WebDriverClickAndHoldAction($this->mouse, $element) @@ -93,10 +75,9 @@ public function clickAndHold(WebDriverElement $element = null) * Context-click (right click). * If $element is provided, move to the middle of the element first. * - * @param WebDriverElement $element * @return WebDriverActions */ - public function contextClick(WebDriverElement $element = null) + public function contextClick(?WebDriverElement $element = null) { $this->action->addAction( new WebDriverContextClickAction($this->mouse, $element) @@ -109,10 +90,9 @@ public function contextClick(WebDriverElement $element = null) * Double click. * If $element is provided, move to the middle of the element first. * - * @param WebDriverElement $element * @return WebDriverActions */ - public function doubleClick(WebDriverElement $element = null) + public function doubleClick(?WebDriverElement $element = null) { $this->action->addAction( new WebDriverDoubleClickAction($this->mouse, $element) @@ -124,8 +104,6 @@ public function doubleClick(WebDriverElement $element = null) /** * Drag and drop from $source to $target. * - * @param WebDriverElement $source - * @param WebDriverElement $target * @return WebDriverActions */ public function dragAndDrop(WebDriverElement $source, WebDriverElement $target) @@ -146,7 +124,6 @@ public function dragAndDrop(WebDriverElement $source, WebDriverElement $target) /** * Drag $source and drop by offset ($x_offset, $y_offset). * - * @param WebDriverElement $source * @param int $x_offset * @param int $y_offset * @return WebDriverActions @@ -187,7 +164,6 @@ public function moveByOffset($x_offset, $y_offset) * Extra shift, calculated from the top-left corner of the element, can be set by passing $x_offset and $y_offset * parameters. * - * @param WebDriverElement $element * @param int $x_offset * @param int $y_offset * @return WebDriverActions @@ -208,10 +184,9 @@ public function moveToElement(WebDriverElement $element, $x_offset = null, $y_of * Release the mouse button. * If $element is provided, move to the middle of the element first. * - * @param WebDriverElement $element * @return WebDriverActions */ - public function release(WebDriverElement $element = null) + public function release(?WebDriverElement $element = null) { $this->action->addAction( new WebDriverButtonReleaseAction($this->mouse, $element) @@ -225,11 +200,10 @@ public function release(WebDriverElement $element = null) * If $element is provided, focus on that element first. * * @see WebDriverKeys for special keys like CONTROL, ALT, etc. - * @param WebDriverElement $element * @param string $key * @return WebDriverActions */ - public function keyDown(WebDriverElement $element = null, $key = null) + public function keyDown(?WebDriverElement $element = null, $key = null) { $this->action->addAction( new WebDriverKeyDownAction($this->keyboard, $this->mouse, $element, $key) @@ -243,11 +217,10 @@ public function keyDown(WebDriverElement $element = null, $key = null) * If $element is provided, focus on that element first. * * @see WebDriverKeys for special keys like CONTROL, ALT, etc. - * @param WebDriverElement $element * @param string $key * @return WebDriverActions */ - public function keyUp(WebDriverElement $element = null, $key = null) + public function keyUp(?WebDriverElement $element = null, $key = null) { $this->action->addAction( new WebDriverKeyUpAction($this->keyboard, $this->mouse, $element, $key) @@ -258,14 +231,13 @@ public function keyUp(WebDriverElement $element = null, $key = null) /** * Send keys by keyboard. - * If $element is provided, focus on that element first. + * If $element is provided, focus on that element first (using single mouse click). * * @see WebDriverKeys for special keys like CONTROL, ALT, etc. - * @param WebDriverElement $element * @param string $keys * @return WebDriverActions */ - public function sendKeys(WebDriverElement $element = null, $keys = null) + public function sendKeys(?WebDriverElement $element = null, $keys = null) { $this->action->addAction( new WebDriverSendKeysAction( diff --git a/lib/Interactions/WebDriverCompositeAction.php b/lib/Interactions/WebDriverCompositeAction.php index 75b879082..955d61986 100644 --- a/lib/Interactions/WebDriverCompositeAction.php +++ b/lib/Interactions/WebDriverCompositeAction.php @@ -1,17 +1,4 @@ setCustomRequestParameters($url, $method); + + parent::__construct($session_id, DriverCommand::CUSTOM_COMMAND, $parameters); + } + + /** + * @throws WebDriverException + * @return string + */ + public function getCustomUrl() + { + if ($this->customUrl === null) { + throw LogicException::forError('URL of custom command is not set'); + } + + return $this->customUrl; + } + + /** + * @throws WebDriverException + * @return string + */ + public function getCustomMethod() + { + if ($this->customMethod === null) { + throw LogicException::forError('Method of custom command is not set'); + } + + return $this->customMethod; + } + + /** + * @param string $custom_url + * @param string $custom_method + * @throws WebDriverException + */ + protected function setCustomRequestParameters($custom_url, $custom_method) + { + $allowedMethods = [static::METHOD_GET, static::METHOD_POST]; + if (!in_array($custom_method, $allowedMethods, true)) { + throw LogicException::forError( + sprintf( + 'Invalid custom method "%s", must be one of [%s]', + $custom_method, + implode(', ', $allowedMethods) + ) + ); + } + $this->customMethod = $custom_method; + + if (mb_strpos($custom_url, '/') !== 0) { + throw LogicException::forError( + sprintf('URL of custom command has to start with / but is "%s"', $custom_url) + ); + } + $this->customUrl = $custom_url; + } +} diff --git a/lib/Remote/DesiredCapabilities.php b/lib/Remote/DesiredCapabilities.php index badd04313..88aa6b141 100644 --- a/lib/Remote/DesiredCapabilities.php +++ b/lib/Remote/DesiredCapabilities.php @@ -1,40 +1,46 @@ 'platformName', + WebDriverCapabilityType::VERSION => 'browserVersion', + WebDriverCapabilityType::ACCEPT_SSL_CERTS => 'acceptInsecureCerts', + ]; + public function __construct(array $capabilities = []) { $this->capabilities = $capabilities; } + public static function createFromW3cCapabilities(array $capabilities = []) + { + $w3cToOss = array_flip(self::$ossToW3c); + + foreach ($w3cToOss as $w3cCapability => $ossCapability) { + // Copy W3C capabilities to OSS ones + if (array_key_exists($w3cCapability, $capabilities)) { + $capabilities[$ossCapability] = $capabilities[$w3cCapability]; + } + } + + return new self($capabilities); + } + /** * @return string The name of the browser. */ @@ -89,6 +95,15 @@ public function getCapability($name) */ public function setCapability($name, $value) { + // When setting 'moz:firefoxOptions' from an array and not from instance of FirefoxOptions, we must merge + // it with default FirefoxOptions to keep previous behavior (where the default preferences were added + // using FirefoxProfile, thus not overwritten by adding 'moz:firefoxOptions') + // TODO: remove in next major version, once FirefoxOptions are only accepted as object instance and not as array + if ($name === FirefoxOptions::CAPABILITY && is_array($value)) { + $defaultOptions = (new FirefoxOptions())->toArray(); + $value = array_merge($defaultOptions, $value); + } + $this->set($name, $value); return $this; @@ -123,6 +138,8 @@ public function is($capability_name) } /** + * @todo Remove in next major release (BC) + * @deprecated All browsers are always JS enabled except HtmlUnit and it's not meaningful to disable JS execution. * @return bool Whether javascript is enabled. */ public function isJavascriptEnabled() @@ -134,17 +151,17 @@ public function isJavascriptEnabled() * This is a htmlUnit-only option. * * @param bool $enabled - * @throws Exception + * @throws UnsupportedOperationException * @return DesiredCapabilities - * @see https://code.google.com/p/selenium/wiki/DesiredCapabilities#Read-write_capabilities + * @see https://github.com/SeleniumHQ/selenium/wiki/DesiredCapabilities#read-write-capabilities */ public function setJavascriptEnabled($enabled) { $browser = $this->getBrowserName(); if ($browser && $browser !== WebDriverBrowserType::HTMLUNIT) { - throw new Exception( - 'isJavascriptEnable() is a htmlunit-only option. ' . - 'See https://code.google.com/p/selenium/wiki/DesiredCapabilities#Read-write_capabilities.' + throw new UnsupportedOperationException( + 'isJavascriptEnabled() is a htmlunit-only option. ' . + 'See https://github.com/SeleniumHQ/selenium/wiki/DesiredCapabilities#read-write-capabilities.' ); } @@ -154,6 +171,7 @@ public function setJavascriptEnabled($enabled) } /** + * @todo Remove side-effects - not change eg. ChromeOptions::CAPABILITY from instance of ChromeOptions to an array * @return array */ public function toArray() @@ -165,6 +183,13 @@ public function toArray() $this->capabilities[ChromeOptions::CAPABILITY]->toArray(); } + if (isset($this->capabilities[FirefoxOptions::CAPABILITY]) && + $this->capabilities[FirefoxOptions::CAPABILITY] instanceof FirefoxOptions + ) { + $this->capabilities[FirefoxOptions::CAPABILITY] = + $this->capabilities[FirefoxOptions::CAPABILITY]->toArray(); + } + if (isset($this->capabilities[FirefoxDriver::PROFILE]) && $this->capabilities[FirefoxDriver::PROFILE] instanceof FirefoxProfile ) { @@ -176,31 +201,71 @@ public function toArray() } /** - * @param string $key - * @param mixed $value - * @return DesiredCapabilities + * @return array */ - private function set($key, $value) + public function toW3cCompatibleArray() { - $this->capabilities[$key] = $value; + $allowedW3cCapabilities = [ + 'browserName', + 'browserVersion', + 'platformName', + 'acceptInsecureCerts', + 'pageLoadStrategy', + 'proxy', + 'setWindowRect', + 'timeouts', + 'strictFileInteractability', + 'unhandledPromptBehavior', + ]; + + $ossCapabilities = $this->toArray(); + $w3cCapabilities = []; + + foreach ($ossCapabilities as $capabilityKey => $capabilityValue) { + // Copy already W3C compatible capabilities + if (in_array($capabilityKey, $allowedW3cCapabilities, true)) { + $w3cCapabilities[$capabilityKey] = $capabilityValue; + } + + // Convert capabilities with changed name + if (array_key_exists($capabilityKey, self::$ossToW3c)) { + if ($capabilityKey === WebDriverCapabilityType::PLATFORM) { + $w3cCapabilities[self::$ossToW3c[$capabilityKey]] = mb_strtolower($capabilityValue); + + // Remove platformName if it is set to "any" + if ($w3cCapabilities[self::$ossToW3c[$capabilityKey]] === 'any') { + unset($w3cCapabilities[self::$ossToW3c[$capabilityKey]]); + } + } else { + $w3cCapabilities[self::$ossToW3c[$capabilityKey]] = $capabilityValue; + } + } + + // Copy vendor extensions + if (mb_strpos($capabilityKey, ':') !== false) { + $w3cCapabilities[$capabilityKey] = $capabilityValue; + } + } - return $this; - } + // Convert ChromeOptions + if (array_key_exists(ChromeOptions::CAPABILITY, $ossCapabilities)) { + $w3cCapabilities[ChromeOptions::CAPABILITY] = $ossCapabilities[ChromeOptions::CAPABILITY]; + } - /** - * @param string $key - * @param mixed $default - * @return mixed - */ - private function get($key, $default = null) - { - return isset($this->capabilities[$key]) - ? $this->capabilities[$key] - : $default; + // Convert Firefox profile + if (array_key_exists(FirefoxDriver::PROFILE, $ossCapabilities)) { + // Convert profile only if not already set in moz:firefoxOptions + if (!array_key_exists(FirefoxOptions::CAPABILITY, $ossCapabilities) + || !array_key_exists('profile', $ossCapabilities[FirefoxOptions::CAPABILITY])) { + $w3cCapabilities[FirefoxOptions::CAPABILITY]['profile'] = $ossCapabilities[FirefoxDriver::PROFILE]; + } + } + + return $w3cCapabilities; } /** - * @return DesiredCapabilities + * @return static */ public static function android() { @@ -211,7 +276,7 @@ public static function android() } /** - * @return DesiredCapabilities + * @return static */ public static function chrome() { @@ -222,7 +287,7 @@ public static function chrome() } /** - * @return DesiredCapabilities + * @return static */ public static function firefox() { @@ -231,16 +296,13 @@ public static function firefox() WebDriverCapabilityType::PLATFORM => WebDriverPlatform::ANY, ]); - // disable the "Reader View" help tooltip, which can hide elements in the window.document - $profile = new FirefoxProfile(); - $profile->setPreference(FirefoxPreferences::READER_PARSE_ON_LOAD_ENABLED, false); - $caps->setCapability(FirefoxDriver::PROFILE, $profile); + $caps->setCapability(FirefoxOptions::CAPABILITY, new FirefoxOptions()); // to add default options return $caps; } /** - * @return DesiredCapabilities + * @return static */ public static function htmlUnit() { @@ -251,7 +313,7 @@ public static function htmlUnit() } /** - * @return DesiredCapabilities + * @return static */ public static function htmlUnitWithJS() { @@ -264,7 +326,7 @@ public static function htmlUnitWithJS() } /** - * @return DesiredCapabilities + * @return static */ public static function internetExplorer() { @@ -275,7 +337,7 @@ public static function internetExplorer() } /** - * @return DesiredCapabilities + * @return static */ public static function microsoftEdge() { @@ -286,7 +348,7 @@ public static function microsoftEdge() } /** - * @return DesiredCapabilities + * @return static */ public static function iphone() { @@ -297,7 +359,7 @@ public static function iphone() } /** - * @return DesiredCapabilities + * @return static */ public static function ipad() { @@ -308,7 +370,7 @@ public static function ipad() } /** - * @return DesiredCapabilities + * @return static */ public static function opera() { @@ -319,7 +381,7 @@ public static function opera() } /** - * @return DesiredCapabilities + * @return static */ public static function safari() { @@ -330,7 +392,9 @@ public static function safari() } /** - * @return DesiredCapabilities + * @deprecated PhantomJS is no longer developed and its support will be removed in next major version. + * Use headless Chrome or Firefox instead. + * @return static */ public static function phantomjs() { @@ -339,4 +403,26 @@ public static function phantomjs() WebDriverCapabilityType::PLATFORM => WebDriverPlatform::ANY, ]); } + + /** + * @param string $key + * @param mixed $value + * @return DesiredCapabilities + */ + private function set($key, $value) + { + $this->capabilities[$key] = $value; + + return $this; + } + + /** + * @param string $key + * @param mixed $default + * @return mixed + */ + private function get($key, $default = null) + { + return $this->capabilities[$key] ?? $default; + } } diff --git a/lib/Remote/DriverCommand.php b/lib/Remote/DriverCommand.php index e2ce958b5..a3a230b7c 100644 --- a/lib/Remote/DriverCommand.php +++ b/lib/Remote/DriverCommand.php @@ -1,144 +1,151 @@ ['method' => 'POST', 'url' => '/session/:sessionId/accept_alert'], @@ -46,11 +38,13 @@ class HttpCommandExecutor implements WebDriverCommandExecutor DriverCommand::FIND_ELEMENT => ['method' => 'POST', 'url' => '/session/:sessionId/element'], DriverCommand::FIND_ELEMENTS => ['method' => 'POST', 'url' => '/session/:sessionId/elements'], DriverCommand::SWITCH_TO_FRAME => ['method' => 'POST', 'url' => '/session/:sessionId/frame'], + DriverCommand::SWITCH_TO_PARENT_FRAME => ['method' => 'POST', 'url' => '/session/:sessionId/frame/parent'], DriverCommand::SWITCH_TO_WINDOW => ['method' => 'POST', 'url' => '/session/:sessionId/window'], DriverCommand::GET => ['method' => 'POST', 'url' => '/session/:sessionId/url'], DriverCommand::GET_ACTIVE_ELEMENT => ['method' => 'POST', 'url' => '/session/:sessionId/element/active'], DriverCommand::GET_ALERT_TEXT => ['method' => 'GET', 'url' => '/session/:sessionId/alert_text'], DriverCommand::GET_ALL_COOKIES => ['method' => 'GET', 'url' => '/session/:sessionId/cookie'], + DriverCommand::GET_NAMED_COOKIE => ['method' => 'GET', 'url' => '/session/:sessionId/cookie/:name'], DriverCommand::GET_ALL_SESSIONS => ['method' => 'GET', 'url' => '/sessions'], DriverCommand::GET_AVAILABLE_LOG_TYPES => ['method' => 'GET', 'url' => '/session/:sessionId/log/types'], DriverCommand::GET_CURRENT_URL => ['method' => 'GET', 'url' => '/session/:sessionId/url'], @@ -121,8 +115,13 @@ class HttpCommandExecutor implements WebDriverCommandExecutor 'method' => 'POST', 'url' => '/session/:sessionId/window/:windowHandle/size', ], + DriverCommand::STATUS => ['method' => 'GET', 'url' => '/status'], DriverCommand::SUBMIT_ELEMENT => ['method' => 'POST', 'url' => '/session/:sessionId/element/:id/submit'], DriverCommand::SCREENSHOT => ['method' => 'GET', 'url' => '/session/:sessionId/screenshot'], + DriverCommand::TAKE_ELEMENT_SCREENSHOT => [ + 'method' => 'GET', + 'url' => '/session/:sessionId/element/:id/screenshot', + ], DriverCommand::TOUCH_SINGLE_TAP => ['method' => 'POST', 'url' => '/session/:sessionId/touch/click'], DriverCommand::TOUCH_DOWN => ['method' => 'POST', 'url' => '/session/:sessionId/touch/down'], DriverCommand::TOUCH_DOUBLE_TAP => ['method' => 'POST', 'url' => '/session/:sessionId/touch/doubleclick'], @@ -131,6 +130,53 @@ class HttpCommandExecutor implements WebDriverCommandExecutor DriverCommand::TOUCH_MOVE => ['method' => 'POST', 'url' => '/session/:sessionId/touch/move'], DriverCommand::TOUCH_SCROLL => ['method' => 'POST', 'url' => '/session/:sessionId/touch/scroll'], DriverCommand::TOUCH_UP => ['method' => 'POST', 'url' => '/session/:sessionId/touch/up'], + DriverCommand::CUSTOM_COMMAND => [], + ]; + /** + * @var array Will be merged with $commands + */ + protected static $w3cCompliantCommands = [ + DriverCommand::ACCEPT_ALERT => ['method' => 'POST', 'url' => '/session/:sessionId/alert/accept'], + DriverCommand::ACTIONS => ['method' => 'POST', 'url' => '/session/:sessionId/actions'], + DriverCommand::DISMISS_ALERT => ['method' => 'POST', 'url' => '/session/:sessionId/alert/dismiss'], + DriverCommand::EXECUTE_ASYNC_SCRIPT => ['method' => 'POST', 'url' => '/session/:sessionId/execute/async'], + DriverCommand::EXECUTE_SCRIPT => ['method' => 'POST', 'url' => '/session/:sessionId/execute/sync'], + DriverCommand::FIND_ELEMENT_FROM_SHADOW_ROOT => [ + 'method' => 'POST', + 'url' => '/session/:sessionId/shadow/:id/element', + ], + DriverCommand::FIND_ELEMENTS_FROM_SHADOW_ROOT => [ + 'method' => 'POST', + 'url' => '/session/:sessionId/shadow/:id/elements', + ], + DriverCommand::FULLSCREEN_WINDOW => ['method' => 'POST', 'url' => '/session/:sessionId/window/fullscreen'], + DriverCommand::GET_ACTIVE_ELEMENT => ['method' => 'GET', 'url' => '/session/:sessionId/element/active'], + DriverCommand::GET_ALERT_TEXT => ['method' => 'GET', 'url' => '/session/:sessionId/alert/text'], + DriverCommand::GET_CURRENT_WINDOW_HANDLE => ['method' => 'GET', 'url' => '/session/:sessionId/window'], + DriverCommand::GET_ELEMENT_LOCATION => ['method' => 'GET', 'url' => '/session/:sessionId/element/:id/rect'], + DriverCommand::GET_ELEMENT_PROPERTY => [ + 'method' => 'GET', + 'url' => '/session/:sessionId/element/:id/property/:name', + ], + DriverCommand::GET_ELEMENT_SHADOW_ROOT => [ + 'method' => 'GET', + 'url' => '/session/:sessionId/element/:id/shadow', + ], + DriverCommand::GET_ELEMENT_SIZE => ['method' => 'GET', 'url' => '/session/:sessionId/element/:id/rect'], + DriverCommand::GET_WINDOW_HANDLES => ['method' => 'GET', 'url' => '/session/:sessionId/window/handles'], + DriverCommand::GET_WINDOW_POSITION => ['method' => 'GET', 'url' => '/session/:sessionId/window/rect'], + DriverCommand::GET_WINDOW_SIZE => ['method' => 'GET', 'url' => '/session/:sessionId/window/rect'], + DriverCommand::IMPLICITLY_WAIT => ['method' => 'POST', 'url' => '/session/:sessionId/timeouts'], + DriverCommand::MAXIMIZE_WINDOW => ['method' => 'POST', 'url' => '/session/:sessionId/window/maximize'], + DriverCommand::MINIMIZE_WINDOW => ['method' => 'POST', 'url' => '/session/:sessionId/window/minimize'], + DriverCommand::NEW_WINDOW => ['method' => 'POST', 'url' => '/session/:sessionId/window/new'], + DriverCommand::SET_ALERT_VALUE => ['method' => 'POST', 'url' => '/session/:sessionId/alert/text'], + DriverCommand::SET_SCRIPT_TIMEOUT => ['method' => 'POST', 'url' => '/session/:sessionId/timeouts'], + DriverCommand::SET_TIMEOUT => ['method' => 'POST', 'url' => '/session/:sessionId/timeouts'], + DriverCommand::SET_WINDOW_SIZE => ['method' => 'POST', 'url' => '/session/:sessionId/window/rect'], + DriverCommand::SET_WINDOW_POSITION => ['method' => 'POST', 'url' => '/session/:sessionId/window/rect'], + // Selenium extension of W3C protocol + DriverCommand::UPLOAD_FILE => ['method' => 'POST', 'url' => '/session/:sessionId/se/file'], ]; /** * @var string @@ -140,6 +186,10 @@ class HttpCommandExecutor implements WebDriverCommandExecutor * @var resource */ protected $curl; + /** + * @var bool + */ + protected $isW3cCompliant = true; /** * @param string $url @@ -148,12 +198,14 @@ class HttpCommandExecutor implements WebDriverCommandExecutor */ public function __construct($url, $http_proxy = null, $http_proxy_port = null) { + self::$w3cCompliantCommands = array_merge(self::$commands, self::$w3cCompliantCommands); + $this->url = $url; $this->curl = curl_init(); if (!empty($http_proxy)) { curl_setopt($this->curl, CURLOPT_PROXY, $http_proxy); - if (!empty($http_proxy_port)) { + if ($http_proxy_port !== null) { curl_setopt($this->curl, CURLOPT_PROXYPORT, $http_proxy_port); } } @@ -169,16 +221,15 @@ public function __construct($url, $http_proxy = null, $http_proxy_port = null) curl_setopt($this->curl, CURLOPT_RETURNTRANSFER, true); curl_setopt($this->curl, CURLOPT_FOLLOWLOCATION, true); - curl_setopt( - $this->curl, - CURLOPT_HTTPHEADER, - [ - 'Content-Type: application/json;charset=UTF-8', - 'Accept: application/json', - ] - ); - $this->setRequestTimeout(30000); - $this->setConnectionTimeout(30000); + curl_setopt($this->curl, CURLOPT_HTTPHEADER, static::DEFAULT_HTTP_HEADERS); + + $this->setConnectionTimeout(30 * 1000); // 30 seconds + $this->setRequestTimeout(180 * 1000); // 3 minutes + } + + public function disableW3cCompliance() + { + $this->isW3cCompliant = false; } /** @@ -221,21 +272,16 @@ public function setRequestTimeout($timeout_in_ms) } /** - * @param WebDriverCommand $command - * - * @throws WebDriverException - * @return mixed + * @return WebDriverResponse */ public function execute(WebDriverCommand $command) { - if (!isset(self::$commands[$command->getName()])) { - throw new InvalidArgumentException($command->getName() . ' is not a valid command.'); - } + $http_options = $this->getCommandHttpOptions($command); + $http_method = $http_options['method']; + $url = $http_options['url']; - $raw = self::$commands[$command->getName()]; - $http_method = $raw['method']; - $url = $raw['url']; - $url = str_replace(':sessionId', $command->getSessionID(), $url); + $sessionID = $command->getSessionID(); + $url = str_replace(':sessionId', $sessionID ?? '', $url); $params = $command->getParameters(); foreach ($params as $name => $value) { if ($name[0] === ':') { @@ -244,14 +290,8 @@ public function execute(WebDriverCommand $command) } } - if ($params && is_array($params) && $http_method !== 'POST') { - throw new BadMethodCallException(sprintf( - 'The http method called for %s is %s but it has to be POST' . - ' if you want to pass the JSON params %s', - $url, - $http_method, - json_encode($params) - )); + if (is_array($params) && !empty($params) && $http_method !== 'POST') { + throw LogicException::forInvalidHttpMethod($url, $http_method, $params); } curl_setopt($this->curl, CURLOPT_URL, $this->url . $url); @@ -263,10 +303,23 @@ public function execute(WebDriverCommand $command) curl_setopt($this->curl, CURLOPT_CUSTOMREQUEST, $http_method); } + if (in_array($http_method, ['POST', 'PUT'], true)) { + // Disable sending 'Expect: 100-Continue' header, as it is causing issues with eg. squid proxy + // https://tools.ietf.org/html/rfc7231#section-5.1.1 + curl_setopt($this->curl, CURLOPT_HTTPHEADER, array_merge(static::DEFAULT_HTTP_HEADERS, ['Expect:'])); + } else { + curl_setopt($this->curl, CURLOPT_HTTPHEADER, static::DEFAULT_HTTP_HEADERS); + } + $encoded_params = null; - if ($http_method === 'POST' && $params && is_array($params)) { - $encoded_params = json_encode($params); + if ($http_method === 'POST') { + if (is_array($params) && !empty($params)) { + $encoded_params = json_encode($params); + } elseif ($this->isW3cCompliant) { + // POST body must be valid JSON in W3C, even if empty: https://www.w3.org/TR/webdriver/#processing-model + $encoded_params = '{}'; + } } curl_setopt($this->curl, CURLOPT_POSTFIELDS, $encoded_params); @@ -274,29 +327,13 @@ public function execute(WebDriverCommand $command) $raw_results = trim(curl_exec($this->curl)); if ($error = curl_error($this->curl)) { - $msg = sprintf( - 'Curl error thrown for http %s to %s', - $http_method, - $url - ); - if ($params && is_array($params)) { - $msg .= sprintf(' with params: %s', json_encode($params)); - } - WebDriverException::throwException(-1, $msg . "\n\n" . $error, []); + throw WebDriverCurlException::forCurlError($http_method, $url, $error, is_array($params) ? $params : null); } $results = json_decode($raw_results, true); if ($results === null && json_last_error() !== JSON_ERROR_NONE) { - throw new WebDriverException( - sprintf( - "JSON decoding of remote response failed.\n" . - "Error code: %d\n" . - "The response: '%s'\n", - json_last_error(), - $raw_results - ) - ); + throw UnexpectedResponseException::forJsonDecodingError(json_last_error(), $raw_results); } $value = null; @@ -310,12 +347,25 @@ public function execute(WebDriverCommand $command) } $sessionId = null; - if (is_array($results) && array_key_exists('sessionId', $results)) { + if (is_array($value) && array_key_exists('sessionId', $value)) { + // W3C's WebDriver + $sessionId = $value['sessionId']; + } elseif (is_array($results) && array_key_exists('sessionId', $results)) { + // Legacy JsonWire $sessionId = $results['sessionId']; } - $status = isset($results['status']) ? $results['status'] : 0; - WebDriverException::throwException($status, $message, $results); + // @see https://w3c.github.io/webdriver/#errors + if (isset($value['error'])) { + // W3C's WebDriver + WebDriverException::throwException($value['error'], $message, $results); + } + + $status = $results['status'] ?? 0; + if ($status !== 0) { + // Legacy JsonWire + WebDriverException::throwException($status, $message, $results); + } $response = new WebDriverResponse($sessionId); @@ -331,4 +381,36 @@ public function getAddressOfRemoteServer() { return $this->url; } + + /** + * @return array + */ + protected function getCommandHttpOptions(WebDriverCommand $command) + { + $commandName = $command->getName(); + if (!isset(self::$commands[$commandName])) { + if ($this->isW3cCompliant && !isset(self::$w3cCompliantCommands[$commandName])) { + throw LogicException::forError($command->getName() . ' is not a valid command.'); + } + } + + if ($this->isW3cCompliant) { + $raw = self::$w3cCompliantCommands[$command->getName()]; + } else { + $raw = self::$commands[$command->getName()]; + } + + if ($command instanceof CustomWebDriverCommand) { + $url = $command->getCustomUrl(); + $method = $command->getCustomMethod(); + } else { + $url = $raw['url']; + $method = $raw['method']; + } + + return [ + 'url' => $url, + 'method' => $method, + ]; + } } diff --git a/lib/Remote/JsonWireCompat.php b/lib/Remote/JsonWireCompat.php new file mode 100644 index 000000000..b9e1b5ee4 --- /dev/null +++ b/lib/Remote/JsonWireCompat.php @@ -0,0 +1,98 @@ +getMechanism(); + $value = $by->getValue(); + + if ($isW3cCompliant) { + switch ($mechanism) { + // Convert to CSS selectors + case 'class name': + $mechanism = 'css selector'; + $value = sprintf('.%s', self::escapeSelector($value)); + break; + case 'id': + $mechanism = 'css selector'; + $value = sprintf('#%s', self::escapeSelector($value)); + break; + case 'name': + $mechanism = 'css selector'; + $value = sprintf('[name=\'%s\']', self::escapeSelector($value)); + break; + } + } + + return ['using' => $mechanism, 'value' => $value]; + } + + /** + * Escapes a CSS selector. + * + * Code adapted from the Zend Escaper project. + * + * @copyright Copyright (c) 2005-2015 Zend Technologies USA Inc. (http://www.zend.com) + * @see https://github.com/zendframework/zend-escaper/blob/master/src/Escaper.php + * + * @param string $selector + * @return string + */ + private static function escapeSelector($selector) + { + return preg_replace_callback('/[^a-z0-9]/iSu', function ($matches) { + $chr = $matches[0]; + if (mb_strlen($chr) === 1) { + $ord = ord($chr); + } else { + $chr = mb_convert_encoding($chr, 'UTF-32BE', 'UTF-8'); + $ord = hexdec(bin2hex($chr)); + } + + return sprintf('\\%X ', $ord); + }, $selector); + } +} diff --git a/lib/Remote/LocalFileDetector.php b/lib/Remote/LocalFileDetector.php index 07f996cf0..ea7e85e01 100644 --- a/lib/Remote/LocalFileDetector.php +++ b/lib/Remote/LocalFileDetector.php @@ -1,17 +1,4 @@ driver = $driver; @@ -32,7 +16,6 @@ public function __construct(RemoteWebDriver $driver) /** * @param string $command_name - * @param array $parameters * @return mixed */ public function execute($command_name, array $parameters = []) diff --git a/lib/Remote/RemoteKeyboard.php b/lib/Remote/RemoteKeyboard.php index b10b1ca72..095b0c573 100644 --- a/lib/Remote/RemoteKeyboard.php +++ b/lib/Remote/RemoteKeyboard.php @@ -1,20 +1,8 @@ executor = $executor; + $this->driver = $driver; + $this->isW3cCompliant = $isW3cCompliant; } /** @@ -43,9 +35,14 @@ public function __construct(RemoteExecuteMethod $executor) */ public function sendKeys($keys) { - $this->executor->execute(DriverCommand::SEND_KEYS_TO_ACTIVE_ELEMENT, [ - 'value' => WebDriverKeys::encode($keys), - ]); + if ($this->isW3cCompliant) { + $activeElement = $this->driver->switchTo()->activeElement(); + $activeElement->sendKeys($keys); + } else { + $this->executor->execute(DriverCommand::SEND_KEYS_TO_ACTIVE_ELEMENT, [ + 'value' => WebDriverKeys::encode($keys), + ]); + } return $this; } @@ -59,9 +56,21 @@ public function sendKeys($keys) */ public function pressKey($key) { - $this->executor->execute(DriverCommand::SEND_KEYS_TO_ACTIVE_ELEMENT, [ - 'value' => [(string) $key], - ]); + if ($this->isW3cCompliant) { + $this->executor->execute(DriverCommand::ACTIONS, [ + 'actions' => [ + [ + 'type' => 'key', + 'id' => 'keyboard', + 'actions' => [['type' => 'keyDown', 'value' => $key]], + ], + ], + ]); + } else { + $this->executor->execute(DriverCommand::SEND_KEYS_TO_ACTIVE_ELEMENT, [ + 'value' => [(string) $key], + ]); + } return $this; } @@ -75,9 +84,21 @@ public function pressKey($key) */ public function releaseKey($key) { - $this->executor->execute(DriverCommand::SEND_KEYS_TO_ACTIVE_ELEMENT, [ - 'value' => [(string) $key], - ]); + if ($this->isW3cCompliant) { + $this->executor->execute(DriverCommand::ACTIONS, [ + 'actions' => [ + [ + 'type' => 'key', + 'id' => 'keyboard', + 'actions' => [['type' => 'keyUp', 'value' => $key]], + ], + ], + ]); + } else { + $this->executor->execute(DriverCommand::SEND_KEYS_TO_ACTIVE_ELEMENT, [ + 'value' => [(string) $key], + ]); + } return $this; } diff --git a/lib/Remote/RemoteMouse.php b/lib/Remote/RemoteMouse.php index 48c578df7..fee209f94 100644 --- a/lib/Remote/RemoteMouse.php +++ b/lib/Remote/RemoteMouse.php @@ -1,17 +1,4 @@ executor = $executor; + $this->isW3cCompliant = $isW3cCompliant; } /** - * @param null|WebDriverCoordinates $where - * * @return RemoteMouse */ - public function click(WebDriverCoordinates $where = null) + public function click(?WebDriverCoordinates $where = null) { + if ($this->isW3cCompliant) { + $moveAction = $where ? [$this->createMoveAction($where)] : []; + $this->executor->execute(DriverCommand::ACTIONS, [ + 'actions' => [ + [ + 'type' => 'pointer', + 'id' => 'mouse', + 'parameters' => ['pointerType' => 'mouse'], + 'actions' => array_merge($moveAction, $this->createClickActions()), + ], + ], + ]); + + return $this; + } + $this->moveIfNeeded($where); $this->executor->execute(DriverCommand::CLICK, [ - 'button' => 0, + 'button' => self::BUTTON_LEFT, ]); return $this; } /** - * @param WebDriverCoordinates $where - * * @return RemoteMouse */ - public function contextClick(WebDriverCoordinates $where = null) + public function contextClick(?WebDriverCoordinates $where = null) { + if ($this->isW3cCompliant) { + $moveAction = $where ? [$this->createMoveAction($where)] : []; + $this->executor->execute(DriverCommand::ACTIONS, [ + 'actions' => [ + [ + 'type' => 'pointer', + 'id' => 'mouse', + 'parameters' => ['pointerType' => 'mouse'], + 'actions' => array_merge($moveAction, [ + [ + 'type' => 'pointerDown', + 'button' => self::BUTTON_RIGHT, + ], + [ + 'type' => 'pointerUp', + 'button' => self::BUTTON_RIGHT, + ], + ]), + ], + ], + ]); + + return $this; + } + $this->moveIfNeeded($where); $this->executor->execute(DriverCommand::CLICK, [ - 'button' => 2, + 'button' => self::BUTTON_RIGHT, ]); return $this; } /** - * @param WebDriverCoordinates $where - * * @return RemoteMouse */ - public function doubleClick(WebDriverCoordinates $where = null) + public function doubleClick(?WebDriverCoordinates $where = null) { + if ($this->isW3cCompliant) { + $clickActions = $this->createClickActions(); + $moveAction = $where === null ? [] : [$this->createMoveAction($where)]; + $this->executor->execute(DriverCommand::ACTIONS, [ + 'actions' => [ + [ + 'type' => 'pointer', + 'id' => 'mouse', + 'parameters' => ['pointerType' => 'mouse'], + 'actions' => array_merge($moveAction, $clickActions, $clickActions), + ], + ], + ]); + + return $this; + } + $this->moveIfNeeded($where); $this->executor->execute(DriverCommand::DOUBLE_CLICK); @@ -80,12 +131,31 @@ public function doubleClick(WebDriverCoordinates $where = null) } /** - * @param WebDriverCoordinates $where - * * @return RemoteMouse */ - public function mouseDown(WebDriverCoordinates $where = null) + public function mouseDown(?WebDriverCoordinates $where = null) { + if ($this->isW3cCompliant) { + $this->executor->execute(DriverCommand::ACTIONS, [ + 'actions' => [ + [ + 'type' => 'pointer', + 'id' => 'mouse', + 'parameters' => ['pointerType' => 'mouse'], + 'actions' => [ + $this->createMoveAction($where), + [ + 'type' => 'pointerDown', + 'button' => self::BUTTON_LEFT, + ], + ], + ], + ], + ]); + + return $this; + } + $this->moveIfNeeded($where); $this->executor->execute(DriverCommand::MOUSE_DOWN); @@ -93,17 +163,31 @@ public function mouseDown(WebDriverCoordinates $where = null) } /** - * @param WebDriverCoordinates $where * @param int|null $x_offset * @param int|null $y_offset * * @return RemoteMouse */ public function mouseMove( - WebDriverCoordinates $where = null, + ?WebDriverCoordinates $where = null, $x_offset = null, $y_offset = null ) { + if ($this->isW3cCompliant) { + $this->executor->execute(DriverCommand::ACTIONS, [ + 'actions' => [ + [ + 'type' => 'pointer', + 'id' => 'mouse', + 'parameters' => ['pointerType' => 'mouse'], + 'actions' => [$this->createMoveAction($where, $x_offset, $y_offset)], + ], + ], + ]); + + return $this; + } + $params = []; if ($where !== null) { $params['element'] = $where->getAuxiliary(); @@ -114,31 +198,93 @@ public function mouseMove( if ($y_offset !== null) { $params['yoffset'] = $y_offset; } + $this->executor->execute(DriverCommand::MOVE_TO, $params); return $this; } /** - * @param WebDriverCoordinates $where - * * @return RemoteMouse */ - public function mouseUp(WebDriverCoordinates $where = null) + public function mouseUp(?WebDriverCoordinates $where = null) { + if ($this->isW3cCompliant) { + $moveAction = $where ? [$this->createMoveAction($where)] : []; + + $this->executor->execute(DriverCommand::ACTIONS, [ + 'actions' => [ + [ + 'type' => 'pointer', + 'id' => 'mouse', + 'parameters' => ['pointerType' => 'mouse'], + 'actions' => array_merge($moveAction, [ + [ + 'type' => 'pointerUp', + 'button' => self::BUTTON_LEFT, + ], + ]), + ], + ], + ]); + + return $this; + } + $this->moveIfNeeded($where); $this->executor->execute(DriverCommand::MOUSE_UP); return $this; } - /** - * @param WebDriverCoordinates $where - */ - protected function moveIfNeeded(WebDriverCoordinates $where = null) + protected function moveIfNeeded(?WebDriverCoordinates $where = null) { if ($where) { $this->mouseMove($where); } } + + /** + * @param int|null $x_offset + * @param int|null $y_offset + * + * @return array + */ + private function createMoveAction( + ?WebDriverCoordinates $where = null, + $x_offset = null, + $y_offset = null + ) { + $move_action = [ + 'type' => 'pointerMove', + 'duration' => 100, // to simulate human delay + 'x' => $x_offset ?? 0, + 'y' => $y_offset ?? 0, + ]; + + if ($where !== null) { + $move_action['origin'] = [JsonWireCompat::WEB_DRIVER_ELEMENT_IDENTIFIER => $where->getAuxiliary()]; + } else { + $move_action['origin'] = 'pointer'; + } + + return $move_action; + } + + /** + * @return array + */ + private function createClickActions() + { + return [ + [ + 'type' => 'pointerDown', + 'button' => self::BUTTON_LEFT, + ], + [ + 'type' => 'pointerUp', + 'button' => self::BUTTON_LEFT, + ], + ]; + } } diff --git a/lib/Remote/RemoteStatus.php b/lib/Remote/RemoteStatus.php new file mode 100644 index 000000000..73ebeba21 --- /dev/null +++ b/lib/Remote/RemoteStatus.php @@ -0,0 +1,79 @@ +isReady = (bool) $isReady; + $this->message = (string) $message; + + $this->setMeta($meta); + } + + /** + * @return RemoteStatus + */ + public static function createFromResponse(array $responseBody) + { + $object = new static($responseBody['ready'], $responseBody['message'], $responseBody); + + return $object; + } + + /** + * The remote end's readiness state. + * False if an attempt to create a session at the current time would fail. + * However, the value true does not guarantee that a New Session command will succeed. + * + * @return bool + */ + public function isReady() + { + return $this->isReady; + } + + /** + * An implementation-defined string explaining the remote end's readiness state. + * + * @return string + */ + public function getMessage() + { + return $this->message; + } + + /** + * Arbitrary meta information specific to remote-end implementation. + * + * @return array + */ + public function getMeta() + { + return $this->meta; + } + + protected function setMeta(array $meta) + { + unset($meta['ready'], $meta['message']); + + $this->meta = $meta; + } +} diff --git a/lib/Remote/RemoteTargetLocator.php b/lib/Remote/RemoteTargetLocator.php index 9b4fe2526..979b9c4f6 100644 --- a/lib/Remote/RemoteTargetLocator.php +++ b/lib/Remote/RemoteTargetLocator.php @@ -1,21 +1,8 @@ executor = $executor; $this->driver = $driver; + $this->isW3cCompliant = $isW3cCompliant; } /** - * Switch to the main document if the page contains iframes. Otherwise, switch - * to the first frame on the page. - * - * @return WebDriver The driver focused on the top window or the first frame. + * @return RemoteWebDriver */ public function defaultContent() { @@ -55,18 +38,35 @@ public function defaultContent() } /** - * Switch to the iframe by its id or name. - * - * @param WebDriverElement|string $frame The WebDriverElement, - * the id or the name of the frame. - * @return WebDriver The driver focused on the given frame. + * @param WebDriverElement|null|int|string $frame The WebDriverElement, the id or the name of the frame. + * When null, switch to the current top-level browsing context When int, switch to the WindowProxy identified + * by the value. When an Element, switch to that Element. + * @return RemoteWebDriver */ public function frame($frame) { - if ($frame instanceof WebDriverElement) { - $id = ['ELEMENT' => $frame->getID()]; + if ($this->isW3cCompliant) { + if ($frame instanceof WebDriverElement) { + $id = [JsonWireCompat::WEB_DRIVER_ELEMENT_IDENTIFIER => $frame->getID()]; + } elseif ($frame === null) { + $id = null; + } elseif (is_int($frame)) { + $id = $frame; + } else { + throw LogicException::forError( + 'In W3C compliance mode frame must be either instance of WebDriverElement, integer or null' + ); + } } else { - $id = (string) $frame; + if ($frame instanceof WebDriverElement) { + $id = ['ELEMENT' => $frame->getID()]; + } elseif ($frame === null) { + $id = null; + } elseif (is_int($frame)) { + $id = $frame; + } else { + $id = (string) $frame; + } } $params = ['id' => $id]; @@ -76,35 +76,67 @@ public function frame($frame) } /** - * Switch the focus to another window by its handle. + * Switch to the parent iframe. * + * @return RemoteWebDriver This driver focused on the parent frame + */ + public function parent() + { + $this->executor->execute(DriverCommand::SWITCH_TO_PARENT_FRAME, []); + + return $this->driver; + } + + /** * @param string $handle The handle of the window to be focused on. - * @return WebDriver Tge driver focused on the given window. - * @see WebDriver::getWindowHandles + * @return RemoteWebDriver */ public function window($handle) { - $params = ['name' => (string) $handle]; + if ($this->isW3cCompliant) { + $params = ['handle' => (string) $handle]; + } else { + $params = ['name' => (string) $handle]; + } + $this->executor->execute(DriverCommand::SWITCH_TO_WINDOW, $params); return $this->driver; } /** - * Switch to the currently active modal dialog for this particular driver - * instance. + * Creates a new browser window and switches the focus for future commands of this driver to the new window. * - * @return WebDriverAlert + * @see https://w3c.github.io/webdriver/#new-window + * @param string $windowType The type of a new browser window that should be created. One of [tab, window]. + * The created window is not guaranteed to be of the requested type; if the driver does not support the requested + * type, a new browser window will be created of whatever type the driver does support. + * @throws LogicException + * @return RemoteWebDriver This driver focused on the given window */ + public function newWindow($windowType = self::WINDOW_TYPE_TAB) + { + if ($windowType !== self::WINDOW_TYPE_TAB && $windowType !== self::WINDOW_TYPE_WINDOW) { + throw LogicException::forError('Window type must by either "tab" or "window"'); + } + + if (!$this->isW3cCompliant) { + throw LogicException::forError('New window is only supported in W3C mode'); + } + + $response = $this->executor->execute(DriverCommand::NEW_WINDOW, ['type' => $windowType]); + + $this->window($response['handle']); + + return $this->driver; + } + public function alert() { return new WebDriverAlert($this->executor); } /** - * Switches to the element that currently has focus within the document - * currently "switched to", or the body element if this cannot be detected. - * * @return RemoteWebElement */ public function activeElement() @@ -112,6 +144,6 @@ public function activeElement() $response = $this->driver->execute(DriverCommand::GET_ACTIVE_ELEMENT, []); $method = new RemoteExecuteMethod($this->driver); - return new RemoteWebElement($method, $response['ELEMENT']); + return new RemoteWebElement($method, JsonWireCompat::getElement($response), $this->isW3cCompliant); } } diff --git a/lib/Remote/RemoteTouchScreen.php b/lib/Remote/RemoteTouchScreen.php index 8c3981474..951c8619a 100644 --- a/lib/Remote/RemoteTouchScreen.php +++ b/lib/Remote/RemoteTouchScreen.php @@ -1,17 +1,4 @@ executor = $executor; } /** - * @param WebDriverElement $element - * * @return RemoteTouchScreen The instance. */ public function tap(WebDriverElement $element) @@ -52,8 +34,6 @@ public function tap(WebDriverElement $element) } /** - * @param WebDriverElement $element - * * @return RemoteTouchScreen The instance. */ public function doubleTap(WebDriverElement $element) @@ -99,7 +79,6 @@ public function flick($xspeed, $yspeed) } /** - * @param WebDriverElement $element * @param int $xoffset * @param int $yoffset * @param int $speed @@ -119,8 +98,6 @@ public function flickFromElement(WebDriverElement $element, $xoffset, $yoffset, } /** - * @param WebDriverElement $element - * * @return RemoteTouchScreen The instance. */ public function longPress(WebDriverElement $element) @@ -166,7 +143,6 @@ public function scroll($xoffset, $yoffset) } /** - * @param WebDriverElement $element * @param int $xoffset * @param int $yoffset * diff --git a/lib/Remote/RemoteWebDriver.php b/lib/Remote/RemoteWebDriver.php index 964740ead..3d65aaf0b 100644 --- a/lib/Remote/RemoteWebDriver.php +++ b/lib/Remote/RemoteWebDriver.php @@ -1,36 +1,33 @@ executor = $commandExecutor; + $this->sessionID = $sessionId; + $this->isW3cCompliant = $isW3cCompliant; + $this->capabilities = $capabilities; } /** @@ -61,11 +74,13 @@ protected function __construct() * * @param string $selenium_server_url The url of the remote Selenium WebDriver server * @param DesiredCapabilities|array $desired_capabilities The desired capabilities - * @param int|null $connection_timeout_in_ms - * @param int|null $request_timeout_in_ms - * @param string|null $http_proxy The proxy to tunnel requests through - * @param int|null $http_proxy_port - * @return RemoteWebDriver + * @param int|null $connection_timeout_in_ms Set timeout for the connect phase to remote Selenium WebDriver server + * @param int|null $request_timeout_in_ms Set the maximum time of a request to remote Selenium WebDriver server + * @param string|null $http_proxy The proxy to tunnel requests to the remote Selenium WebDriver through + * @param int|null $http_proxy_port The proxy port to tunnel requests to the remote Selenium WebDriver through + * @param DesiredCapabilities $required_capabilities The required capabilities + * + * @return static */ public static function create( $selenium_server_url = 'http://localhost:4444/wd/hub', @@ -73,15 +88,12 @@ public static function create( $connection_timeout_in_ms = null, $request_timeout_in_ms = null, $http_proxy = null, - $http_proxy_port = null + $http_proxy_port = null, + ?DesiredCapabilities $required_capabilities = null ) { $selenium_server_url = preg_replace('#/+$#', '', $selenium_server_url); - // Passing DesiredCapabilities as $desired_capabilities is encouraged but - // array is also accepted for legacy reason. - if ($desired_capabilities instanceof DesiredCapabilities) { - $desired_capabilities = $desired_capabilities->toArray(); - } + $desired_capabilities = self::castToDesiredCapabilitiesObject($desired_capabilities); $executor = new HttpCommandExecutor($selenium_server_url, $http_proxy, $http_proxy_port); if ($connection_timeout_in_ms !== null) { @@ -91,39 +103,83 @@ public static function create( $executor->setRequestTimeout($request_timeout_in_ms); } - $command = new WebDriverCommand( - null, - DriverCommand::NEW_SESSION, - ['desiredCapabilities' => $desired_capabilities] - ); + // W3C + $parameters = [ + 'capabilities' => [ + 'firstMatch' => [(object) $desired_capabilities->toW3cCompatibleArray()], + ], + ]; - $response = $executor->execute($command); + if ($required_capabilities !== null && !empty($required_capabilities->toArray())) { + $parameters['capabilities']['alwaysMatch'] = (object) $required_capabilities->toW3cCompatibleArray(); + } + + // Legacy protocol + if ($required_capabilities !== null) { + // TODO: Selenium (as of v3.0.1) does accept requiredCapabilities only as a property of desiredCapabilities. + // This has changed with the W3C WebDriver spec, but is the only way how to pass these + // values with the legacy protocol. + $desired_capabilities->setCapability('requiredCapabilities', (object) $required_capabilities->toArray()); + } - $driver = new static(); - $driver->setSessionID($response->getSessionID()) - ->setCommandExecutor($executor); + $parameters['desiredCapabilities'] = (object) $desired_capabilities->toArray(); - return $driver; + $command = WebDriverCommand::newSession($parameters); + + $response = $executor->execute($command); + + return static::createFromResponse($response, $executor); } /** * [Experimental] Construct the RemoteWebDriver by an existing session. * - * This constructor can boost the performance a lot by reusing the same - * browser for the whole test suite. You do not have to pass the desired - * capabilities because the session was created before. + * This constructor can boost the performance by reusing the same browser for the whole test suite. On the other + * hand, because the browser is not pristine, this may lead to flaky and dependent tests. So carefully + * consider the tradeoffs. + * + * To create the instance, we need to know Capabilities of the previously created session. You can either + * pass them in $existingCapabilities parameter, or we will attempt to receive them from the Selenium Grid server. + * However, if Capabilities were not provided and the attempt to get them was not successful, + * exception will be thrown. * - * @param string $selenium_server_url The url of the remote Selenium WebDriver server * @param string $session_id The existing session id - * @return RemoteWebDriver - */ - public static function createBySessionID($session_id, $selenium_server_url = 'http://localhost:4444/wd/hub') - { - $driver = new static(); - $driver->setSessionID($session_id) - ->setCommandExecutor(new HttpCommandExecutor($selenium_server_url)); + * @param string $selenium_server_url The url of the remote Selenium WebDriver server + * @param int|null $connection_timeout_in_ms Set timeout for the connect phase to remote Selenium WebDriver server + * @param int|null $request_timeout_in_ms Set the maximum time of a request to remote Selenium WebDriver server + * @param bool $isW3cCompliant True to use W3C WebDriver (default), false to use the legacy JsonWire protocol + * @param WebDriverCapabilities|null $existingCapabilities Provide capabilities of the existing previously created + * session. If not provided, we will attempt to read them, but this will only work when using Selenium Grid. + * @return static + */ + public static function createBySessionID( + $session_id, + $selenium_server_url = 'http://localhost:4444/wd/hub', + $connection_timeout_in_ms = null, + $request_timeout_in_ms = null + ) { + // BC layer to not break the method signature + $isW3cCompliant = func_num_args() > 4 ? func_get_arg(4) : true; + $existingCapabilities = func_num_args() > 5 ? func_get_arg(5) : null; + + $executor = new HttpCommandExecutor($selenium_server_url, null, null); + if ($connection_timeout_in_ms !== null) { + $executor->setConnectionTimeout($connection_timeout_in_ms); + } + if ($request_timeout_in_ms !== null) { + $executor->setRequestTimeout($request_timeout_in_ms); + } - return $driver; + if (!$isW3cCompliant) { + $executor->disableW3cCompliance(); + } + + // if capabilities were not provided, attempt to read them from the Selenium Grid API + if ($existingCapabilities === null) { + $existingCapabilities = self::readExistingCapabilitiesFromSeleniumGrid($session_id, $executor); + } + + return new static($executor, $session_id, $existingCapabilities, $isW3cCompliant); } /** @@ -138,42 +194,54 @@ public function close() return $this; } + /** + * Create a new top-level browsing context. + * + * @codeCoverageIgnore + * @deprecated Use $driver->switchTo()->newWindow() + * @return WebDriver The current instance. + */ + public function newWindow() + { + return $this->switchTo()->newWindow(); + } + /** * Find the first WebDriverElement using the given mechanism. * - * @param WebDriverBy $by * @return RemoteWebElement NoSuchElementException is thrown in HttpCommandExecutor if no element is found. * @see WebDriverBy */ public function findElement(WebDriverBy $by) { - $params = ['using' => $by->getMechanism(), 'value' => $by->getValue()]; $raw_element = $this->execute( DriverCommand::FIND_ELEMENT, - $params + JsonWireCompat::getUsing($by, $this->isW3cCompliant) ); - return $this->newElement($raw_element['ELEMENT']); + return $this->newElement(JsonWireCompat::getElement($raw_element)); } /** * Find all WebDriverElements within the current page using the given mechanism. * - * @param WebDriverBy $by * @return RemoteWebElement[] A list of all WebDriverElements, or an empty array if nothing matches * @see WebDriverBy */ public function findElements(WebDriverBy $by) { - $params = ['using' => $by->getMechanism(), 'value' => $by->getValue()]; $raw_elements = $this->execute( DriverCommand::FIND_ELEMENTS, - $params + JsonWireCompat::getUsing($by, $this->isW3cCompliant) ); + if (!is_array($raw_elements)) { + throw UnexpectedResponseException::forError('Server response to findElements command is not an array'); + } + $elements = []; foreach ($raw_elements as $raw_element) { - $elements[] = $this->newElement($raw_element['ELEMENT']); + $elements[] = $this->newElement(JsonWireCompat::getElement($raw_element)); } return $elements; @@ -240,6 +308,9 @@ public function getWindowHandle() /** * Get all window handles available to the current session. * + * Note: Do not use `end($driver->getWindowHandles())` to find the last open window, for proper solution see: + * https://github.com/php-webdriver/php-webdriver/wiki/Alert,-tabs,-frames,-iframes#switch-to-the-new-window + * * @return array An array of string containing all available window handles. */ public function getWindowHandles() @@ -257,32 +328,8 @@ public function quit() } /** - * Prepare arguments for JavaScript injection - * - * @param array $arguments - * @return array - */ - private function prepareScriptArguments(array $arguments) - { - $args = []; - foreach ($arguments as $key => $value) { - if ($value instanceof WebDriverElement) { - $args[$key] = ['ELEMENT' => $value->getID()]; - } else { - if (is_array($value)) { - $value = $this->prepareScriptArguments($value); - } - $args[$key] = $value; - } - } - - return $args; - } - - /** - * Inject a snippet of JavaScript into the page for execution in the context - * of the currently selected frame. The executed script is assumed to be - * synchronous and the result of evaluating the script will be returned. + * Inject a snippet of JavaScript into the page for execution in the context of the currently selected frame. + * The executed script is assumed to be synchronous and the result of evaluating the script will be returned. * * @param string $script The script to inject. * @param array $arguments The arguments of the script. @@ -299,13 +346,12 @@ public function executeScript($script, array $arguments = []) } /** - * Inject a snippet of JavaScript into the page for asynchronous execution in - * the context of the currently selected frame. + * Inject a snippet of JavaScript into the page for asynchronous execution in the context of the currently selected + * frame. * - * The driver will pass a callback as the last argument to the snippet, and - * block until the callback is invoked. + * The driver will pass a callback as the last argument to the snippet, and block until the callback is invoked. * - * @see WebDriverExecuteAsyncScriptTestCase + * You may need to define script timeout using `setScriptTimeout()` method of `WebDriverTimeouts` first. * * @param string $script The script to inject. * @param array $arguments The arguments of the script. @@ -332,24 +378,28 @@ public function executeAsyncScript($script, array $arguments = []) */ public function takeScreenshot($save_as = null) { - $screenshot = base64_decode( - $this->execute(DriverCommand::SCREENSHOT) - ); - if ($save_as) { - file_put_contents($save_as, $screenshot); - } + return (new ScreenshotHelper($this->getExecuteMethod()))->takePageScreenshot($save_as); + } - return $screenshot; + /** + * Status returns information about whether a remote end is in a state in which it can create new sessions. + */ + public function getStatus() + { + $response = $this->execute(DriverCommand::STATUS); + + return RemoteStatus::createFromResponse($response); } /** * Construct a new WebDriverWait by the current WebDriver instance. * Sample usage: * + * ``` * $driver->wait(20, 1000)->until( * WebDriverExpectedCondition::titleIs('WebDriver Page') * ); - * + * ``` * @param int $timeout_in_second * @param int $interval_in_millisecond * @@ -371,7 +421,7 @@ public function wait($timeout_in_second = 30, $interval_in_millisecond = 250) */ public function manage() { - return new WebDriverOptions($this->getExecuteMethod()); + return new WebDriverOptions($this->getExecuteMethod(), $this->isW3cCompliant); } /** @@ -393,7 +443,7 @@ public function navigate() */ public function switchTo() { - return new RemoteTargetLocator($this->getExecuteMethod(), $this); + return new RemoteTargetLocator($this->getExecuteMethod(), $this, $this->isW3cCompliant); } /** @@ -402,7 +452,7 @@ public function switchTo() public function getMouse() { if (!$this->mouse) { - $this->mouse = new RemoteMouse($this->getExecuteMethod()); + $this->mouse = new RemoteMouse($this->getExecuteMethod(), $this->isW3cCompliant); } return $this->mouse; @@ -414,7 +464,7 @@ public function getMouse() public function getKeyboard() { if (!$this->keyboard) { - $this->keyboard = new RemoteKeyboard($this->getExecuteMethod()); + $this->keyboard = new RemoteKeyboard($this->getExecuteMethod(), $this, $this->isW3cCompliant); } return $this->keyboard; @@ -432,18 +482,6 @@ public function getTouch() return $this->touch; } - /** - * @return RemoteExecuteMethod - */ - protected function getExecuteMethod() - { - if (!$this->executeMethod) { - $this->executeMethod = new RemoteExecuteMethod($this); - } - - return $this->executeMethod; - } - /** * Construct a new action builder. * @@ -454,21 +492,13 @@ public function action() return new WebDriverActions($this); } - /** - * Return the WebDriverElement with the given id. - * - * @param string $id The id of the element to be created. - * @return RemoteWebElement - */ - protected function newElement($id) - { - return new RemoteWebElement($this->getExecuteMethod(), $id); - } - /** * Set the command executor of this RemoteWebdriver * - * @param WebDriverCommandExecutor $executor + * @deprecated To be removed in the future. Executor should be passed in the constructor. + * @internal + * @codeCoverageIgnore + * @param WebDriverCommandExecutor $executor Despite the typehint, it have be an instance of HttpCommandExecutor. * @return RemoteWebDriver */ public function setCommandExecutor(WebDriverCommandExecutor $executor) @@ -479,7 +509,7 @@ public function setCommandExecutor(WebDriverCommandExecutor $executor) } /** - * Set the command executor of this RemoteWebdriver + * Get the command executor of this RemoteWebdriver * * @return HttpCommandExecutor */ @@ -491,6 +521,9 @@ public function getCommandExecutor() /** * Set the session id of the RemoteWebDriver. * + * @deprecated To be removed in the future. Session ID should be passed in the constructor. + * @internal + * @codeCoverageIgnore * @param string $session_id * @return RemoteWebDriver */ @@ -504,7 +537,7 @@ public function setSessionID($session_id) /** * Get current selenium sessionID * - * @return string sessionID + * @return string */ public function getSessionID() { @@ -512,15 +545,26 @@ public function getSessionID() } /** - * Get all selenium sessions. + * Get capabilities of the RemoteWebDriver. * + * @return WebDriverCapabilities|null + */ + public function getCapabilities() + { + return $this->capabilities; + } + + /** + * Returns a list of the currently active sessions. + * + * @deprecated Removed in W3C WebDriver. * @param string $selenium_server_url The url of the remote Selenium WebDriver server * @param int $timeout_in_ms * @return array */ public static function getAllSessions($selenium_server_url = 'http://localhost:4444/wd/hub', $timeout_in_ms = 30000) { - $executor = new HttpCommandExecutor($selenium_server_url); + $executor = new HttpCommandExecutor($selenium_server_url, null, null); $executor->setConnectionTimeout($timeout_in_ms); $command = new WebDriverCommand( @@ -534,6 +578,19 @@ public static function getAllSessions($selenium_server_url = 'http://localhost:4 public function execute($command_name, $params = []) { + // As we so far only use atom for IS_ELEMENT_DISPLAYED, this condition is hardcoded here. In case more atoms + // are used, this should be rewritten and separated from this class (e.g. to some abstract matcher logic). + if ($command_name === DriverCommand::IS_ELEMENT_DISPLAYED + && ( + // When capabilities are missing in php-webdriver 1.13.x, always fallback to use the atom + $this->getCapabilities() === null + // If capabilities are present, use the atom only if condition matches + || IsElementDisplayedAtom::match($this->getCapabilities()->getBrowserName()) + ) + ) { + return (new IsElementDisplayedAtom($this))->execute($params); + } + $command = new WebDriverCommand( $this->sessionID, $command_name, @@ -548,4 +605,156 @@ public function execute($command_name, $params = []) return null; } + + /** + * Execute custom commands on remote end. + * For example vendor-specific commands or other commands not implemented by php-webdriver. + * + * @see https://github.com/php-webdriver/php-webdriver/wiki/Custom-commands + * @param string $endpointUrl + * @param string $method + * @param array $params + * @return mixed|null + */ + public function executeCustomCommand($endpointUrl, $method = 'GET', $params = []) + { + $command = new CustomWebDriverCommand( + $this->sessionID, + $endpointUrl, + $method, + $params + ); + + if ($this->executor) { + $response = $this->executor->execute($command); + + return $response->getValue(); + } + + return null; + } + + /** + * @internal + * @return bool + */ + public function isW3cCompliant() + { + return $this->isW3cCompliant; + } + + /** + * Create instance based on response to NEW_SESSION command. + * Also detect W3C/OSS dialect and setup the driver/executor accordingly. + * + * @internal + * @return static + */ + protected static function createFromResponse(WebDriverResponse $response, HttpCommandExecutor $commandExecutor) + { + $responseValue = $response->getValue(); + + if (!$isW3cCompliant = isset($responseValue['capabilities'])) { + $commandExecutor->disableW3cCompliance(); + } + + if ($isW3cCompliant) { + $returnedCapabilities = DesiredCapabilities::createFromW3cCapabilities($responseValue['capabilities']); + } else { + $returnedCapabilities = new DesiredCapabilities($responseValue); + } + + return new static($commandExecutor, $response->getSessionID(), $returnedCapabilities, $isW3cCompliant); + } + + /** + * Prepare arguments for JavaScript injection + * + * @return array + */ + protected function prepareScriptArguments(array $arguments) + { + $args = []; + foreach ($arguments as $key => $value) { + if ($value instanceof WebDriverElement) { + $args[$key] = [ + $this->isW3cCompliant ? + JsonWireCompat::WEB_DRIVER_ELEMENT_IDENTIFIER + : 'ELEMENT' => $value->getID(), + ]; + } else { + if (is_array($value)) { + $value = $this->prepareScriptArguments($value); + } + $args[$key] = $value; + } + } + + return $args; + } + + /** + * @return RemoteExecuteMethod + */ + protected function getExecuteMethod() + { + if (!$this->executeMethod) { + $this->executeMethod = new RemoteExecuteMethod($this); + } + + return $this->executeMethod; + } + + /** + * Return the WebDriverElement with the given id. + * + * @param string $id The id of the element to be created. + * @return RemoteWebElement + */ + protected function newElement($id) + { + return new RemoteWebElement($this->getExecuteMethod(), $id, $this->isW3cCompliant); + } + + /** + * Cast legacy types (array or null) to DesiredCapabilities object. To be removed in future when instance of + * DesiredCapabilities will be required. + * + * @param array|DesiredCapabilities|null $desired_capabilities + * @return DesiredCapabilities + */ + protected static function castToDesiredCapabilitiesObject($desired_capabilities = null) + { + if ($desired_capabilities === null) { + return new DesiredCapabilities(); + } + + if (is_array($desired_capabilities)) { + return new DesiredCapabilities($desired_capabilities); + } + + return $desired_capabilities; + } + + protected static function readExistingCapabilitiesFromSeleniumGrid( + string $session_id, + HttpCommandExecutor $executor + ): DesiredCapabilities { + $getCapabilitiesCommand = new CustomWebDriverCommand($session_id, '/se/grid/session/:sessionId', 'GET', []); + + try { + $capabilitiesResponse = $executor->execute($getCapabilitiesCommand); + + $existingCapabilities = DesiredCapabilities::createFromW3cCapabilities( + $capabilitiesResponse->getValue()['capabilities'] + ); + if ($existingCapabilities === null) { + throw UnexpectedResponseException::forError('Empty capabilities received'); + } + } catch (\Exception $e) { + throw UnexpectedResponseException::forCapabilitiesRetrievalError($e); + } + + return $existingCapabilities; + } } diff --git a/lib/Remote/RemoteWebElement.php b/lib/Remote/RemoteWebElement.php index dfc66d124..e0ce43b55 100644 --- a/lib/Remote/RemoteWebElement.php +++ b/lib/Remote/RemoteWebElement.php @@ -1,23 +1,16 @@ executor = $executor; $this->id = $id; $this->fileDetector = new UselessFileDetector(); + $this->isW3cCompliant = $isW3cCompliant; } /** - * If this element is a TEXTAREA or text INPUT element, this will clear the value. + * Clear content editable or resettable element * - * @return RemoteWebElement The current instance. + * @return $this The current instance. */ public function clear() { @@ -72,14 +70,21 @@ public function clear() /** * Click this element. * - * @return RemoteWebElement The current instance. + * @return $this The current instance. */ public function click() { - $this->executor->execute( - DriverCommand::CLICK_ELEMENT, - [':id' => $this->id] - ); + try { + $this->executor->execute( + DriverCommand::CLICK_ELEMENT, + [':id' => $this->id] + ); + } catch (ElementNotInteractableException $e) { + // An issue with geckodriver (https://github.com/mozilla/geckodriver/issues/653) prevents clicking on a link + // if the first child is a block-level element. + // The workaround in this case is to click on a child element. + $this->clickChildElement($e); + } return $this; } @@ -87,59 +92,66 @@ public function click() /** * Find the first WebDriverElement within this element using the given mechanism. * - * @param WebDriverBy $by - * @return RemoteWebElement NoSuchElementException is thrown in - * HttpCommandExecutor if no element is found. + * When using xpath be aware that webdriver follows standard conventions: a search prefixed with "//" will + * search the entire document from the root, not just the children (relative context) of this current node. + * Use ".//" to limit your search to the children of this element. + * + * @return static NoSuchElementException is thrown in HttpCommandExecutor if no element is found. * @see WebDriverBy */ public function findElement(WebDriverBy $by) { - $params = [ - 'using' => $by->getMechanism(), - 'value' => $by->getValue(), - ':id' => $this->id, - ]; + $params = JsonWireCompat::getUsing($by, $this->isW3cCompliant); + $params[':id'] = $this->id; + $raw_element = $this->executor->execute( DriverCommand::FIND_CHILD_ELEMENT, $params ); - return $this->newElement($raw_element['ELEMENT']); + return $this->newElement(JsonWireCompat::getElement($raw_element)); } /** * Find all WebDriverElements within this element using the given mechanism. * - * @param WebDriverBy $by - * @return RemoteWebElement[] A list of all WebDriverElements, or an empty + * When using xpath be aware that webdriver follows standard conventions: a search prefixed with "//" will + * search the entire document from the root, not just the children (relative context) of this current node. + * Use ".//" to limit your search to the children of this element. + * + * @return static[] A list of all WebDriverElements, or an empty * array if nothing matches * @see WebDriverBy */ public function findElements(WebDriverBy $by) { - $params = [ - 'using' => $by->getMechanism(), - 'value' => $by->getValue(), - ':id' => $this->id, - ]; + $params = JsonWireCompat::getUsing($by, $this->isW3cCompliant); + $params[':id'] = $this->id; $raw_elements = $this->executor->execute( DriverCommand::FIND_CHILD_ELEMENTS, $params ); + if (!is_array($raw_elements)) { + throw UnexpectedResponseException::forError('Server response to findChildElements command is not an array'); + } + $elements = []; foreach ($raw_elements as $raw_element) { - $elements[] = $this->newElement($raw_element['ELEMENT']); + $elements[] = $this->newElement(JsonWireCompat::getElement($raw_element)); } return $elements; } /** - * Get the value of a the given attribute of the element. + * Get the value of the given attribute of the element. + * Attribute is meant what is declared in the HTML markup of the element. + * To read a value of a IDL "JavaScript" property (like `innerHTML`), use `getDomProperty()` method. * * @param string $attribute_name The name of the attribute. - * @return string|null The value of the attribute. + * @return string|true|null The value of the attribute. If this is boolean attribute, return true if the element + * has it, otherwise return null. */ public function getAttribute($attribute_name) { @@ -148,10 +160,45 @@ public function getAttribute($attribute_name) ':id' => $this->id, ]; - return $this->executor->execute( - DriverCommand::GET_ELEMENT_ATTRIBUTE, - $params - ); + if ($this->isW3cCompliant && ($attribute_name === 'value' || $attribute_name === 'index')) { + $value = $this->executor->execute(DriverCommand::GET_ELEMENT_PROPERTY, $params); + + if ($value === true) { + return 'true'; + } + + if ($value === false) { + return 'false'; + } + + if ($value !== null) { + return (string) $value; + } + } + + return $this->executor->execute(DriverCommand::GET_ELEMENT_ATTRIBUTE, $params); + } + + /** + * Gets the value of a IDL JavaScript property of this element (for example `innerHTML`, `tagName` etc.). + * + * @see https://developer.mozilla.org/en-US/docs/Glossary/IDL + * @see https://developer.mozilla.org/en-US/docs/Web/API/Element#properties + * @param string $propertyName + * @return mixed|null The property's current value or null if the value is not set or the property does not exist. + */ + public function getDomProperty($propertyName) + { + if (!$this->isW3cCompliant) { + throw new UnsupportedOperationException('This method is only supported in W3C mode'); + } + + $params = [ + ':name' => $propertyName, + ':id' => $this->id, + ]; + + return $this->executor->execute(DriverCommand::GET_ELEMENT_PROPERTY, $params); } /** @@ -196,10 +243,25 @@ public function getLocation() */ public function getLocationOnScreenOnceScrolledIntoView() { - $location = $this->executor->execute( - DriverCommand::GET_ELEMENT_LOCATION_ONCE_SCROLLED_INTO_VIEW, - [':id' => $this->id] - ); + if ($this->isW3cCompliant) { + $script = <<executor->execute(DriverCommand::EXECUTE_SCRIPT, [ + 'script' => $script, + 'args' => [[JsonWireCompat::WEB_DRIVER_ELEMENT_IDENTIFIER => $this->id]], + ]); + $location = ['x' => $result['x'], 'y' => $result['y']]; + } else { + $location = $this->executor->execute( + DriverCommand::GET_ELEMENT_LOCATION_ONCE_SCROLLED_INTO_VIEW, + [':id' => $this->id] + ); + } return new WebDriverPoint($location['x'], $location['y']); } @@ -212,10 +274,10 @@ public function getCoordinates() $element = $this; $on_screen = null; // planned but not yet implemented - $in_view_port = function () use ($element) { + $in_view_port = static function () use ($element) { return $element->getLocationOnScreenOnceScrolledIntoView(); }; - $on_page = function () use ($element) { + $on_page = static function () use ($element) { return $element->getLocation(); }; $auxiliary = $this->getID(); @@ -244,17 +306,17 @@ public function getSize() } /** - * Get the tag name of this element. + * Get the (lowercase) tag name of this element. * * @return string The tag name. */ public function getTagName() { - // Force tag name to be lowercase as expected by protocol for Opera driver + // Force tag name to be lowercase as expected by JsonWire protocol for Opera driver // until this issue is not resolved : // https://github.com/operasoftware/operadriver/issues/102 // Remove it when fixed to be consistent with the protocol. - return strtolower($this->executor->execute( + return mb_strtolower($this->executor->execute( DriverCommand::GET_ELEMENT_TAG_NAME, [':id' => $this->id] )); @@ -303,7 +365,7 @@ public function isEnabled() } /** - * Determine whether or not this element is selected or not. + * Determine whether this element is selected or not. * * @return bool */ @@ -319,76 +381,69 @@ public function isSelected() * Simulate typing into an element, which may set its value. * * @param mixed $value The data to be typed. - * @return RemoteWebElement The current instance. + * @return static The current instance. */ public function sendKeys($value) { $local_file = $this->fileDetector->getLocalFile($value); + + $params = []; if ($local_file === null) { - $params = [ - 'value' => WebDriverKeys::encode($value), - ':id' => $this->id, - ]; - $this->executor->execute(DriverCommand::SEND_KEYS_TO_ELEMENT, $params); + if ($this->isW3cCompliant) { + // Work around the Geckodriver NULL issue by splitting on NULL and calling sendKeys multiple times. + // See https://bugzilla.mozilla.org/show_bug.cgi?id=1494661. + $encodedValues = explode(WebDriverKeys::NULL, WebDriverKeys::encode($value, true)); + foreach ($encodedValues as $encodedValue) { + $params[] = [ + 'text' => $encodedValue, + ':id' => $this->id, + ]; + } + } else { + $params[] = [ + 'value' => WebDriverKeys::encode($value), + ':id' => $this->id, + ]; + } } else { - $remote_path = $this->upload($local_file); - $params = [ - 'value' => WebDriverKeys::encode($remote_path), - ':id' => $this->id, - ]; - $this->executor->execute(DriverCommand::SEND_KEYS_TO_ELEMENT, $params); + if ($this->isW3cCompliant) { + try { + // Attempt to upload the file to the remote browser. + // This is so far non-W3C compliant method, so it may fail - if so, we just ignore the exception. + // @see https://github.com/w3c/webdriver/issues/1355 + $fileName = $this->upload($local_file); + } catch (PhpWebDriverExceptionInterface $e) { + $fileName = $local_file; + } + + $params[] = [ + 'text' => $fileName, + ':id' => $this->id, + ]; + } else { + $params[] = [ + 'value' => WebDriverKeys::encode($this->upload($local_file)), + ':id' => $this->id, + ]; + } } - return $this; - } - - /** - * Upload a local file to the server - * - * @param string $local_file - * - * @throws WebDriverException - * @return string The remote path of the file. - */ - private function upload($local_file) - { - if (!is_file($local_file)) { - throw new WebDriverException('You may only upload files: ' . $local_file); + foreach ($params as $param) { + $this->executor->execute(DriverCommand::SEND_KEYS_TO_ELEMENT, $param); } - // Create a temporary file in the system temp directory. - $temp_zip = tempnam(sys_get_temp_dir(), 'WebDriverZip'); - $zip = new ZipArchive(); - if ($zip->open($temp_zip, ZipArchive::CREATE) !== true) { - return false; - } - $info = pathinfo($local_file); - $file_name = $info['basename']; - $zip->addFile($local_file, $file_name); - $zip->close(); - $params = [ - 'file' => base64_encode(file_get_contents($temp_zip)), - ]; - $remote_path = $this->executor->execute( - DriverCommand::UPLOAD_FILE, - $params - ); - unlink($temp_zip); - - return $remote_path; + return $this; } /** - * Set the fileDetector in order to let the RemoteWebElement to know that - * you are going to upload a file. + * Set the fileDetector in order to let the RemoteWebElement to know that you are going to upload a file. * * Basically, if you want WebDriver trying to send a file, set the fileDetector * to be LocalFileDetector. Otherwise, keep it UselessFileDetector. * - * eg. $element->setFileDetector(new LocalFileDetector); + * eg. `$element->setFileDetector(new LocalFileDetector);` * - * @param FileDetector $detector - * @return RemoteWebElement + * @return $this * @see FileDetector * @see LocalFileDetector * @see UselessFileDetector @@ -403,10 +458,34 @@ public function setFileDetector(FileDetector $detector) /** * If this current element is a form, or an element within a form, then this will be submitted to the remote server. * - * @return RemoteWebElement The current instance. + * @return $this The current instance. */ public function submit() { + if ($this->isW3cCompliant) { + // Submit method cannot be called directly in case an input of this form is named "submit". + // We use this polyfill to trigger 'submit' event using form.dispatchEvent(). + $submitPolyfill = <<executor->execute(DriverCommand::EXECUTE_SCRIPT, [ + 'script' => $submitPolyfill, + 'args' => [[JsonWireCompat::WEB_DRIVER_ELEMENT_IDENTIFIER => $this->id]], + ]); + + return $this; + } + $this->executor->execute( DriverCommand::SUBMIT_ELEMENT, [':id' => $this->id] @@ -426,28 +505,146 @@ public function getID() } /** - * Test if two element IDs refer to the same DOM element. + * Take a screenshot of a specific element. + * + * @param string $save_as The path of the screenshot to be saved. + * @return string The screenshot in PNG format. + */ + public function takeElementScreenshot($save_as = null) + { + return (new ScreenshotHelper($this->executor))->takeElementScreenshot($this->id, $save_as); + } + + /** + * Test if two elements IDs refer to the same DOM element. * - * @param WebDriverElement $other * @return bool */ public function equals(WebDriverElement $other) { + if ($this->isW3cCompliant) { + return $this->getID() === $other->getID(); + } + return $this->executor->execute(DriverCommand::ELEMENT_EQUALS, [ ':id' => $this->id, ':other' => $other->getID(), ]); } + /** + * Get representation of an element's shadow root for accessing the shadow DOM of a web component. + * + * @return ShadowRoot + */ + public function getShadowRoot() + { + if (!$this->isW3cCompliant) { + throw new UnsupportedOperationException('This method is only supported in W3C mode'); + } + + $response = $this->executor->execute( + DriverCommand::GET_ELEMENT_SHADOW_ROOT, + [ + ':id' => $this->id, + ] + ); + + return ShadowRoot::createFromResponse($this->executor, $response); + } + + /** + * Attempt to click on a child level element. + * + * This provides a workaround for geckodriver bug 653 whereby a link whose first element is a block-level element + * throws an ElementNotInteractableException could not scroll into view exception. + * + * The workaround provided here attempts to click on a child node of the element. + * In case the first child is hidden, other elements are processed until we run out of elements. + * + * @param ElementNotInteractableException $originalException The exception to throw if unable to click on any child + * @see https://github.com/mozilla/geckodriver/issues/653 + * @see https://bugzilla.mozilla.org/show_bug.cgi?id=1374283 + */ + protected function clickChildElement(ElementNotInteractableException $originalException) + { + $children = $this->findElements(WebDriverBy::xpath('./*')); + foreach ($children as $child) { + try { + // Note: This does not use $child->click() as this would cause recursion into all children. + // Where the element is hidden, all children will also be hidden. + $this->executor->execute( + DriverCommand::CLICK_ELEMENT, + [':id' => $child->id] + ); + + return; + } catch (ElementNotInteractableException $e) { + // Ignore the ElementNotInteractableException exception on this node. Try the next child instead. + } + } + + throw $originalException; + } + /** * Return the WebDriverElement with $id * * @param string $id * - * @return RemoteWebElement + * @return static */ protected function newElement($id) { - return new static($this->executor, $id); + return new static($this->executor, $id, $this->isW3cCompliant); + } + + /** + * Upload a local file to the server + * + * @param string $local_file + * + * @throws LogicException + * @return string The remote path of the file. + */ + protected function upload($local_file) + { + if (!is_file($local_file)) { + throw LogicException::forError('You may only upload files: ' . $local_file); + } + + $temp_zip_path = $this->createTemporaryZipArchive($local_file); + + $remote_path = $this->executor->execute( + DriverCommand::UPLOAD_FILE, + ['file' => base64_encode(file_get_contents($temp_zip_path))] + ); + + unlink($temp_zip_path); + + return $remote_path; + } + + /** + * @param string $fileToZip + * @return string + */ + protected function createTemporaryZipArchive($fileToZip) + { + // Create a temporary file in the system temp directory. + // Intentionally do not use `tempnam()`, as it creates empty file which zip extension may not handle. + $tempZipPath = sys_get_temp_dir() . '/' . uniqid('WebDriverZip', false); + + $zip = new ZipArchive(); + if (($errorCode = $zip->open($tempZipPath, ZipArchive::CREATE)) !== true) { + throw IOException::forFileError(sprintf('Error creating zip archive: %s', $errorCode), $tempZipPath); + } + + $info = pathinfo($fileToZip); + $file_name = $info['basename']; + $zip->addFile($fileToZip, $file_name); + $zip->close(); + + return $tempZipPath; } } diff --git a/lib/Remote/Service/DriverCommandExecutor.php b/lib/Remote/Service/DriverCommandExecutor.php index 59125833b..5e3ef8399 100644 --- a/lib/Remote/Service/DriverCommandExecutor.php +++ b/lib/Remote/Service/DriverCommandExecutor.php @@ -1,28 +1,16 @@ service->isRunning()) { - throw new WebDriverException('The driver server has died.'); + throw new DriverServerDiedException($e); } throw $e; } diff --git a/lib/Remote/Service/DriverService.php b/lib/Remote/Service/DriverService.php index 634c45864..028b8cd83 100644 --- a/lib/Remote/Service/DriverService.php +++ b/lib/Remote/Service/DriverService.php @@ -1,27 +1,15 @@ executable = self::checkExecutable($executable); + $this->setExecutable($executable); $this->url = sprintf('http://localhost:%d', $port); $this->args = $args; $this->environment = $environment ?: $_ENV; @@ -81,14 +69,11 @@ public function start() return $this; } - $processBuilder = (new ProcessBuilder()) - ->setPrefix($this->executable) - ->setArguments($this->args) - ->addEnvironmentVariables($this->environment); - - $this->process = $processBuilder->getProcess(); + $this->process = $this->createProcess(); $this->process->start(); + $this->checkWasStarted($this->process); + $checker = new URLChecker(); $checker->waitUntilAvailable(20 * 1000, $this->url . '/status'); @@ -126,22 +111,73 @@ public function isRunning() } /** - * Check if the executable is executable. - * + * @deprecated Has no effect. Will be removed in next major version. Executable is now checked + * when calling setExecutable(). * @param string $executable - * @throws Exception * @return string */ protected static function checkExecutable($executable) { - if (!is_file($executable)) { - throw new Exception("'$executable' is not a file."); + return $executable; + } + + /** + * @param string $executable + * @throws IOException + */ + protected function setExecutable($executable) + { + if ($this->isExecutable($executable)) { + $this->executable = $executable; + + return; } - if (!is_executable($executable)) { - throw new Exception("'$executable' is not executable."); + throw IOException::forFileError( + 'File is not executable. Make sure the path is correct or use environment variable to specify' + . ' location of the executable.', + $executable + ); + } + + /** + * @param Process $process + */ + protected function checkWasStarted($process) + { + usleep(10000); // wait 10ms, otherwise the asynchronous process failure may not yet be propagated + + if (!$process->isRunning()) { + throw RuntimeException::forDriverError($process); } + } - return $executable; + private function createProcess(): Process + { + $commandLine = array_merge([$this->executable], $this->args); + + return new Process($commandLine, null, $this->environment); + } + + /** + * Check whether given file is executable directly or using system PATH + */ + private function isExecutable(string $filename): bool + { + if (is_executable($filename)) { + return true; + } + if ($filename !== basename($filename)) { // $filename is an absolute path, do no try to search it in PATH + return false; + } + + $paths = explode(PATH_SEPARATOR, getenv('PATH')); + foreach ($paths as $path) { + if (is_executable($path . DIRECTORY_SEPARATOR . $filename)) { + return true; + } + } + + return false; } } diff --git a/lib/Remote/ShadowRoot.php b/lib/Remote/ShadowRoot.php new file mode 100644 index 000000000..5d419b19a --- /dev/null +++ b/lib/Remote/ShadowRoot.php @@ -0,0 +1,98 @@ +executor = $executor; + $this->id = $id; + } + + /** + * @return self + */ + public static function createFromResponse(RemoteExecuteMethod $executor, array $response) + { + if (empty($response[self::SHADOW_ROOT_IDENTIFIER])) { + throw new UnknownErrorException('Shadow root is missing in server response'); + } + + return new self($executor, $response[self::SHADOW_ROOT_IDENTIFIER]); + } + + /** + * @return RemoteWebElement + */ + public function findElement(WebDriverBy $locator) + { + $params = JsonWireCompat::getUsing($locator, true); + $params[':id'] = $this->id; + + $rawElement = $this->executor->execute( + DriverCommand::FIND_ELEMENT_FROM_SHADOW_ROOT, + $params + ); + + return new RemoteWebElement($this->executor, JsonWireCompat::getElement($rawElement), true); + } + + /** + * @return WebDriverElement[] + */ + public function findElements(WebDriverBy $locator) + { + $params = JsonWireCompat::getUsing($locator, true); + $params[':id'] = $this->id; + + $rawElements = $this->executor->execute( + DriverCommand::FIND_ELEMENTS_FROM_SHADOW_ROOT, + $params + ); + + if (!is_array($rawElements)) { + throw UnexpectedResponseException::forError( + 'Server response to findElementsFromShadowRoot command is not an array' + ); + } + + $elements = []; + foreach ($rawElements as $rawElement) { + $elements[] = new RemoteWebElement($this->executor, JsonWireCompat::getElement($rawElement), true); + } + + return $elements; + } + + /** + * @return string + */ + public function getID() + { + return $this->id; + } +} diff --git a/lib/Remote/UselessFileDetector.php b/lib/Remote/UselessFileDetector.php index 5ddd78e7d..6bce0e004 100644 --- a/lib/Remote/UselessFileDetector.php +++ b/lib/Remote/UselessFileDetector.php @@ -1,17 +1,4 @@ parameters = $parameters; } + /** + * @return self + */ + public static function newSession(array $parameters) + { + // TODO: In 2.0 call empty constructor and assign properties directly. + return new self(null, DriverCommand::NEW_SESSION, $parameters); + } + /** * @return string */ @@ -45,7 +43,7 @@ public function getName() } /** - * @return string + * @return string|null Could be null for newSession command */ public function getSessionID() { diff --git a/lib/Remote/WebDriverResponse.php b/lib/Remote/WebDriverResponse.php index 92ac6f66d..39b19bf09 100644 --- a/lib/Remote/WebDriverResponse.php +++ b/lib/Remote/WebDriverResponse.php @@ -1,17 +1,4 @@ dispatcher = $dispatcher ?: new WebDriverDispatcher(); if (!$this->dispatcher->getDefaultDriver()) { $this->dispatcher->setDefaultDriver($this); } $this->driver = $driver; - - return $this; } /** @@ -62,20 +43,6 @@ public function getDispatcher() return $this->dispatcher; } - /** - * @param mixed $method - */ - protected function dispatch($method) - { - if (!$this->dispatcher) { - return; - } - - $arguments = func_get_args(); - unset($arguments[0]); - $this->dispatcher->dispatch($method, $arguments); - } - /** * @return WebDriver */ @@ -84,15 +51,6 @@ public function getWebDriver() return $this->driver; } - /** - * @param WebDriverElement $element - * @return EventFiringWebElement - */ - protected function newElement(WebDriverElement $element) - { - return new EventFiringWebElement($element, $this->getDispatcher()); - } - /** * @param mixed $url * @throws WebDriverException @@ -101,10 +59,12 @@ protected function newElement(WebDriverElement $element) public function get($url) { $this->dispatch('beforeNavigateTo', $url, $this); + try { $this->driver->get($url); } catch (WebDriverException $exception) { $this->dispatchOnException($exception); + throw $exception; } $this->dispatch('afterNavigateTo', $url, $this); @@ -112,7 +72,6 @@ public function get($url) } /** - * @param WebDriverBy $by * @throws WebDriverException * @return array */ @@ -127,6 +86,7 @@ public function findElements(WebDriverBy $by) } } catch (WebDriverException $exception) { $this->dispatchOnException($exception); + throw $exception; } $this->dispatch('afterFindBy', $by, null, $this); @@ -135,7 +95,6 @@ public function findElements(WebDriverBy $by) } /** - * @param WebDriverBy $by * @throws WebDriverException * @return EventFiringWebElement */ @@ -147,6 +106,7 @@ public function findElement(WebDriverBy $by) $element = $this->newElement($this->driver->findElement($by)); } catch (WebDriverException $exception) { $this->dispatchOnException($exception); + throw $exception; } $this->dispatch('afterFindBy', $by, null, $this); @@ -155,8 +115,7 @@ public function findElement(WebDriverBy $by) } /** - * @param $script - * @param array $arguments + * @param string $script * @throws WebDriverException * @return mixed */ @@ -174,6 +133,7 @@ public function executeScript($script, array $arguments = []) $result = $this->driver->executeScript($script, $arguments); } catch (WebDriverException $exception) { $this->dispatchOnException($exception); + throw $exception; } $this->dispatch('afterScript', $script, $this); @@ -182,8 +142,7 @@ public function executeScript($script, array $arguments = []) } /** - * @param $script - * @param array $arguments + * @param string $script * @throws WebDriverException * @return mixed */ @@ -196,10 +155,12 @@ public function executeAsyncScript($script, array $arguments = []) } $this->dispatch('beforeScript', $script, $this); + try { $result = $this->driver->executeAsyncScript($script, $arguments); } catch (WebDriverException $exception) { $this->dispatchOnException($exception); + throw $exception; } $this->dispatch('afterScript', $script, $this); @@ -218,6 +179,7 @@ public function close() return $this; } catch (WebDriverException $exception) { $this->dispatchOnException($exception); + throw $exception; } } @@ -231,6 +193,7 @@ public function getCurrentURL() return $this->driver->getCurrentURL(); } catch (WebDriverException $exception) { $this->dispatchOnException($exception); + throw $exception; } } @@ -244,6 +207,7 @@ public function getPageSource() return $this->driver->getPageSource(); } catch (WebDriverException $exception) { $this->dispatchOnException($exception); + throw $exception; } } @@ -257,6 +221,7 @@ public function getTitle() return $this->driver->getTitle(); } catch (WebDriverException $exception) { $this->dispatchOnException($exception); + throw $exception; } } @@ -270,6 +235,7 @@ public function getWindowHandle() return $this->driver->getWindowHandle(); } catch (WebDriverException $exception) { $this->dispatchOnException($exception); + throw $exception; } } @@ -283,6 +249,7 @@ public function getWindowHandles() return $this->driver->getWindowHandles(); } catch (WebDriverException $exception) { $this->dispatchOnException($exception); + throw $exception; } } @@ -295,6 +262,7 @@ public function quit() $this->driver->quit(); } catch (WebDriverException $exception) { $this->dispatchOnException($exception); + throw $exception; } } @@ -309,6 +277,7 @@ public function takeScreenshot($save_as = null) return $this->driver->takeScreenshot($save_as); } catch (WebDriverException $exception) { $this->dispatchOnException($exception); + throw $exception; } } @@ -324,6 +293,7 @@ public function wait($timeout_in_second = 30, $interval_in_millisecond = 250) return $this->driver->wait($timeout_in_second, $interval_in_millisecond); } catch (WebDriverException $exception) { $this->dispatchOnException($exception); + throw $exception; } } @@ -337,6 +307,7 @@ public function manage() return $this->driver->manage(); } catch (WebDriverException $exception) { $this->dispatchOnException($exception); + throw $exception; } } @@ -353,6 +324,7 @@ public function navigate() ); } catch (WebDriverException $exception) { $this->dispatchOnException($exception); + throw $exception; } } @@ -366,6 +338,7 @@ public function switchTo() return $this->driver->switchTo(); } catch (WebDriverException $exception) { $this->dispatchOnException($exception); + throw $exception; } } @@ -379,21 +352,43 @@ public function getTouch() return $this->driver->getTouch(); } catch (WebDriverException $exception) { $this->dispatchOnException($exception); + throw $exception; } } - private function dispatchOnException($exception) - { - $this->dispatch('onException', $exception, $this); - throw $exception; - } - public function execute($name, $params) { try { return $this->driver->execute($name, $params); } catch (WebDriverException $exception) { $this->dispatchOnException($exception); + throw $exception; + } + } + + /** + * @return EventFiringWebElement + */ + protected function newElement(WebDriverElement $element) + { + return new EventFiringWebElement($element, $this->getDispatcher()); + } + + /** + * @param mixed $method + * @param mixed ...$arguments + */ + protected function dispatch($method, ...$arguments) + { + if (!$this->dispatcher) { + return; } + + $this->dispatcher->dispatch($method, $arguments); + } + + protected function dispatchOnException(WebDriverException $exception) + { + $this->dispatch('onException', $exception, $this); } } diff --git a/lib/Support/Events/EventFiringWebDriverNavigation.php b/lib/Support/Events/EventFiringWebDriverNavigation.php index a588d5e17..27ea34205 100644 --- a/lib/Support/Events/EventFiringWebDriverNavigation.php +++ b/lib/Support/Events/EventFiringWebDriverNavigation.php @@ -1,28 +1,15 @@ navigator = $navigator; $this->dispatcher = $dispatcher; - - return $this; } /** @@ -51,37 +32,20 @@ public function getDispatcher() } /** - * @param mixed $method - */ - protected function dispatch($method) - { - if (!$this->dispatcher) { - return; - } - - $arguments = func_get_args(); - unset($arguments[0]); - $this->dispatcher->dispatch($method, $arguments); - } - - /** - * @return WebDriverNavigation + * @return WebDriverNavigationInterface */ public function getNavigator() { return $this->navigator; } - /** - * @throws WebDriverException - * @return $this - */ public function back() { $this->dispatch( 'beforeNavigateBack', $this->getDispatcher()->getDefaultDriver() ); + try { $this->navigator->back(); } catch (WebDriverException $exception) { @@ -95,16 +59,13 @@ public function back() return $this; } - /** - * @throws WebDriverException - * @return $this - */ public function forward() { $this->dispatch( 'beforeNavigateForward', $this->getDispatcher()->getDefaultDriver() ); + try { $this->navigator->forward(); } catch (WebDriverException $exception) { @@ -118,10 +79,6 @@ public function forward() return $this; } - /** - * @throws WebDriverException - * @return $this - */ public function refresh() { try { @@ -130,14 +87,10 @@ public function refresh() return $this; } catch (WebDriverException $exception) { $this->dispatchOnException($exception); + throw $exception; } } - /** - * @param mixed $url - * @throws WebDriverException - * @return $this - */ public function to($url) { $this->dispatch( @@ -145,11 +98,14 @@ public function to($url) $url, $this->getDispatcher()->getDefaultDriver() ); + try { $this->navigator->to($url); } catch (WebDriverException $exception) { $this->dispatchOnException($exception); + throw $exception; } + $this->dispatch( 'afterNavigateTo', $url, @@ -159,9 +115,21 @@ public function to($url) return $this; } - private function dispatchOnException($exception) + /** + * @param mixed $method + * @param mixed ...$arguments + */ + protected function dispatch($method, ...$arguments) + { + if (!$this->dispatcher) { + return; + } + + $this->dispatcher->dispatch($method, $arguments); + } + + protected function dispatchOnException(WebDriverException $exception) { $this->dispatch('onException', $exception); - throw $exception; } } diff --git a/lib/Support/Events/EventFiringWebElement.php b/lib/Support/Events/EventFiringWebElement.php index 872c59c5e..6caa08684 100644 --- a/lib/Support/Events/EventFiringWebElement.php +++ b/lib/Support/Events/EventFiringWebElement.php @@ -1,17 +1,4 @@ element = $element; $this->dispatcher = $dispatcher; - - return $this; } /** @@ -55,19 +36,6 @@ public function getDispatcher() return $this->dispatcher; } - /** - * @param mixed $method - */ - protected function dispatch($method) - { - if (!$this->dispatcher) { - return; - } - $arguments = func_get_args(); - unset($arguments[0]); - $this->dispatcher->dispatch($method, $arguments); - } - /** * @return WebDriverElement */ @@ -76,15 +44,6 @@ public function getElement() return $this->element; } - /** - * @param WebDriverElement $element - * @return EventFiringWebElement - */ - protected function newElement(WebDriverElement $element) - { - return new static($element, $this->getDispatcher()); - } - /** * @param mixed $value * @throws WebDriverException @@ -93,10 +52,12 @@ protected function newElement(WebDriverElement $element) public function sendKeys($value) { $this->dispatch('beforeChangeValueOf', $this); + try { $this->element->sendKeys($value); } catch (WebDriverException $exception) { $this->dispatchOnException($exception); + throw $exception; } $this->dispatch('afterChangeValueOf', $this); @@ -110,10 +71,12 @@ public function sendKeys($value) public function click() { $this->dispatch('beforeClickOn', $this); + try { $this->element->click(); } catch (WebDriverException $exception) { $this->dispatchOnException($exception); + throw $exception; } $this->dispatch('afterClickOn', $this); @@ -121,7 +84,6 @@ public function click() } /** - * @param WebDriverBy $by * @throws WebDriverException * @return EventFiringWebElement */ @@ -138,6 +100,7 @@ public function findElement(WebDriverBy $by) $element = $this->newElement($this->element->findElement($by)); } catch (WebDriverException $exception) { $this->dispatchOnException($exception); + throw $exception; } $this->dispatch( @@ -151,7 +114,6 @@ public function findElement(WebDriverBy $by) } /** - * @param WebDriverBy $by * @throws WebDriverException * @return array */ @@ -163,6 +125,7 @@ public function findElements(WebDriverBy $by) $this, $this->dispatcher->getDefaultDriver() ); + try { $elements = []; foreach ($this->element->findElements($by) as $element) { @@ -170,6 +133,7 @@ public function findElements(WebDriverBy $by) } } catch (WebDriverException $exception) { $this->dispatchOnException($exception); + throw $exception; } $this->dispatch( 'afterFindBy', @@ -193,6 +157,7 @@ public function clear() return $this; } catch (WebDriverException $exception) { $this->dispatchOnException($exception); + throw $exception; } } @@ -207,6 +172,7 @@ public function getAttribute($attribute_name) return $this->element->getAttribute($attribute_name); } catch (WebDriverException $exception) { $this->dispatchOnException($exception); + throw $exception; } } @@ -221,6 +187,7 @@ public function getCSSValue($css_property_name) return $this->element->getCSSValue($css_property_name); } catch (WebDriverException $exception) { $this->dispatchOnException($exception); + throw $exception; } } @@ -234,6 +201,7 @@ public function getLocation() return $this->element->getLocation(); } catch (WebDriverException $exception) { $this->dispatchOnException($exception); + throw $exception; } } @@ -247,6 +215,7 @@ public function getLocationOnScreenOnceScrolledIntoView() return $this->element->getLocationOnScreenOnceScrolledIntoView(); } catch (WebDriverException $exception) { $this->dispatchOnException($exception); + throw $exception; } } @@ -259,6 +228,7 @@ public function getCoordinates() return $this->element->getCoordinates(); } catch (WebDriverException $exception) { $this->dispatchOnException($exception); + throw $exception; } } @@ -272,6 +242,7 @@ public function getSize() return $this->element->getSize(); } catch (WebDriverException $exception) { $this->dispatchOnException($exception); + throw $exception; } } @@ -285,6 +256,7 @@ public function getTagName() return $this->element->getTagName(); } catch (WebDriverException $exception) { $this->dispatchOnException($exception); + throw $exception; } } @@ -298,6 +270,7 @@ public function getText() return $this->element->getText(); } catch (WebDriverException $exception) { $this->dispatchOnException($exception); + throw $exception; } } @@ -311,6 +284,7 @@ public function isDisplayed() return $this->element->isDisplayed(); } catch (WebDriverException $exception) { $this->dispatchOnException($exception); + throw $exception; } } @@ -324,6 +298,7 @@ public function isEnabled() return $this->element->isEnabled(); } catch (WebDriverException $exception) { $this->dispatchOnException($exception); + throw $exception; } } @@ -337,6 +312,7 @@ public function isSelected() return $this->element->isSelected(); } catch (WebDriverException $exception) { $this->dispatchOnException($exception); + throw $exception; } } @@ -352,6 +328,7 @@ public function submit() return $this; } catch (WebDriverException $exception) { $this->dispatchOnException($exception); + throw $exception; } } @@ -365,13 +342,13 @@ public function getID() return $this->element->getID(); } catch (WebDriverException $exception) { $this->dispatchOnException($exception); + throw $exception; } } /** * Test if two element IDs refer to the same DOM element. * - * @param WebDriverElement $other * @return bool */ public function equals(WebDriverElement $other) @@ -380,16 +357,57 @@ public function equals(WebDriverElement $other) return $this->element->equals($other); } catch (WebDriverException $exception) { $this->dispatchOnException($exception); + throw $exception; } } - private function dispatchOnException($exception) + public function takeElementScreenshot($save_as = null) + { + try { + return $this->element->takeElementScreenshot($save_as); + } catch (WebDriverException $exception) { + $this->dispatchOnException($exception); + throw $exception; + } + } + + public function getShadowRoot() + { + try { + return $this->element->getShadowRoot(); + } catch (WebDriverException $exception) { + $this->dispatchOnException($exception); + throw $exception; + } + } + + protected function dispatchOnException(WebDriverException $exception) { $this->dispatch( 'onException', $exception, $this->dispatcher->getDefaultDriver() ); - throw $exception; + } + + /** + * @param mixed $method + * @param mixed ...$arguments + */ + protected function dispatch($method, ...$arguments) + { + if (!$this->dispatcher) { + return; + } + + $this->dispatcher->dispatch($method, $arguments); + } + + /** + * @return static + */ + protected function newElement(WebDriverElement $element) + { + return new static($element, $this->getDispatcher()); } } diff --git a/lib/Support/IsElementDisplayedAtom.php b/lib/Support/IsElementDisplayedAtom.php new file mode 100644 index 000000000..d95e4f01f --- /dev/null +++ b/lib/Support/IsElementDisplayedAtom.php @@ -0,0 +1,71 @@ +driver = $driver; + } + + public static function match($browserName) + { + return !in_array($browserName, self::BROWSERS_WITH_ENDPOINT_SUPPORT, true); + } + + public function execute($params) + { + $element = new RemoteWebElement( + new RemoteExecuteMethod($this->driver), + $params[':id'], + $this->driver->isW3cCompliant() + ); + + return $this->executeAtom('isElementDisplayed', $element); + } + + protected function executeAtom($atomName, ...$params) + { + return $this->driver->executeScript( + sprintf('%s; return (%s).apply(null, arguments);', $this->loadAtomScript($atomName), $atomName), + $params + ); + } + + private function loadAtomScript($atomName) + { + return file_get_contents(__DIR__ . '/../scripts/' . $atomName . '.js'); + } +} diff --git a/lib/Support/ScreenshotHelper.php b/lib/Support/ScreenshotHelper.php new file mode 100644 index 000000000..956f56147 --- /dev/null +++ b/lib/Support/ScreenshotHelper.php @@ -0,0 +1,81 @@ +executor = $executor; + } + + /** + * @param string|null $saveAs + * @throws WebDriverException + * @return string + */ + public function takePageScreenshot($saveAs = null) + { + $commandToExecute = [DriverCommand::SCREENSHOT]; + + return $this->takeScreenshot($commandToExecute, $saveAs); + } + + public function takeElementScreenshot($elementId, $saveAs = null) + { + $commandToExecute = [DriverCommand::TAKE_ELEMENT_SCREENSHOT, [':id' => $elementId]]; + + return $this->takeScreenshot($commandToExecute, $saveAs); + } + + private function takeScreenshot(array $commandToExecute, $saveAs = null) + { + $response = $this->executor->execute(...$commandToExecute); + + if (!is_string($response)) { + throw UnexpectedResponseException::forError( + 'Error taking screenshot, no data received from the remote end' + ); + } + + $screenshot = base64_decode($response, true); + + if ($screenshot === false) { + throw UnexpectedResponseException::forError('Error decoding screenshot data'); + } + + if ($saveAs !== null) { + $this->saveScreenshotToPath($screenshot, $saveAs); + } + + return $screenshot; + } + + private function saveScreenshotToPath($screenshot, $path) + { + $this->createDirectoryIfNotExists(dirname($path)); + + file_put_contents($path, $screenshot); + } + + private function createDirectoryIfNotExists($directoryPath) + { + if (!file_exists($directoryPath)) { + if (!mkdir($directoryPath, 0777, true) && !is_dir($directoryPath)) { + throw IOException::forFileError('Directory cannot be not created', $directoryPath); + } + } + } +} diff --git a/lib/Support/XPathEscaper.php b/lib/Support/XPathEscaper.php new file mode 100644 index 000000000..eb85081ca --- /dev/null +++ b/lib/Support/XPathEscaper.php @@ -0,0 +1,32 @@ + `concat('foo', "'" ,'"bar')` + * + * @param string $xpathToEscape The xpath to be converted. + * @return string The escaped string. + */ + public static function escapeQuotes($xpathToEscape) + { + // Single quotes not present => we can quote in them + if (mb_strpos($xpathToEscape, "'") === false) { + return sprintf("'%s'", $xpathToEscape); + } + + // Double quotes not present => we can quote in them + if (mb_strpos($xpathToEscape, '"') === false) { + return sprintf('"%s"', $xpathToEscape); + } + + // Both single and double quotes are present + return sprintf( + "concat('%s')", + str_replace("'", "', \"'\" ,'", $xpathToEscape) + ); + } +} diff --git a/lib/WebDriver.php b/lib/WebDriver.php index 85e8c1931..52120a7d7 100755 --- a/lib/WebDriver.php +++ b/lib/WebDriver.php @@ -1,20 +1,9 @@ type = $element->getAttribute('type'); + if ($this->type !== 'checkbox') { + throw new InvalidElementStateException('The input must be of type "checkbox".'); + } + } + + public function isMultiple() + { + return true; + } + + public function deselectAll() + { + foreach ($this->getRelatedElements() as $checkbox) { + $this->deselectOption($checkbox); + } + } + + public function deselectByIndex($index) + { + $this->byIndex($index, false); + } + + public function deselectByValue($value) + { + $this->byValue($value, false); + } + + public function deselectByVisibleText($text) + { + $this->byVisibleText($text, false, false); + } + + public function deselectByVisiblePartialText($text) + { + $this->byVisibleText($text, true, false); + } +} diff --git a/lib/WebDriverCommandExecutor.php b/lib/WebDriverCommandExecutor.php index 00357c6e7..7f6bb3ece 100644 --- a/lib/WebDriverCommandExecutor.php +++ b/lib/WebDriverCommandExecutor.php @@ -1,21 +1,9 @@ height; + return (int) $this->height; } /** @@ -56,7 +43,7 @@ public function getHeight() */ public function getWidth() { - return $this->width; + return (int) $this->width; } /** @@ -65,7 +52,7 @@ public function getWidth() * @param WebDriverDimension $dimension The dimension to be compared with. * @return bool Whether the height and the width are the same as the instance. */ - public function equals(WebDriverDimension $dimension) + public function equals(self $dimension) { return $this->height === $dimension->getHeight() && $this->width === $dimension->getWidth(); } diff --git a/lib/WebDriverDispatcher.php b/lib/WebDriverDispatcher.php index 782c84690..fe1ecb0f4 100644 --- a/lib/WebDriverDispatcher.php +++ b/lib/WebDriverDispatcher.php @@ -1,17 +1,4 @@ apply = $apply; + } + /** * @return callable A callable function to be executed by WebDriverWait */ @@ -42,16 +35,11 @@ public function getApply() return $this->apply; } - protected function __construct(callable $apply) - { - $this->apply = $apply; - } - /** * An expectation for checking the title of a page. * * @param string $title The expected title, which must be an exact match. - * @return bool WebDriverExpectedCondition True when the title matches, false otherwise. + * @return static Condition returns whether current page title equals given string. */ public static function titleIs($title) { @@ -66,40 +54,120 @@ function (WebDriver $driver) use ($title) { * An expectation for checking substring of a page Title. * * @param string $title The expected substring of Title. - * @return bool WebDriverExpectedCondition True when in title, false otherwise. + * @return static Condition returns whether current page title contains given string. */ public static function titleContains($title) { return new static( function (WebDriver $driver) use ($title) { - return strpos($driver->getTitle(), $title) !== false; + return mb_strpos($driver->getTitle(), $title) !== false; + } + ); + } + + /** + * An expectation for checking current page title matches the given regular expression. + * + * @param string $titleRegexp The regular expression to test against. + * @return static Condition returns whether current page title matches the regular expression. + */ + public static function titleMatches($titleRegexp) + { + return new static( + function (WebDriver $driver) use ($titleRegexp) { + return (bool) preg_match($titleRegexp, $driver->getTitle()); + } + ); + } + + /** + * An expectation for checking the URL of a page. + * + * @param string $url The expected URL, which must be an exact match. + * @return static Condition returns whether current URL equals given one. + */ + public static function urlIs($url) + { + return new static( + function (WebDriver $driver) use ($url) { + return $url === $driver->getCurrentURL(); + } + ); + } + + /** + * An expectation for checking substring of the URL of a page. + * + * @param string $url The expected substring of the URL + * @return static Condition returns whether current URL contains given string. + */ + public static function urlContains($url) + { + return new static( + function (WebDriver $driver) use ($url) { + return mb_strpos($driver->getCurrentURL(), $url) !== false; + } + ); + } + + /** + * An expectation for checking current page URL matches the given regular expression. + * + * @param string $urlRegexp The regular expression to test against. + * @return static Condition returns whether current URL matches the regular expression. + */ + public static function urlMatches($urlRegexp) + { + return new static( + function (WebDriver $driver) use ($urlRegexp) { + return (bool) preg_match($urlRegexp, $driver->getCurrentURL()); } ); } /** - * An expectation for checking that an element is present on the DOM of a - * page. This does not necessarily mean that the element is visible. + * An expectation for checking that an element is present on the DOM of a page. + * This does not necessarily mean that the element is visible. * * @param WebDriverBy $by The locator used to find the element. - * @return WebDriverExpectedCondition The element which is located. + * @return static Condition returns the WebDriverElement which is located. */ public static function presenceOfElementLocated(WebDriverBy $by) { return new static( function (WebDriver $driver) use ($by) { - return $driver->findElement($by); + try { + return $driver->findElement($by); + } catch (NoSuchElementException $e) { + return false; + } + } + ); + } + + /** + * An expectation for checking that there is at least one element present on a web page. + * + * @param WebDriverBy $by The locator used to find the element. + * @return static Condition return an array of WebDriverElement once they are located. + */ + public static function presenceOfAllElementsLocatedBy(WebDriverBy $by) + { + return new static( + function (WebDriver $driver) use ($by) { + $elements = $driver->findElements($by); + + return count($elements) > 0 ? $elements : null; } ); } /** - * An expectation for checking that an element is present on the DOM of a page - * and visible. Visibility means that the element is not only displayed but - * also has a height and width that is greater than 0. + * An expectation for checking that an element is present on the DOM of a page and visible. + * Visibility means that the element is not only displayed but also has a height and width that is greater than 0. * * @param WebDriverBy $by The locator used to find the element. - * @return WebDriverExpectedCondition The element which is located and visible. + * @return static Condition returns the WebDriverElement which is located and visible. */ public static function visibilityOfElementLocated(WebDriverBy $by) { @@ -117,12 +185,40 @@ function (WebDriver $driver) use ($by) { } /** - * An expectation for checking that an element, known to be present on the DOM - * of a page, is visible. Visibility means that the element is not only - * displayed but also has a height and width that is greater than 0. + * An expectation for checking than at least one element in an array of elements is present on the + * DOM of a page and visible. + * Visibility means that the element is not only displayed but also has a height and width that is greater than 0. + * + * @param WebDriverBy $by The located used to find the element. + * @return static Condition returns the array of WebDriverElement that are located and visible. + */ + public static function visibilityOfAnyElementLocated(WebDriverBy $by) + { + return new static( + function (WebDriver $driver) use ($by) { + $elements = $driver->findElements($by); + $visibleElements = []; + + foreach ($elements as $element) { + try { + if ($element->isDisplayed()) { + $visibleElements[] = $element; + } + } catch (StaleElementReferenceException $e) { + } + } + + return count($visibleElements) > 0 ? $visibleElements : null; + } + ); + } + + /** + * An expectation for checking that an element, known to be present on the DOM of a page, is visible. + * Visibility means that the element is not only displayed but also has a height and width that is greater than 0. * * @param WebDriverElement $element The element to be checked. - * @return WebDriverExpectedCondition The same WebDriverElement once it is visible. + * @return static Condition returns the same WebDriverElement once it is visible. */ public static function visibilityOf(WebDriverElement $element) { @@ -134,39 +230,77 @@ function () use ($element) { } /** - * An expectation for checking that there is at least one element present on a - * web page. + * An expectation for checking if the given text is present in the specified element. + * To check exact text match use elementTextIs() condition. * + * @codeCoverageIgnore + * @deprecated Use WebDriverExpectedCondition::elementTextContains() instead * @param WebDriverBy $by The locator used to find the element. - * @return WebDriverExpectedCondition An array of WebDriverElements once they are located. + * @param string $text The text to be presented in the element. + * @return static Condition returns whether the text is present in the element. */ - public static function presenceOfAllElementsLocatedBy(WebDriverBy $by) + public static function textToBePresentInElement(WebDriverBy $by, $text) + { + return self::elementTextContains($by, $text); + } + + /** + * An expectation for checking if the given text is present in the specified element. + * To check exact text match use elementTextIs() condition. + * + * @param WebDriverBy $by The locator used to find the element. + * @param string $text The text to be presented in the element. + * @return static Condition returns whether the partial text is present in the element. + */ + public static function elementTextContains(WebDriverBy $by, $text) { return new static( - function (WebDriver $driver) use ($by) { - $elements = $driver->findElements($by); + function (WebDriver $driver) use ($by, $text) { + try { + $element_text = $driver->findElement($by)->getText(); - return count($elements) > 0 ? $elements : null; + return mb_strpos($element_text, $text) !== false; + } catch (StaleElementReferenceException $e) { + return null; + } } ); } /** - * An expectation for checking if the given text is present in the specified - * element. + * An expectation for checking if the given text exactly equals the text in specified element. + * To check only partial substring of the text use elementTextContains() condition. * * @param WebDriverBy $by The locator used to find the element. - * @param string $text The text to be presented in the element. - * @return bool WebDriverExpectedCondition Whether the text is presented. + * @param string $text The expected text of the element. + * @return static Condition returns whether the element has text value equal to given one. */ - public static function textToBePresentInElement(WebDriverBy $by, $text) + public static function elementTextIs(WebDriverBy $by, $text) { return new static( function (WebDriver $driver) use ($by, $text) { try { - $element_text = $driver->findElement($by)->getText(); + return $driver->findElement($by)->getText() == $text; + } catch (StaleElementReferenceException $e) { + return null; + } + } + ); + } - return strpos($element_text, $text) !== false; + /** + * An expectation for checking if the given regular expression matches the text in specified element. + * + * @param WebDriverBy $by The locator used to find the element. + * @param string $regexp The regular expression to test against. + * @return static Condition returns whether the element has text value equal to given one. + */ + public static function elementTextMatches(WebDriverBy $by, $regexp) + { + return new static( + function (WebDriver $driver) use ($by, $regexp) { + try { + return (bool) preg_match($regexp, $driver->findElement($by)->getText()); } catch (StaleElementReferenceException $e) { return null; } @@ -175,21 +309,34 @@ function (WebDriver $driver) use ($by, $text) { } /** - * An expectation for checking if the given text is present in the specified - * elements value attribute. + * An expectation for checking if the given text is present in the specified elements value attribute. * + * @codeCoverageIgnore + * @deprecated Use WebDriverExpectedCondition::elementValueContains() instead * @param WebDriverBy $by The locator used to find the element. * @param string $text The text to be presented in the element value. - * @return bool WebDriverExpectedCondition Whether the text is presented. + * @return static Condition returns whether the text is present in value attribute. */ public static function textToBePresentInElementValue(WebDriverBy $by, $text) + { + return self::elementValueContains($by, $text); + } + + /** + * An expectation for checking if the given text is present in the specified elements value attribute. + * + * @param WebDriverBy $by The locator used to find the element. + * @param string $text The text to be presented in the element value. + * @return static Condition returns whether the text is present in value attribute. + */ + public static function elementValueContains(WebDriverBy $by, $text) { return new static( function (WebDriver $driver) use ($by, $text) { try { $element_text = $driver->findElement($by)->getAttribute('value'); - return strpos($element_text, $text) !== false; + return mb_strpos($element_text, $text) !== false; } catch (StaleElementReferenceException $e) { return null; } @@ -198,13 +345,11 @@ function (WebDriver $driver) use ($by, $text) { } /** - * Expectation for checking if iFrame exists. - * If iFrame exists switches driver's focus to the iFrame + * Expectation for checking if iFrame exists. If iFrame exists switches driver's focus to the iFrame. * * @param string $frame_locator The locator used to find the iFrame * expected to be either the id or name value of the i/frame - * @return WebDriverExpectedCondition object focused on new frame - * when frame is found bool false otherwise + * @return static Condition returns object focused on new frame when frame is found, false otherwise. */ public static function frameToBeAvailableAndSwitchToIt($frame_locator) { @@ -220,21 +365,18 @@ function (WebDriver $driver) use ($frame_locator) { } /** - * An expectation for checking that an element is either invisible or not - * present on the DOM. + * An expectation for checking that an element is either invisible or not present on the DOM. * * @param WebDriverBy $by The locator used to find the element. - * @return bool WebDriverExpectedCondition Whether there is no element located. + * @return static Condition returns whether no visible element located. */ public static function invisibilityOfElementLocated(WebDriverBy $by) { return new static( function (WebDriver $driver) use ($by) { try { - return !($driver->findElement($by)->isDisplayed()); - } catch (NoSuchElementException $e) { - return true; - } catch (StaleElementReferenceException $e) { + return !$driver->findElement($by)->isDisplayed(); + } catch (NoSuchElementException|StaleElementReferenceException $e) { return true; } } @@ -242,12 +384,11 @@ function (WebDriver $driver) use ($by) { } /** - * An expectation for checking that an element with text is either invisible - * or not present on the DOM. + * An expectation for checking that an element with text is either invisible or not present on the DOM. * - * @param WebdriverBy $by The locator used to find the element. + * @param WebDriverBy $by The locator used to find the element. * @param string $text The text of the element. - * @return bool WebDriverExpectedCondition Whether the text is found in the element located. + * @return static Condition returns whether the text is found in the element located. */ public static function invisibilityOfElementWithText(WebDriverBy $by, $text) { @@ -255,9 +396,7 @@ public static function invisibilityOfElementWithText(WebDriverBy $by, $text) function (WebDriver $driver) use ($by, $text) { try { return !($driver->findElement($by)->getText() === $text); - } catch (NoSuchElementException $e) { - return true; - } catch (StaleElementReferenceException $e) { + } catch (NoSuchElementException|StaleElementReferenceException $e) { return true; } } @@ -265,17 +404,14 @@ function (WebDriver $driver) use ($by, $text) { } /** - * An expectation for checking an element is visible and enabled such that you - * can click it. + * An expectation for checking an element is visible and enabled such that you can click it. * * @param WebDriverBy $by The locator used to find the element - * @return WebDriverExpectedCondition The WebDriverElement - * once it is located, visible and clickable + * @return static Condition return the WebDriverElement once it is located, visible and clickable. */ public static function elementToBeClickable(WebDriverBy $by) { - $visibility_of_element_located = - self::visibilityOfElementLocated($by); + $visibility_of_element_located = self::visibilityOfElementLocated($by); return new static( function (WebDriver $driver) use ($visibility_of_element_located) { @@ -283,6 +419,7 @@ function (WebDriver $driver) use ($visibility_of_element_located) { $visibility_of_element_located->getApply(), $driver ); + try { if ($element !== null && $element->isEnabled()) { return $element; @@ -300,7 +437,7 @@ function (WebDriver $driver) use ($visibility_of_element_located) { * Wait until an element is no longer attached to the DOM. * * @param WebDriverElement $element The element to wait for. - * @return bool WebDriverExpectedCondition false if the element is still attached to the DOM, true otherwise. + * @return static Condition returns whether the element is still attached to the DOM. */ public static function stalenessOf(WebDriverElement $element) { @@ -320,16 +457,15 @@ function () use ($element) { /** * Wrapper for a condition, which allows for elements to update by redrawing. * - * This works around the problem of conditions which have two parts: find an - * element and then check for some condition on it. For these conditions it is - * possible that an element is located and then subsequently it is redrawn on - * the client. When this happens a StaleElementReferenceException is thrown - * when the second part of the condition is checked. + * This works around the problem of conditions which have two parts: find an element and then check for some + * condition on it. For these conditions it is possible that an element is located and then subsequently it is + * redrawn on the client. When this happens a StaleElementReferenceException is thrown when the second part of + * the condition is checked. * * @param WebDriverExpectedCondition $condition The condition wrapped. - * @return WebDriverExpectedCondition The return value of the getApply() of the given condition. + * @return static Condition returns the return value of the getApply() of the given condition. */ - public static function refreshed(WebDriverExpectedCondition $condition) + public static function refreshed(self $condition) { return new static( function (WebDriver $driver) use ($condition) { @@ -346,7 +482,7 @@ function (WebDriver $driver) use ($condition) { * An expectation for checking if the given element is selected. * * @param mixed $element_or_by Either the element or the locator. - * @return bool WebDriverExpectedCondition whether the element is selected. + * @return static Condition returns whether the element is selected. */ public static function elementToBeSelected($element_or_by) { @@ -361,7 +497,7 @@ public static function elementToBeSelected($element_or_by) * * @param mixed $element_or_by Either the element or the locator. * @param bool $selected The required state. - * @return bool WebDriverExpectedCondition Whether the element is selected. + * @return static Condition returns whether the element is selected. */ public static function elementSelectionStateToBe($element_or_by, $selected) { @@ -371,27 +507,29 @@ function () use ($element_or_by, $selected) { return $element_or_by->isSelected() === $selected; } ); - } else { - if ($element_or_by instanceof WebDriverBy) { - return new static( - function (WebDriver $driver) use ($element_or_by, $selected) { - try { - $element = $driver->findElement($element_or_by); - - return $element->isSelected() === $selected; - } catch (StaleElementReferenceException $e) { - return null; - } + } + + if ($element_or_by instanceof WebDriverBy) { + return new static( + function (WebDriver $driver) use ($element_or_by, $selected) { + try { + $element = $driver->findElement($element_or_by); + + return $element->isSelected() === $selected; + } catch (StaleElementReferenceException $e) { + return null; } - ); - } + } + ); } + + throw LogicException::forError('Instance of either WebDriverElement or WebDriverBy must be given'); } /** * An expectation for whether an alert() box is present. * - * @return WebDriverExpectedCondition if alert() is present, null otherwise. + * @return static Condition returns WebDriverAlert if alert() is present, null otherwise. */ public static function alertIsPresent() { @@ -405,20 +543,35 @@ function (WebDriver $driver) { $alert->getText(); return $alert; - } catch (NoAlertOpenException $e) { + } catch (NoSuchAlertException $e) { return null; } } ); } + /** + * An expectation checking the number of opened windows. + * + * @param int $expectedNumberOfWindows + * @return static + */ + public static function numberOfWindowsToBe($expectedNumberOfWindows) + { + return new static( + function (WebDriver $driver) use ($expectedNumberOfWindows) { + return count($driver->getWindowHandles()) == $expectedNumberOfWindows; + } + ); + } + /** * An expectation with the logical opposite condition of the given condition. * * @param WebDriverExpectedCondition $condition The condition to be negated. * @return mixed The negation of the result of the given condition. */ - public static function not(WebDriverExpectedCondition $condition) + public static function not(self $condition) { return new static( function (WebDriver $driver) use ($condition) { diff --git a/lib/WebDriverHasInputDevices.php b/lib/WebDriverHasInputDevices.php index 93e42a30a..efe41ae42 100644 --- a/lib/WebDriverHasInputDevices.php +++ b/lib/WebDriverHasInputDevices.php @@ -1,17 +1,4 @@ executor = $executor; } - /** - * Move back a single entry in the browser's history, if possible. - * - * @return WebDriverNavigation The instance. - */ public function back() { $this->executor->execute(DriverCommand::GO_BACK); @@ -48,11 +21,6 @@ public function back() return $this; } - /** - * Move forward a single entry in the browser's history, if possible. - * - * @return WebDriverNavigation The instance. - */ public function forward() { $this->executor->execute(DriverCommand::GO_FORWARD); @@ -60,11 +28,6 @@ public function forward() return $this; } - /** - * Refresh the current page. - * - * @return WebDriverNavigation The instance. - */ public function refresh() { $this->executor->execute(DriverCommand::REFRESH); @@ -72,12 +35,6 @@ public function refresh() return $this; } - /** - * Navigate to the given URL. - * - * @param string $url - * @return WebDriverNavigation The instance. - */ public function to($url) { $params = ['url' => (string) $url]; diff --git a/lib/WebDriverNavigationInterface.php b/lib/WebDriverNavigationInterface.php new file mode 100644 index 000000000..6fcd06e6a --- /dev/null +++ b/lib/WebDriverNavigationInterface.php @@ -0,0 +1,43 @@ +executor = $executor; + $this->isW3cCompliant = $isW3cCompliant; } /** * Add a specific cookie. * - * Here are the valid attributes of a cookie array. - * 'name' : string The name of the cookie; may not be null or an empty string. - * 'value' : string The cookie value; may not be null. - * 'path' : string OPTIONAL The path the cookie is visible to. Defaults to "/" if omitted. - * 'domain' : string OPTIONAL The domain the cookie is visible to. Defaults to the current browsing context's - * document's URL domain if omitted. - * 'secure' : bool OPTIONAL Whether this cookie requires a secure connection (https). Defaults to false if - * omitted. - * 'httpOnly': bool OPTIONAL Whether the cookie is an HTTP only cookie. Defaults to false if omitted. - * 'expiry' : int OPTIONAL The cookie's expiration date, specified in seconds since Unix Epoch. - * - * @see https://w3c.github.io/webdriver/webdriver-spec.html#cookies - * @param array $cookie An array with key as the attributes mentioned above. + * @see Cookie for description of possible cookie properties + * @param Cookie|array $cookie Cookie object. May be also created from array for compatibility reasons. * @return WebDriverOptions The current instance. */ - public function addCookie(array $cookie) + public function addCookie($cookie) { - $this->validate($cookie); + if (is_array($cookie)) { // @todo @deprecated remove in 2.0 + $cookie = Cookie::createFromArray($cookie); + } + if (!$cookie instanceof Cookie) { + throw LogicException::forError('Cookie must be set from instance of Cookie class or from array.'); + } + $this->executor->execute( DriverCommand::ADD_COOKIE, - ['cookie' => $cookie] + ['cookie' => $cookie->toArray()] ); return $this; @@ -76,7 +64,7 @@ public function deleteAllCookies() } /** - * Delete the cookie with the give name. + * Delete the cookie with the given name. * * @param string $name * @return WebDriverOptions The current instance. @@ -95,10 +83,24 @@ public function deleteCookieNamed($name) * Get the cookie with a given name. * * @param string $name - * @return array The cookie, or null if no cookie with the given name is presented. + * @throws NoSuchCookieException In W3C compliant mode if no cookie with the given name is present + * @return Cookie|null The cookie, or null in JsonWire mode if no cookie with the given name is present */ public function getCookieNamed($name) { + if ($this->isW3cCompliant) { + $cookieArray = $this->executor->execute( + DriverCommand::GET_NAMED_COOKIE, + [':name' => $name] + ); + + if (!is_array($cookieArray)) { // Microsoft Edge returns null even in W3C mode => emulate proper behavior + throw new NoSuchCookieException('no such cookie'); + } + + return Cookie::createFromArray($cookieArray); + } + $cookies = $this->getCookies(); foreach ($cookies as $cookie) { if ($cookie['name'] === $name) { @@ -112,35 +114,21 @@ public function getCookieNamed($name) /** * Get all the cookies for the current domain. * - * @return array The array of cookies presented. + * @return Cookie[] The array of cookies presented. */ public function getCookies() { - return $this->executor->execute(DriverCommand::GET_ALL_COOKIES); - } - - private function validate(array $cookie) - { - if (!isset($cookie['name']) || - $cookie['name'] === '' || - strpos($cookie['name'], ';') !== false - ) { - throw new InvalidArgumentException( - '"name" should be non-empty and does not contain a ";"' - ); + $cookieArrays = $this->executor->execute(DriverCommand::GET_ALL_COOKIES); + if (!is_array($cookieArrays)) { // Microsoft Edge returns null if there are no cookies... + return []; } - if (!isset($cookie['value'])) { - throw new InvalidArgumentException( - '"value" is required when setting a cookie.' - ); + $cookies = []; + foreach ($cookieArrays as $cookieArray) { + $cookies[] = Cookie::createFromArray($cookieArray); } - if (isset($cookie['domain']) && strpos($cookie['domain'], ':') !== false) { - throw new InvalidArgumentException( - '"domain" should not contain a port:' . (string) $cookie['domain'] - ); - } + return $cookies; } /** @@ -150,7 +138,7 @@ private function validate(array $cookie) */ public function timeouts() { - return new WebDriverTimeouts($this->executor); + return new WebDriverTimeouts($this->executor, $this->isW3cCompliant); } /** @@ -161,7 +149,7 @@ public function timeouts() */ public function window() { - return new WebDriverWindow($this->executor); + return new WebDriverWindow($this->executor, $this->isW3cCompliant); } /** @@ -169,7 +157,7 @@ public function window() * * @param string $log_type The log type. * @return array The list of log entries. - * @see https://code.google.com/p/selenium/wiki/JsonWireProtocol#Log_Type + * @see https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol#log-type */ public function getLog($log_type) { @@ -183,7 +171,7 @@ public function getLog($log_type) * Get available log types. * * @return array The list of available log types. - * @see https://code.google.com/p/selenium/wiki/JsonWireProtocol#Log_Type + * @see https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol#log-type */ public function getAvailableLogTypes() { diff --git a/lib/WebDriverPlatform.php b/lib/WebDriverPlatform.php index bb1923556..a589f3013 100644 --- a/lib/WebDriverPlatform.php +++ b/lib/WebDriverPlatform.php @@ -1,33 +1,23 @@ x; + return (int) $this->x; } /** @@ -46,7 +33,7 @@ public function getX() */ public function getY() { - return $this->y; + return (int) $this->y; } /** @@ -85,7 +72,7 @@ public function moveBy($x_offset, $y_offset) * @param WebDriverPoint $point The point to be compared with. * @return bool Whether the x and y coordinates are the same as the instance. */ - public function equals(WebDriverPoint $point) + public function equals(self $point) { return $this->x === $point->getX() && $this->y === $point->getY(); diff --git a/lib/WebDriverRadios.php b/lib/WebDriverRadios.php new file mode 100644 index 000000000..aeaaaecac --- /dev/null +++ b/lib/WebDriverRadios.php @@ -0,0 +1,52 @@ +type = $element->getAttribute('type'); + if ($this->type !== 'radio') { + throw new InvalidElementStateException('The input must be of type "radio".'); + } + } + + public function isMultiple() + { + return false; + } + + public function deselectAll() + { + throw new UnsupportedOperationException('You cannot deselect radio buttons'); + } + + public function deselectByIndex($index) + { + throw new UnsupportedOperationException('You cannot deselect radio buttons'); + } + + public function deselectByValue($value) + { + throw new UnsupportedOperationException('You cannot deselect radio buttons'); + } + + public function deselectByVisibleText($text) + { + throw new UnsupportedOperationException('You cannot deselect radio buttons'); + } + + public function deselectByVisiblePartialText($text) + { + throw new UnsupportedOperationException('You cannot deselect radio buttons'); + } +} diff --git a/lib/WebDriverSearchContext.php b/lib/WebDriverSearchContext.php index 4d784c682..5fb1daaf9 100644 --- a/lib/WebDriverSearchContext.php +++ b/lib/WebDriverSearchContext.php @@ -1,33 +1,19 @@ ` tag, providing helper methods to select and deselect options. */ -class WebDriverSelect +class WebDriverSelect implements WebDriverSelectInterface { + /** @var WebDriverElement */ private $element; + /** @var bool */ private $isMulti; public function __construct(WebDriverElement $element) @@ -36,48 +26,42 @@ public function __construct(WebDriverElement $element) } $this->element = $element; $value = $element->getAttribute('multiple'); - $this->isMulti = ($value === 'true'); + + /** + * There is a bug in safari webdriver that returns 'multiple' instead of 'true' which does not match the spec. + * Apple Feedback #FB12760673 + * + * @see https://www.w3.org/TR/webdriver2/#get-element-attribute + */ + $this->isMulti = $value === 'true' || $value === 'multiple'; } - /** - * @return bool Whether this select element support selecting multiple - * options. This is done by checking the value of the 'multiple' - * attribute. - */ public function isMultiple() { return $this->isMulti; } - /** - * @return WebDriverElement[] All options belonging to this select tag. - */ public function getOptions() { return $this->element->findElements(WebDriverBy::tagName('option')); } - /** - * @return WebDriverElement[] All selected options belonging to this select tag. - */ public function getAllSelectedOptions() { $selected_options = []; foreach ($this->getOptions() as $option) { if ($option->isSelected()) { $selected_options[] = $option; + + if (!$this->isMultiple()) { + return $selected_options; + } } } return $selected_options; } - /** - * @throws NoSuchElementException - * - * @return WebDriverElement The first selected option in this select tag (or - * the currently selected option in a normal select) - */ public function getFirstSelectedOption() { foreach ($this->getOptions() as $option) { @@ -89,74 +73,27 @@ public function getFirstSelectedOption() throw new NoSuchElementException('No options are selected'); } - /** - * Deselect all options in multiple select tag. - * - * @throws UnsupportedOperationException - */ - public function deselectAll() - { - if (!$this->isMultiple()) { - throw new UnsupportedOperationException( - 'You may only deselect all options of a multi-select' - ); - } - - foreach ($this->getOptions() as $option) { - if ($option->isSelected()) { - $option->click(); - } - } - } - - /** - * Select the option at the given index. - * - * @param int $index The index of the option. (0-based) - * - * @throws NoSuchElementException - */ public function selectByIndex($index) { - $matched = false; foreach ($this->getOptions() as $option) { if ($option->getAttribute('index') === (string) $index) { - if (!$option->isSelected()) { - $option->click(); - if (!$this->isMultiple()) { - return; - } - } - $matched = true; + $this->selectOption($option); + + return; } } - if (!$matched) { - throw new NoSuchElementException( - sprintf('Cannot locate option with index: %d', $index) - ); - } + + throw new NoSuchElementException(sprintf('Cannot locate option with index: %d', $index)); } - /** - * Select all options that have value attribute matching the argument. That - * is, when given "foo" this would select an option like: - * - * ; - * - * @param string $value The value to match against. - * - * @throws NoSuchElementException - */ public function selectByValue($value) { $matched = false; - $xpath = './/option[@value = ' . $this->escapeQuotes($value) . ']'; + $xpath = './/option[@value = ' . XPathEscaper::escapeQuotes($value) . ']'; $options = $this->element->findElements(WebDriverBy::xpath($xpath)); foreach ($options as $option) { - if (!$option->isSelected()) { - $option->click(); - } + $this->selectOption($option); if (!$this->isMultiple()) { return; } @@ -170,26 +107,14 @@ public function selectByValue($value) } } - /** - * Select all options that display text matching the argument. That is, when - * given "Bar" this would select an option like: - * - * ; - * - * @param string $text The visible text to match against. - * - * @throws NoSuchElementException - */ public function selectByVisibleText($text) { $matched = false; - $xpath = './/option[normalize-space(.) = ' . $this->escapeQuotes($text) . ']'; + $xpath = './/option[normalize-space(.) = ' . XPathEscaper::escapeQuotes($text) . ']'; $options = $this->element->findElements(WebDriverBy::xpath($xpath)); foreach ($options as $option) { - if (!$option->isSelected()) { - $option->click(); - } + $this->selectOption($option); if (!$this->isMultiple()) { return; } @@ -201,9 +126,7 @@ public function selectByVisibleText($text) if (!$matched) { foreach ($this->getOptions() as $option) { if ($option->getText() === $text) { - if (!$option->isSelected()) { - $option->click(); - } + $this->selectOption($option); if (!$this->isMultiple()) { return; } @@ -219,87 +142,109 @@ public function selectByVisibleText($text) } } - /** - * Deselect the option at the given index. - * - * @param int $index The index of the option. (0-based) - */ + public function selectByVisiblePartialText($text) + { + $matched = false; + $xpath = './/option[contains(normalize-space(.), ' . XPathEscaper::escapeQuotes($text) . ')]'; + $options = $this->element->findElements(WebDriverBy::xpath($xpath)); + + foreach ($options as $option) { + $this->selectOption($option); + if (!$this->isMultiple()) { + return; + } + $matched = true; + } + + if (!$matched) { + throw new NoSuchElementException( + sprintf('Cannot locate option with text: %s', $text) + ); + } + } + + public function deselectAll() + { + if (!$this->isMultiple()) { + throw new UnsupportedOperationException('You may only deselect all options of a multi-select'); + } + + foreach ($this->getOptions() as $option) { + $this->deselectOption($option); + } + } + public function deselectByIndex($index) { + if (!$this->isMultiple()) { + throw new UnsupportedOperationException('You may only deselect options of a multi-select'); + } + foreach ($this->getOptions() as $option) { - if ($option->getAttribute('index') === (string) $index && $option->isSelected()) { - $option->click(); + if ($option->getAttribute('index') === (string) $index) { + $this->deselectOption($option); + + return; } } } - /** - * Deselect all options that have value attribute matching the argument. That - * is, when given "foo" this would select an option like: - * - * ; - * - * @param string $value The value to match against. - */ public function deselectByValue($value) { - $xpath = './/option[@value = ' . $this->escapeQuotes($value) . ']'; + if (!$this->isMultiple()) { + throw new UnsupportedOperationException('You may only deselect options of a multi-select'); + } + + $xpath = './/option[@value = ' . XPathEscaper::escapeQuotes($value) . ']'; $options = $this->element->findElements(WebDriverBy::xpath($xpath)); foreach ($options as $option) { - if ($option->isSelected()) { - $option->click(); - } + $this->deselectOption($option); } } - /** - * Deselect all options that display text matching the argument. That is, when - * given "Bar" this would select an option like: - * - * ; - * - * @param string $text The visible text to match against. - */ public function deselectByVisibleText($text) { - $xpath = './/option[normalize-space(.) = ' . $this->escapeQuotes($text) . ']'; + if (!$this->isMultiple()) { + throw new UnsupportedOperationException('You may only deselect options of a multi-select'); + } + + $xpath = './/option[normalize-space(.) = ' . XPathEscaper::escapeQuotes($text) . ']'; $options = $this->element->findElements(WebDriverBy::xpath($xpath)); foreach ($options as $option) { - if ($option->isSelected()) { - $option->click(); - } + $this->deselectOption($option); } } - /** - * Convert strings with both quotes and ticks into: - * foo'"bar -> concat("foo'", '"', "bar") - * - * @param string $to_escape The string to be converted. - * @return string The escaped string. - */ - protected function escapeQuotes($to_escape) + public function deselectByVisiblePartialText($text) { - if (strpos($to_escape, '"') !== false && strpos($to_escape, "'") !== false) { - $substrings = explode('"', $to_escape); - - $escaped = 'concat('; - $first = true; - foreach ($substrings as $string) { - if (!$first) { - $escaped .= ", '\"',"; - $first = false; - } - $escaped .= '"' . $string . '"'; - } + if (!$this->isMultiple()) { + throw new UnsupportedOperationException('You may only deselect options of a multi-select'); + } - return $escaped; + $xpath = './/option[contains(normalize-space(.), ' . XPathEscaper::escapeQuotes($text) . ')]'; + $options = $this->element->findElements(WebDriverBy::xpath($xpath)); + foreach ($options as $option) { + $this->deselectOption($option); } + } - if (strpos($to_escape, '"') !== false) { - return sprintf("'%s'", $to_escape); + /** + * Mark option selected + */ + protected function selectOption(WebDriverElement $option) + { + if (!$option->isSelected()) { + $option->click(); } + } - return sprintf('"%s"', $to_escape); + /** + * Mark option not selected + */ + protected function deselectOption(WebDriverElement $option) + { + if ($option->isSelected()) { + $option->click(); + } } } diff --git a/lib/WebDriverSelectInterface.php b/lib/WebDriverSelectInterface.php new file mode 100644 index 000000000..030a783e9 --- /dev/null +++ b/lib/WebDriverSelectInterface.php @@ -0,0 +1,128 @@ +Bar` + * + * @param string $value The value to match against. + * + * @throws NoSuchElementException + */ + public function selectByValue($value); + + /** + * Select all options that display text matching the argument. That is, when given "Bar" this would + * select an option like: + * + * `` + * + * @param string $text The visible text to match against. + * + * @throws NoSuchElementException + */ + public function selectByVisibleText($text); + + /** + * Select all options that display text partially matching the argument. That is, when given "Bar" this would + * select an option like: + * + * `` + * + * @param string $text The visible text to match against. + * + * @throws NoSuchElementException + */ + public function selectByVisiblePartialText($text); + + /** + * Deselect all options in multiple select tag. + * + * @throws UnsupportedOperationException If the SELECT does not support multiple selections + */ + public function deselectAll(); + + /** + * Deselect the option at the given index. + * + * @param int $index The index of the option. (0-based) + * @throws UnsupportedOperationException If the SELECT does not support multiple selections + */ + public function deselectByIndex($index); + + /** + * Deselect all options that have value attribute matching the argument. That is, when given "foo" this would + * deselect an option like: + * + * `` + * + * @param string $value The value to match against. + * @throws UnsupportedOperationException If the SELECT does not support multiple selections + */ + public function deselectByValue($value); + + /** + * Deselect all options that display text matching the argument. That is, when given "Bar" this would + * deselect an option like: + * + * `` + * + * @param string $text The visible text to match against. + * @throws UnsupportedOperationException If the SELECT does not support multiple selections + */ + public function deselectByVisibleText($text); + + /** + * Deselect all options that display text matching the argument. That is, when given "Bar" this would + * deselect an option like: + * + * `` + * + * @param string $text The visible text to match against. + * @throws UnsupportedOperationException If the SELECT does not support multiple selections + */ + public function deselectByVisiblePartialText($text); +} diff --git a/lib/WebDriverTargetLocator.php b/lib/WebDriverTargetLocator.php index d9fdf5c18..8787f66c5 100644 --- a/lib/WebDriverTargetLocator.php +++ b/lib/WebDriverTargetLocator.php @@ -1,17 +1,4 @@ executor = $executor; + $this->isW3cCompliant = $isW3cCompliant; } /** - * Specify the amount of time the driver should wait when searching for an - * element if it is not immediately present. + * Specify the amount of time the driver should wait when searching for an element if it is not immediately present. * - * @param int $seconds Wait time in second. + * @param null|int|float $seconds Wait time in seconds. * @return WebDriverTimeouts The current instance. */ public function implicitlyWait($seconds) { + if ($this->isW3cCompliant) { + $this->executor->execute( + DriverCommand::IMPLICITLY_WAIT, + ['implicit' => $seconds === null ? null : floor($seconds * 1000)] + ); + + return $this; + } + + if ($seconds === null) { + throw new \InvalidArgumentException('JsonWire Protocol implicit-wait-timeout value cannot be null'); + } $this->executor->execute( DriverCommand::IMPLICITLY_WAIT, - ['ms' => $seconds * 1000] + ['ms' => floor($seconds * 1000)] ); return $this; } /** - * Set the amount of time to wait for an asynchronous script to finish - * execution before throwing an error. + * Set the amount of time to wait for an asynchronous script to finish execution before throwing an error. * - * @param int $seconds Wait time in second. + * @param null|int|float $seconds Wait time in seconds. * @return WebDriverTimeouts The current instance. */ public function setScriptTimeout($seconds) { + if ($this->isW3cCompliant) { + $this->executor->execute( + DriverCommand::SET_SCRIPT_TIMEOUT, + ['script' => $seconds === null ? null : floor($seconds * 1000)] + ); + + return $this; + } + + if ($seconds === null) { + throw new \InvalidArgumentException('JsonWire Protocol script-timeout value cannot be null'); + } $this->executor->execute( DriverCommand::SET_SCRIPT_TIMEOUT, - ['ms' => $seconds * 1000] + ['ms' => floor($seconds * 1000)] ); return $this; } /** - * Set the amount of time to wait for a page load to complete before throwing - * an error. + * Set the amount of time to wait for a page load to complete before throwing an error. * - * @param int $seconds Wait time in second. + * @param null|int|float $seconds Wait time in seconds. * @return WebDriverTimeouts The current instance. */ public function pageLoadTimeout($seconds) { + if ($this->isW3cCompliant) { + $this->executor->execute( + DriverCommand::SET_SCRIPT_TIMEOUT, + ['pageLoad' => $seconds === null ? null : floor($seconds * 1000)] + ); + + return $this; + } + + if ($seconds === null) { + throw new \InvalidArgumentException('JsonWire Protocol page-load-timeout value cannot be null'); + } $this->executor->execute(DriverCommand::SET_TIMEOUT, [ 'type' => 'page load', - 'ms' => $seconds * 1000, + 'ms' => floor($seconds * 1000), ]); return $this; diff --git a/lib/WebDriverUpAction.php b/lib/WebDriverUpAction.php index e8a480c9e..3b045df4b 100644 --- a/lib/WebDriverUpAction.php +++ b/lib/WebDriverUpAction.php @@ -1,17 +1,4 @@ driver = $driver; - $this->timeout = isset($timeout_in_second) ? $timeout_in_second : 30; + $this->timeout = $timeout_in_second ?? 30; $this->interval = $interval_in_millisecond ?: 250; } @@ -51,9 +38,9 @@ public function __construct(WebDriver $driver, $timeout_in_second = null, $inter * @param callable|WebDriverExpectedCondition $func_or_ec * @param string $message * - * @throws NoSuchElementException - * @throws TimeOutException * @throws \Exception + * @throws NoSuchElementException + * @throws TimeoutException * @return mixed The return value of $func_or_ec */ public function until($func_or_ec, $message = '') @@ -81,6 +68,6 @@ public function until($func_or_ec, $message = '') throw $last_exception; } - throw new TimeOutException($message); + throw new TimeoutException($message); } } diff --git a/lib/WebDriverWindow.php b/lib/WebDriverWindow.php index d48a70773..2a69fe240 100644 --- a/lib/WebDriverWindow.php +++ b/lib/WebDriverWindow.php @@ -1,21 +1,10 @@ executor = $executor; + $this->isW3cCompliant = $isW3cCompliant; } /** @@ -72,6 +66,22 @@ public function getSize() ); } + /** + * Minimizes the current window if it is not already minimized. + * + * @return WebDriverWindow The instance. + */ + public function minimize() + { + if (!$this->isW3cCompliant) { + throw new UnsupportedOperationException('Minimize window is only supported in W3C mode'); + } + + $this->executor->execute(DriverCommand::MINIMIZE_WINDOW, []); + + return $this; + } + /** * Maximizes the current window if it is not already maximized * @@ -79,10 +89,30 @@ public function getSize() */ public function maximize() { - $this->executor->execute( - DriverCommand::MAXIMIZE_WINDOW, - [':windowHandle' => 'current'] - ); + if ($this->isW3cCompliant) { + $this->executor->execute(DriverCommand::MAXIMIZE_WINDOW, []); + } else { + $this->executor->execute( + DriverCommand::MAXIMIZE_WINDOW, + [':windowHandle' => 'current'] + ); + } + + return $this; + } + + /** + * Makes the current window full screen. + * + * @return WebDriverWindow The instance. + */ + public function fullscreen() + { + if (!$this->isW3cCompliant) { + throw new UnsupportedOperationException('The Fullscreen window command is only supported in W3C mode'); + } + + $this->executor->execute(DriverCommand::FULLSCREEN_WINDOW, []); return $this; } @@ -91,7 +121,6 @@ public function maximize() * Set the size of the current window. This will change the outer window * dimension, not just the view port. * - * @param WebDriverDimension $size * @return WebDriverWindow The instance. */ public function setSize(WebDriverDimension $size) @@ -110,7 +139,6 @@ public function setSize(WebDriverDimension $size) * Set the position of the current window. This is relative to the upper left * corner of the screen. * - * @param WebDriverPoint $position * @return WebDriverWindow The instance. */ public function setPosition(WebDriverPoint $position) @@ -145,11 +173,9 @@ public function getScreenOrientation() */ public function setScreenOrientation($orientation) { - $orientation = strtoupper($orientation); - if (!in_array($orientation, ['PORTRAIT', 'LANDSCAPE'])) { - throw new IndexOutOfBoundsException( - 'Orientation must be either PORTRAIT, or LANDSCAPE' - ); + $orientation = mb_strtoupper($orientation); + if (!in_array($orientation, ['PORTRAIT', 'LANDSCAPE'], true)) { + throw LogicException::forError('Orientation must be either PORTRAIT, or LANDSCAPE'); } $this->executor->execute( diff --git a/lib/scripts/isElementDisplayed.js b/lib/scripts/isElementDisplayed.js new file mode 100644 index 000000000..f24bfa55e --- /dev/null +++ b/lib/scripts/isElementDisplayed.js @@ -0,0 +1,219 @@ +/* + * Imported from WebdriverIO project. + * https://github.com/webdriverio/webdriverio/blob/main/packages/webdriverio/src/scripts/isElementDisplayed.ts + * + * Copyright (C) 2017 Apple Inc. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS'' + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, + * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS + * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF + * THE POSSIBILITY OF SUCH DAMAGE. + */ + +/** + * check if element is visible + * @param {HTMLElement} elem element to check + * @return {Boolean} true if element is within viewport + */ +function isElementDisplayed(element) { + function nodeIsElement(node) { + if (!node) { + return false; + } + + switch (node.nodeType) { + case Node.ELEMENT_NODE: + case Node.DOCUMENT_NODE: + case Node.DOCUMENT_FRAGMENT_NODE: + return true; + default: + return false; + } + } + function parentElementForElement(element) { + if (!element) { + return null; + } + return enclosingNodeOrSelfMatchingPredicate(element.parentNode, nodeIsElement); + } + function enclosingNodeOrSelfMatchingPredicate(targetNode, predicate) { + for (let node = targetNode; node && node !== targetNode.ownerDocument; node = node.parentNode) + if (predicate(node)) { + return node; + } + return null; + } + function enclosingElementOrSelfMatchingPredicate(targetElement, predicate) { + for (let element = targetElement; element && element !== targetElement.ownerDocument; element = parentElementForElement(element)) + if (predicate(element)) { + return element; + } + return null; + } + function cascadedStylePropertyForElement(element, property) { + if (!element || !property) { + return null; + } + // if document-fragment, skip it and use element.host instead. This happens + // when the element is inside a shadow root. + // window.getComputedStyle errors on document-fragment. + if (element instanceof ShadowRoot) { + element = element.host; + } + let computedStyle = window.getComputedStyle(element); + let computedStyleProperty = computedStyle.getPropertyValue(property); + if (computedStyleProperty && computedStyleProperty !== 'inherit') { + return computedStyleProperty; + } + // Ideally getPropertyValue would return the 'used' or 'actual' value, but + // it doesn't for legacy reasons. So we need to do our own poor man's cascade. + // Fall back to the first non-'inherit' value found in an ancestor. + // In any case, getPropertyValue will not return 'initial'. + // FIXME: will this incorrectly inherit non-inheritable CSS properties? + // I think all important non-inheritable properties (width, height, etc.) + // for our purposes here are specially resolved, so this may not be an issue. + // Specification is here: https://drafts.csswg.org/cssom/#resolved-values + let parentElement = parentElementForElement(element); + return cascadedStylePropertyForElement(parentElement, property); + } + function elementSubtreeHasNonZeroDimensions(element) { + let boundingBox = element.getBoundingClientRect(); + if (boundingBox.width > 0 && boundingBox.height > 0) { + return true; + } + // Paths can have a zero width or height. Treat them as shown if the stroke width is positive. + if (element.tagName.toUpperCase() === 'PATH' && boundingBox.width + boundingBox.height > 0) { + let strokeWidth = cascadedStylePropertyForElement(element, 'stroke-width'); + return !!strokeWidth && (parseInt(strokeWidth, 10) > 0); + } + let cascadedOverflow = cascadedStylePropertyForElement(element, 'overflow'); + if (cascadedOverflow === 'hidden') { + return false; + } + // If the container's overflow is not hidden and it has zero size, consider the + // container to have non-zero dimensions if a child node has non-zero dimensions. + return Array.from(element.childNodes).some((childNode) => { + if (childNode.nodeType === Node.TEXT_NODE) { + return true; + } + if (nodeIsElement(childNode)) { + return elementSubtreeHasNonZeroDimensions(childNode); + } + return false; + }); + } + function elementOverflowsContainer(element) { + let cascadedOverflow = cascadedStylePropertyForElement(element, 'overflow'); + if (cascadedOverflow !== 'hidden') { + return false; + } + // FIXME: this needs to take into account the scroll position of the element, + // the display modes of it and its ancestors, and the container it overflows. + // See Selenium's bot.dom.getOverflowState atom for an exhaustive list of edge cases. + return true; + } + function isElementSubtreeHiddenByOverflow(element) { + if (!element) { + return false; + } + if (!elementOverflowsContainer(element)) { + return false; + } + if (!element.childNodes.length) { + return false; + } + // This element's subtree is hidden by overflow if all child subtrees are as well. + return Array.from(element.childNodes).every((childNode) => { + // Returns true if the child node is overflowed or otherwise hidden. + // Base case: not an element, has zero size, scrolled out, or doesn't overflow container. + // Visibility of text nodes is controlled by parent + if (childNode.nodeType === Node.TEXT_NODE) { + return false; + } + if (!nodeIsElement(childNode)) { + return true; + } + if (!elementSubtreeHasNonZeroDimensions(childNode)) { + return true; + } + // Recurse. + return isElementSubtreeHiddenByOverflow(childNode); + }); + } + // walk up the tree testing for a shadow root + function isElementInsideShadowRoot(element) { + if (!element) { + return false; + } + if (element.parentNode && element.parentNode.host) { + return true; + } + return isElementInsideShadowRoot(element.parentNode); + } + // This is a partial reimplementation of Selenium's "element is displayed" algorithm. + // When the W3C specification's algorithm stabilizes, we should implement that. + // If this command is misdirected to the wrong document (and is NOT inside a shadow root), treat it as not shown. + if (!isElementInsideShadowRoot(element) && !document.contains(element)) { + return false; + } + // Special cases for specific tag names. + switch (element.tagName.toUpperCase()) { + case 'BODY': + return true; + case 'SCRIPT': + case 'NOSCRIPT': + return false; + case 'OPTGROUP': + case 'OPTION': { + // Option/optgroup are considered shown if the containing is considered not shown. + if (element.type === 'hidden') { + return false; + } + break; + // case 'MAP': + // FIXME: Selenium has special handling for elements. We don't do anything now. + default: + break; + } + if (cascadedStylePropertyForElement(element, 'visibility') !== 'visible') { + return false; + } + let hasAncestorWithZeroOpacity = !!enclosingElementOrSelfMatchingPredicate(element, (e) => { + return Number(cascadedStylePropertyForElement(e, 'opacity')) === 0; + }); + let hasAncestorWithDisplayNone = !!enclosingElementOrSelfMatchingPredicate(element, (e) => { + return cascadedStylePropertyForElement(e, 'display') === 'none'; + }); + if (hasAncestorWithZeroOpacity || hasAncestorWithDisplayNone) { + return false; + } + if (!elementSubtreeHasNonZeroDimensions(element)) { + return false; + } + if (isElementSubtreeHiddenByOverflow(element)) { + return false; + } + return true; +} + diff --git a/logs/.gitkeep b/logs/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/mlc_config.json b/mlc_config.json new file mode 100644 index 000000000..5d70a03e0 --- /dev/null +++ b/mlc_config.json @@ -0,0 +1,7 @@ +{ + "ignorePatterns": [ + { + "pattern": "^https://stackoverflow\\.com/questions/tagged/php\\+selenium-webdriver" + } + ] +} diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 000000000..b4c1e307d --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,29 @@ +parameters: + level: 2 + paths: + - lib/ + - tests/ + + ignoreErrors: + # To be fixed in next major version: + - message: '#Call to an undefined method Facebook\\WebDriver\\WebDriver::getTouch\(\)#' + path: 'lib/Interactions/WebDriverTouchActions.php' + - message: '#Call to an undefined method Facebook\\WebDriver\\WebDriver::getTouch\(\)#' + path: 'lib/Support/Events/EventFiringWebDriver.php' + - message: '#Call to an undefined method Facebook\\WebDriver\\WebDriverElement::getCoordinates\(\)#' + path: 'lib/Support/Events/EventFiringWebElement.php' + - message: '#Call to an undefined method Facebook\\WebDriver\\WebDriverElement::equals\(\)#' + path: 'lib/Support/Events/EventFiringWebElement.php' + - message: '#Call to an undefined method Facebook\\WebDriver\\WebDriverElement::takeElementScreenshot\(\)#' + path: 'lib/Support/Events/EventFiringWebElement.php' + - message: '#Call to an undefined method Facebook\\WebDriver\\WebDriverElement::getShadowRoot\(\)#' + path: 'lib/Support/Events/EventFiringWebElement.php' + - '#Unsafe usage of new static\(\)#' + + # Parameter is intentionally not part of signature to not break BC + - message: '#PHPDoc tag \@param references unknown parameter: \$isW3cCompliant#' + path: 'lib/Remote/RemoteWebDriver.php' + - message: '#PHPDoc tag \@param references unknown parameter: \$existingCapabilities#' + path: 'lib/Remote/RemoteWebDriver.php' + + inferPrivatePropertyTypeFromConstructor: true diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 06dee9c0f..c58c72eaa 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,20 +1,9 @@ - - - - + tests/unit @@ -23,4 +12,14 @@ tests/functional + + + + ./lib + + + + + + diff --git a/scripts/docs-template.html b/scripts/docs-template.html new file mode 100644 index 000000000..4814f5ec5 --- /dev/null +++ b/scripts/docs-template.html @@ -0,0 +1,7 @@ + + + + + Taking you to the latest documentation. + + \ No newline at end of file diff --git a/scripts/doctum.php b/scripts/doctum.php new file mode 100644 index 000000000..08e722917 --- /dev/null +++ b/scripts/doctum.php @@ -0,0 +1,30 @@ +files() + ->name('*.php') + ->in($srcRoot . 'lib'); + +$versions = GitVersionCollection::create($srcRoot) + ->addFromTags('1.*') // only latest minor version + ->addFromTags('0.6.0') + ->add('main', 'main branch') +; + +return new Doctum($iterator, [ + 'title' => 'php-webdriver API', + 'theme' => 'default', + 'build_dir' => $root . '/build/dist/%version%/', + 'cache_dir' => $root . '/build/cache/%version%/', + 'include_parent_data' => true, + 'remote_repository' => new GitHubRemoteRepository('php-webdriver/php-webdriver', $srcRoot), + 'versions' => $versions, + 'base_url' => 'https://php-webdriver.github.io/php-webdriver/%version%/' +]); diff --git a/scripts/update-built-docs.sh b/scripts/update-built-docs.sh new file mode 100755 index 000000000..76646bed5 --- /dev/null +++ b/scripts/update-built-docs.sh @@ -0,0 +1,50 @@ +#!/bin/sh +set -e + +cleanup() { + git ls-files ./ | xargs -r -n 1 rm + rm -rfd ./* +} + +copyToTemp() { + TEMP_DIR="$(mktemp -d --suffix=_doctum-build-php-webdriver)" + cp -rp build/dist/* "${TEMP_DIR}" + cp ./scripts/docs-template.html "${TEMP_DIR}/index.html" +} + +emptyAndRemoveTemp() { + mv "${TEMP_DIR}"/* ./ + # Create symlink for main to latest + ln -s -r ./main ./latest + # Create symlink for main to master + ln -s -r ./main ./master + # Create symlink for main to community + ln -s -r ./main ./community + rm -rf "${TEMP_DIR}" +} + +commitAndPushChanges() { + # Push the changes, only if there is changes + git add -A + git diff-index --quiet HEAD || git commit -m "Api documentations update ($(date --rfc-3339=seconds --utc))" -m "#apidocs" && if [ -z "${SKIP_PUSH}" ]; then git push; fi +} + +if [ ! -d ./build/dist ]; then + echo 'Missing built docs' + exit 1 +fi + +# Remove cache dir, do not upload it +rm -rf ./build/cache + +copyToTemp +# Remove build dir, do not upload it +rm -rf ./build + +git checkout gh-pages + +cleanup +emptyAndRemoveTemp +commitAndPushChanges + +git checkout - > /dev/null diff --git a/tests/bootstrap.php b/tests/bootstrap.php index f9e62ac66..d9cb0c2c7 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -1,2 +1,3 @@ -driver->get($this->getTestPath('index.html')); - self::assertEquals( - 'php-webdriver test page', - $this->driver->getTitle() - ); - } - - public function testGetText() - { - $this->driver->get($this->getTestPath('index.html')); - self::assertEquals( - 'Welcome to the facebook/php-webdriver testing page.', - $this->driver->findElement(WebDriverBy::id('welcome'))->getText() - ); - } - - public function testGetById() - { - $this->driver->get($this->getTestPath('index.html')); - self::assertEquals( - 'Test by ID', - $this->driver->findElement(WebDriverBy::id('id_test'))->getText() - ); - } - - public function testGetByClassName() - { - $this->driver->get($this->getTestPath('index.html')); - self::assertEquals( - 'Test by Class', - $this->driver->findElement(WebDriverBy::className('test_class'))->getText() - ); - } - - public function testGetByCssSelector() - { - $this->driver->get($this->getTestPath('index.html')); - self::assertEquals( - 'Test by Class', - $this->driver->findElement(WebDriverBy::cssSelector('.test_class'))->getText() - ); - } - - public function testGetByLinkText() - { - $this->driver->get($this->getTestPath('index.html')); - self::assertEquals( - 'Click here', - $this->driver->findElement(WebDriverBy::linkText('Click here'))->getText() - ); - } - - public function testGetByName() - { - $this->driver->get($this->getTestPath('index.html')); - self::assertEquals( - 'Test Value', - $this->driver->findElement(WebDriverBy::name('test_name'))->getAttribute('value') - ); - } - - public function testGetByXpath() - { - $this->driver->get($this->getTestPath('index.html')); - self::assertEquals( - 'Test Value', - $this->driver->findElement(WebDriverBy::xpath('//input[@name="test_name"]'))->getAttribute('value') - ); - } - - public function testGetByPartialLinkText() - { - $this->driver->get($this->getTestPath('index.html')); - self::assertEquals( - 'Click here', - $this->driver->findElement(WebDriverBy::partialLinkText('Click'))->getText() - ); - } - - public function testGetByTagName() - { - $this->driver->get($this->getTestPath('index.html')); - self::assertEquals( - 'Test Value', - $this->driver->findElement(WebDriverBy::tagName('input'))->getAttribute('value') - ); - } -} diff --git a/tests/functional/Chrome/ChromeDevToolsDriverTest.php b/tests/functional/Chrome/ChromeDevToolsDriverTest.php new file mode 100644 index 000000000..16298e04a --- /dev/null +++ b/tests/functional/Chrome/ChromeDevToolsDriverTest.php @@ -0,0 +1,46 @@ +desiredCapabilities->getBrowserName() !== WebDriverBrowserType::CHROME) { + $this->markTestSkipped('ChromeDevTools are available only in Chrome'); + } + } + + public function testShouldExecuteDevToolsCommandWithoutParameters(): void + { + $devTools = new ChromeDevToolsDriver($this->driver); + + $result = $devTools->execute('Performance.enable'); + + $this->assertSame([], $result); + } + + public function testShouldExecuteDevToolsCommandWithParameters(): void + { + $devTools = new ChromeDevToolsDriver($this->driver); + + $result = $devTools->execute('Runtime.evaluate', [ + 'returnByValue' => true, + 'expression' => '42 + 1', + ]); + + $this->assertSame('number', $result['result']['type']); + $this->assertSame(43, $result['result']['value']); + } +} diff --git a/tests/functional/Chrome/ChromeDriverServiceTest.php b/tests/functional/Chrome/ChromeDriverServiceTest.php new file mode 100644 index 000000000..9b6c46336 --- /dev/null +++ b/tests/functional/Chrome/ChromeDriverServiceTest.php @@ -0,0 +1,90 @@ +markTestSkipped('The test is run only when running against local chrome'); + } + } + + protected function tearDown(): void + { + if ($this->driverService !== null && $this->driverService->isRunning()) { + $this->driverService->stop(); + } + } + + public function testShouldStartAndStopServiceCreatedUsingShortcutConstructor(): void + { + // The createDefaultService() method expect path to the executable to be present in the environment variable + putenv(ChromeDriverService::CHROME_DRIVER_EXECUTABLE . '=' . getenv('CHROMEDRIVER_PATH')); + + $this->driverService = ChromeDriverService::createDefaultService(); + + $this->assertSame('http://localhost:9515/', $this->driverService->getURL()); + + $this->assertInstanceOf(ChromeDriverService::class, $this->driverService->start()); + $this->assertTrue($this->driverService->isRunning()); + + $this->assertInstanceOf(ChromeDriverService::class, $this->driverService->start()); + + $this->assertInstanceOf(ChromeDriverService::class, $this->driverService->stop()); + $this->assertFalse($this->driverService->isRunning()); + + $this->assertInstanceOf(ChromeDriverService::class, $this->driverService->stop()); + } + + public function testShouldStartAndStopServiceCreatedUsingDefaultConstructor(): void + { + $this->driverService = new ChromeDriverService(getenv('CHROMEDRIVER_PATH'), 9515, ['--port=9515']); + + $this->assertSame('http://localhost:9515/', $this->driverService->getURL()); + + $this->driverService->start(); + $this->assertTrue($this->driverService->isRunning()); + + $this->driverService->stop(); + $this->assertFalse($this->driverService->isRunning()); + } + + public function testShouldThrowExceptionIfExecutableIsNotExecutable(): void + { + putenv(ChromeDriverService::CHROME_DRIVER_EXECUTABLE . '=' . __FILE__); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('is not executable'); + ChromeDriverService::createDefaultService(); + } + + public function testShouldUseDefaultExecutableIfNoneProvided(): void + { + // Put path where ChromeDriver binary is actually located to system PATH, to make sure we can locate it + putenv('PATH=' . getenv('PATH') . ':' . dirname(getenv('CHROMEDRIVER_PATH'))); + + // Unset CHROME_DRIVER_EXECUTABLE so that ChromeDriverService will attempt to run the binary from system PATH + putenv(ChromeDriverService::CHROME_DRIVER_EXECUTABLE . '='); + + $this->driverService = ChromeDriverService::createDefaultService(); + + $this->assertSame('http://localhost:9515/', $this->driverService->getURL()); + + $this->assertInstanceOf(ChromeDriverService::class, $this->driverService->start()); + $this->assertTrue($this->driverService->isRunning()); + } +} diff --git a/tests/functional/Chrome/ChromeDriverTest.php b/tests/functional/Chrome/ChromeDriverTest.php new file mode 100644 index 000000000..407b65bd8 --- /dev/null +++ b/tests/functional/Chrome/ChromeDriverTest.php @@ -0,0 +1,97 @@ +markTestSkipped('The test is run only when running against local chrome'); + } + } + + protected function tearDown(): void + { + if ($this->driver instanceof RemoteWebDriver && $this->driver->getCommandExecutor() !== null) { + $this->driver->quit(); + } + } + + /** + * @dataProvider provideDialect + */ + public function testShouldStartChromeDriver(bool $isW3cDialect): void + { + $this->startChromeDriver($isW3cDialect); + $this->assertInstanceOf(ChromeDriver::class, $this->driver); + $this->assertInstanceOf(DriverCommandExecutor::class, $this->driver->getCommandExecutor()); + + // Make sure actual browser capabilities were set + $this->assertNotEmpty($this->driver->getCapabilities()->getVersion()); + $this->assertNotEmpty($this->driver->getCapabilities()->getCapability('goog:chromeOptions')); + + $this->driver->get('http://localhost:8000/'); + + $this->assertSame('http://localhost:8000/', $this->driver->getCurrentURL()); + } + + /** + * @return array[] + */ + public function provideDialect(): array + { + return [ + 'w3c' => [true], + 'oss' => [false], + ]; + } + + public function testShouldInstantiateDevTools(): void + { + $this->startChromeDriver(); + + $devTools = $this->driver->getDevTools(); + + $this->assertInstanceOf(ChromeDevToolsDriver::class, $devTools); + + $this->driver->get('http://localhost:8000/'); + + $cdpResult = $devTools->execute( + 'Runtime.evaluate', + ['expression' => 'window.location.toString()'] + ); + + $this->assertSame(['result' => ['type' => 'string', 'value' => 'http://localhost:8000/']], $cdpResult); + } + + private function startChromeDriver($w3cDialect = true): void + { + // The createDefaultService() method expect path to the executable to be present in the environment variable + putenv(ChromeDriverService::CHROME_DRIVER_EXECUTABLE . '=' . getenv('CHROMEDRIVER_PATH')); + + // Add --no-sandbox as a workaround for Chrome crashing: https://github.com/SeleniumHQ/selenium/issues/4961 + $chromeOptions = new ChromeOptions(); + $chromeOptions->addArguments(['--no-sandbox', '--headless', '--disable-search-engine-choice-screen']); + $chromeOptions->setExperimentalOption('w3c', $w3cDialect); + $desiredCapabilities = DesiredCapabilities::chrome(); + $desiredCapabilities->setCapability(ChromeOptions::CAPABILITY, $chromeOptions); + + $this->driver = ChromeDriver::start($desiredCapabilities); + } +} diff --git a/tests/functional/FileUploadTest.php b/tests/functional/FileUploadTest.php index 29b878d9f..e812353ea 100644 --- a/tests/functional/FileUploadTest.php +++ b/tests/functional/FileUploadTest.php @@ -1,49 +1,52 @@ -driver->get($this->getTestPath('upload.html')); - $file_input = $this->driver->findElement(WebDriverBy::id('upload')); - $file_input->setFileDetector(new LocalFileDetector()) - ->sendKeys(__DIR__ . '/files/FileUploadTestCaseFile.txt'); - self::assertNotEquals($this->getFilePath(), $file_input->getAttribute('value')); - } + $this->driver->get($this->getTestPageUrl(TestPage::UPLOAD)); - public function testUselessFileDetectorSendKeys() - { - $this->driver->get($this->getTestPath('upload.html')); - $file_input = $this->driver->findElement(WebDriverBy::id('upload')); - $file_input->sendKeys($this->getFilePath()); - self::assertEquals($this->getFilePath(), $file_input->getAttribute('value')); + $fileElement = $this->driver->findElement(WebDriverBy::name('upload')); + + $fileElement->setFileDetector(new LocalFileDetector()) + ->sendKeys($this->getTestFilePath()); + + $fileElement->submit(); + + $this->driver->wait()->until( + WebDriverExpectedCondition::titleIs('File upload endpoint') + ); + + $uploadedFilesList = $this->driver->findElements(WebDriverBy::cssSelector('ul.uploaded-files li')); + $this->assertCount(1, $uploadedFilesList); + + $uploadedFileName = $this->driver->findElement(WebDriverBy::cssSelector('ul.uploaded-files li span.file-name')) + ->getText(); + $uploadedFileSize = $this->driver->findElement(WebDriverBy::cssSelector('ul.uploaded-files li span.file-size')) + ->getText(); + + $this->assertSame('FileUploadTestFile.txt', $uploadedFileName); + $this->assertSame('10', $uploadedFileSize); } - private function getFilePath() + private function getTestFilePath(): string { - return __DIR__ . '/files/FileUploadTestCaseFile.txt'; + return __DIR__ . '/Fixtures/FileUploadTestFile.txt'; } } diff --git a/tests/functional/Firefox/FirefoxDriverServiceTest.php b/tests/functional/Firefox/FirefoxDriverServiceTest.php new file mode 100644 index 000000000..feabe0dbf --- /dev/null +++ b/tests/functional/Firefox/FirefoxDriverServiceTest.php @@ -0,0 +1,83 @@ +markTestSkipped('The test is run only when running against local firefox'); + } + } + + protected function tearDown(): void + { + if ($this->driverService !== null && $this->driverService->isRunning()) { + $this->driverService->stop(); + } + } + + public function testShouldStartAndStopServiceCreatedUsingShortcutConstructor(): void + { + // The createDefaultService() method expect path to the executable to be present in the environment variable + putenv(FirefoxDriverService::WEBDRIVER_FIREFOX_DRIVER . '=' . getenv('GECKODRIVER_PATH')); + + $this->driverService = FirefoxDriverService::createDefaultService(); + + $this->assertSame('http://localhost:9515/', $this->driverService->getURL()); + + $this->assertInstanceOf(FirefoxDriverService::class, $this->driverService->start()); + $this->assertTrue($this->driverService->isRunning()); + + $this->assertInstanceOf(FirefoxDriverService::class, $this->driverService->start()); + + $this->assertInstanceOf(FirefoxDriverService::class, $this->driverService->stop()); + $this->assertFalse($this->driverService->isRunning()); + + $this->assertInstanceOf(FirefoxDriverService::class, $this->driverService->stop()); + } + + public function testShouldStartAndStopServiceCreatedUsingDefaultConstructor(): void + { + $this->driverService = new FirefoxDriverService(getenv('GECKODRIVER_PATH'), 9515, ['-p=9515']); + + $this->assertSame('http://localhost:9515/', $this->driverService->getURL()); + + $this->driverService->start(); + $this->assertTrue($this->driverService->isRunning()); + + $this->driverService->stop(); + $this->assertFalse($this->driverService->isRunning()); + } + + public function testShouldUseDefaultExecutableIfNoneProvided(): void + { + // Put path where geckodriver binary is actually located to system PATH, to make sure we can locate it + putenv('PATH=' . getenv('PATH') . ':' . dirname(getenv('GECKODRIVER_PATH'))); + + // Unset WEBDRIVER_FIREFOX_BINARY so that FirefoxDriverService will attempt to run the binary from system PATH + putenv(FirefoxDriverService::WEBDRIVER_FIREFOX_DRIVER . '='); + + $this->driverService = FirefoxDriverService::createDefaultService(); + + $this->assertSame('http://localhost:9515/', $this->driverService->getURL()); + + $this->assertInstanceOf(FirefoxDriverService::class, $this->driverService->start()); + $this->assertTrue($this->driverService->isRunning()); + } +} diff --git a/tests/functional/Firefox/FirefoxDriverTest.php b/tests/functional/Firefox/FirefoxDriverTest.php new file mode 100644 index 000000000..900746d2f --- /dev/null +++ b/tests/functional/Firefox/FirefoxDriverTest.php @@ -0,0 +1,86 @@ +markTestSkipped('The test is run only when running against local firefox'); + } + } + + protected function tearDown(): void + { + if ($this->driver instanceof RemoteWebDriver && $this->driver->getCommandExecutor() !== null) { + $this->driver->quit(); + } + } + + public function testShouldStartFirefoxDriver(): void + { + $this->startFirefoxDriver(); + $this->assertInstanceOf(FirefoxDriver::class, $this->driver); + $this->assertInstanceOf(DriverCommandExecutor::class, $this->driver->getCommandExecutor()); + + // Make sure actual browser capabilities were set + $this->assertNotEmpty($this->driver->getCapabilities()->getVersion()); + $this->assertNotEmpty($this->driver->getCapabilities()->getCapability('moz:profile')); + $this->assertTrue($this->driver->getCapabilities()->getCapability('moz:headless')); + + // Ensure browser is responding to basic command + $this->driver->get('http://localhost:8000/'); + $this->assertSame('http://localhost:8000/', $this->driver->getCurrentURL()); + } + + public function testShouldSetPreferenceWithFirefoxOptions(): void + { + $firefoxOptions = new FirefoxOptions(); + $firefoxOptions->setPreference('javascript.enabled', false); + + $this->startFirefoxDriver($firefoxOptions); + + $this->driver->get('http://localhost:8000/'); + + $noScriptElement = $this->driver->findElement(WebDriverBy::id('noscript')); + $this->assertEquals( + 'This element is only shown with JavaScript disabled.', + $noScriptElement->getText() + ); + } + + private function startFirefoxDriver(?FirefoxOptions $firefoxOptions = null): void + { + // The createDefaultService() method expect path to the executable to be present in the environment variable + putenv(FirefoxDriverService::WEBDRIVER_FIREFOX_DRIVER . '=' . getenv('GECKODRIVER_PATH')); + + if ($firefoxOptions === null) { + $firefoxOptions = new FirefoxOptions(); + } + $firefoxOptions->addArguments(['-headless']); + $desiredCapabilities = DesiredCapabilities::firefox(); + $desiredCapabilities->setCapability(FirefoxOptions::CAPABILITY, $firefoxOptions); + + $this->driver = FirefoxDriver::start($desiredCapabilities); + } +} diff --git a/tests/functional/Firefox/FirefoxProfileTest.php b/tests/functional/Firefox/FirefoxProfileTest.php new file mode 100644 index 000000000..a8ff2c62d --- /dev/null +++ b/tests/functional/Firefox/FirefoxProfileTest.php @@ -0,0 +1,128 @@ +` element + * with some text at the end of each page Firefox renders. + * + * In case the extension will need to be modified, steps below must be followed, + * otherwise firefox won't load the modified extension: + * + * - Extract the xpi file (it is a zip archive) to some temporary directory + * - Make needed changes in the files + * - Install web-ext tool from Mozilla (@see https://github.com/mozilla/web-ext) + * - Sign in to https://addons.mozilla.org/cs/developers/addon/api/key/ to get your JWT API key and JWT secret + * - Run `web-ext sign --channel=unlisted --api-key=[you-api-key] --api-secret=[your-api-secret]` in the extension dir + * - Store the output file (`web-ext-artifacts/[...].xpi`) to the Fixtures/ directory + * + * @group exclude-saucelabs + * @covers \Facebook\WebDriver\Firefox\FirefoxProfile + */ +class FirefoxProfileTest extends TestCase +{ + /** @var FirefoxDriver */ + protected $driver; + + protected $firefoxTestExtensionFilename = __DIR__ . '/Fixtures/FirefoxExtension.xpi'; + + protected function setUp(): void + { + if (getenv('BROWSER_NAME') !== 'firefox' || empty(getenv('GECKODRIVER_PATH')) + || WebDriverTestCase::isSauceLabsBuild()) { + $this->markTestSkipped('The test is run only when running against local firefox'); + } + } + + protected function tearDown(): void + { + if ($this->driver instanceof RemoteWebDriver && $this->driver->getCommandExecutor() !== null) { + $this->driver->quit(); + } + } + + public function testShouldStartDriverWithEmptyProfile(): void + { + $firefoxProfile = new FirefoxProfile(); + $this->startFirefoxDriverWithProfile($firefoxProfile); + + $this->driver->get('http://localhost:8000/'); + $element = $this->driver->findElement(WebDriverBy::id('welcome')); + $this->assertSame( + 'Welcome to the php-webdriver testing page.', + $element->getText() + ); + } + + public function testShouldInstallExtension(): void + { + $firefoxProfile = new FirefoxProfile(); + $firefoxProfile->addExtension($this->firefoxTestExtensionFilename); + $this->startFirefoxDriverWithProfile($firefoxProfile); + + $this->driver->get($this->getTestPageUrl(TestPage::INDEX)); + + $this->assertInstanceOf(RemoteWebDriver::class, $this->driver); + + // it sometimes takes split of a second for the extension to render the element, so we must use wait + $element = $this->driver->wait(5, 1)->until( + WebDriverExpectedCondition::presenceOfElementLocated(WebDriverBy::id('webDriverExtensionTest')) + ); + + $this->assertEquals('This element was added by browser extension', $element->getText()); + } + + public function testShouldUseProfilePreferences(): void + { + $firefoxProfile = new FirefoxProfile(); + + // Please note, although it is possible to set preferences right into the profile (what this test does), + // we recommend using the setPreference() method on FirefoxOptions instead, so that you don't need to + // create FirefoxProfile. + $firefoxProfile->setPreference('javascript.enabled', false); + $this->assertSame('false', $firefoxProfile->getPreference('javascript.enabled')); + + $this->startFirefoxDriverWithProfile($firefoxProfile); + $this->driver->get('http://localhost:8000/'); + + $noScriptElement = $this->driver->findElement(WebDriverBy::id('noscript')); + $this->assertEquals( + 'This element is only shown with JavaScript disabled.', + $noScriptElement->getText() + ); + } + + protected function getTestPageUrl($path): string + { + $host = 'http://localhost:8000/'; + if ($alternateHost = getenv('FIXTURES_HOST')) { + $host = $alternateHost; + } + + return $host . '/' . $path; + } + + private function startFirefoxDriverWithProfile(FirefoxProfile $firefoxProfile): void + { + // The createDefaultService() method expect path to the executable to be present in the environment variable + putenv(FirefoxDriverService::WEBDRIVER_FIREFOX_DRIVER . '=' . getenv('GECKODRIVER_PATH')); + + $firefoxOptions = new FirefoxOptions(); + $firefoxOptions->addArguments(['-headless']); + $firefoxOptions->setProfile($firefoxProfile); + $desiredCapabilities = DesiredCapabilities::firefox(); + $desiredCapabilities->setCapability(FirefoxOptions::CAPABILITY, $firefoxOptions); + + $this->driver = FirefoxDriver::start($desiredCapabilities); + } +} diff --git a/tests/functional/Firefox/Fixtures/FirefoxExtension.xpi b/tests/functional/Firefox/Fixtures/FirefoxExtension.xpi new file mode 100644 index 000000000..c5805fbeb Binary files /dev/null and b/tests/functional/Firefox/Fixtures/FirefoxExtension.xpi differ diff --git a/tests/functional/files/FileUploadTestCaseFile.txt b/tests/functional/Fixtures/FileUploadTestFile.txt similarity index 100% rename from tests/functional/files/FileUploadTestCaseFile.txt rename to tests/functional/Fixtures/FileUploadTestFile.txt diff --git a/tests/functional/Remote/JsonWireCompatTest.php b/tests/functional/Remote/JsonWireCompatTest.php new file mode 100644 index 000000000..8dfb5e29a --- /dev/null +++ b/tests/functional/Remote/JsonWireCompatTest.php @@ -0,0 +1,17 @@ +expectException(UnexpectedResponseException::class); + $this->expectExceptionMessage('Unexpected server response for getting an element. Expected array'); + + JsonWireCompat::getElement(null); + } +} diff --git a/tests/functional/RemoteKeyboardTest.php b/tests/functional/RemoteKeyboardTest.php new file mode 100644 index 000000000..897d33cf7 --- /dev/null +++ b/tests/functional/RemoteKeyboardTest.php @@ -0,0 +1,84 @@ +driver->get($this->getTestPageUrl(TestPage::EVENTS)); + + $this->driver->getKeyboard()->sendKeys('ab'); + $this->driver->getKeyboard()->pressKey(WebDriverKeys::SHIFT); + + $this->driver->getKeyboard()->sendKeys('cd' . WebDriverKeys::NULL . 'e'); + + $this->driver->getKeyboard()->pressKey(WebDriverKeys::SHIFT); + $this->driver->getKeyboard()->pressKey('f'); + $this->driver->getKeyboard()->releaseKey(WebDriverKeys::SHIFT); + $this->driver->getKeyboard()->releaseKey('f'); + + if (self::isW3cProtocolBuild()) { + $this->assertEquals( + [ + 'keydown "a"', + 'keyup "a"', + 'keydown "b"', + 'keyup "b"', + 'keydown "Shift"', + 'keydown "C"', + 'keyup "C"', + 'keydown "D"', + 'keyup "D"', + 'keyup "Shift"', + 'keydown "e"', + 'keyup "e"', + 'keydown "Shift"', + 'keydown "F"', + 'keyup "Shift"', + 'keyup "f"', + ], + $this->retrieveLoggedKeyboardEvents() + ); + } else { + $this->assertEquals( + [ + 'keydown "a"', + 'keyup "a"', + 'keydown "b"', + 'keyup "b"', + 'keydown "Shift"', + 'keydown "C"', + 'keyup "C"', + 'keydown "D"', + 'keyup "D"', + 'keyup "Shift"', + 'keydown "e"', + 'keyup "e"', + 'keydown "Shift"', + 'keydown "F"', // pressKey behaves differently on old protocol + 'keyup "F"', + 'keyup "Shift"', + 'keydown "f"', + 'keyup "f"', + ], + $this->retrieveLoggedKeyboardEvents() + ); + } + } +} diff --git a/tests/functional/RemoteTargetLocatorTest.php b/tests/functional/RemoteTargetLocatorTest.php new file mode 100644 index 000000000..a3080e67a --- /dev/null +++ b/tests/functional/RemoteTargetLocatorTest.php @@ -0,0 +1,183 @@ +driver->get($this->getTestPageUrl(TestPage::OPEN_NEW_WINDOW)); + $originalWindowHandle = $this->driver->getWindowHandle(); + $windowHandlesBefore = $this->driver->getWindowHandles(); + + $this->driver->findElement(WebDriverBy::cssSelector('a#open-new-window')) + ->click(); + + $this->driver->wait()->until( + WebDriverExpectedCondition::numberOfWindowsToBe(2) + ); + + // At first the window should not be switched + $this->assertStringContainsString('open_new_window.html', $this->driver->getCurrentURL()); + $this->assertSame($originalWindowHandle, $this->driver->getWindowHandle()); + + /** + * @see https://w3c.github.io/webdriver/#get-window-handles + * > "The order in which the window handles are returned is arbitrary." + * Thus we must first find out which window handle is the new one + */ + $windowHandlesAfter = $this->driver->getWindowHandles(); + $newWindowHandle = array_diff($windowHandlesAfter, $windowHandlesBefore); + $newWindowHandle = reset($newWindowHandle); + + $this->driver->switchTo()->window($newWindowHandle); + + $this->driver->wait()->until(function () { + // The window contents is sometimes not yet loaded and needs a while to actually show the index.html page + return mb_strpos($this->driver->getCurrentURL(), 'index.html') !== false; + }); + + // After switchTo() is called, the active window should be changed + $this->assertStringContainsString('index.html', $this->driver->getCurrentURL()); + $this->assertNotSame($originalWindowHandle, $this->driver->getWindowHandle()); + } + + public function testActiveElement(): void + { + $this->driver->get($this->getTestPageUrl(TestPage::INDEX)); + + $activeElement = $this->driver->switchTo()->activeElement(); + $this->assertInstanceOf(RemoteWebElement::class, $activeElement); + $this->assertSame('body', $activeElement->getTagName()); + + $this->driver->findElement(WebDriverBy::name('test_name'))->click(); + $activeElement = $this->driver->switchTo()->activeElement(); + $this->assertSame('input', $activeElement->getTagName()); + $this->assertSame('test_name', $activeElement->getAttribute('name')); + } + + public function testShouldSwitchToFrameByItsId(): void + { + $parentPage = 'This is the host page which contains an iFrame'; + $firstChildFrame = 'This is the content of the iFrame'; + $secondChildFrame = 'open new window'; + + $this->driver->get($this->getTestPageUrl(TestPage::PAGE_WITH_FRAME)); + + $this->assertStringContainsString($parentPage, $this->driver->getPageSource()); + + $this->driver->switchTo()->frame(0); + $this->assertStringContainsString($firstChildFrame, $this->driver->getPageSource()); + + $this->driver->switchTo()->frame(null); + $this->assertStringContainsString($parentPage, $this->driver->getPageSource()); + + $this->driver->switchTo()->frame(1); + $this->assertStringContainsString($secondChildFrame, $this->driver->getPageSource()); + + $this->driver->switchTo()->frame(null); + $this->assertStringContainsString($parentPage, $this->driver->getPageSource()); + + $this->driver->switchTo()->frame(0); + $this->assertStringContainsString($firstChildFrame, $this->driver->getPageSource()); + + $this->driver->switchTo()->defaultContent(); + $this->assertStringContainsString($parentPage, $this->driver->getPageSource()); + } + + public function testShouldSwitchToParentFrame(): void + { + $parentPage = 'This is the host page which contains an iFrame'; + $firstChildFrame = 'This is the content of the iFrame'; + + $this->driver->get($this->getTestPageUrl(TestPage::PAGE_WITH_FRAME)); + + $this->assertStringContainsString($parentPage, $this->driver->getPageSource()); + + $this->driver->switchTo()->frame(0); + $this->assertStringContainsString($firstChildFrame, $this->driver->getPageSource()); + + $this->driver->switchTo()->parent(); + $this->assertStringContainsString($parentPage, $this->driver->getPageSource()); + } + + public function testShouldSwitchToFrameByElement(): void + { + $this->driver->get($this->getTestPageUrl(TestPage::PAGE_WITH_FRAME)); + + $element = $this->driver->findElement(WebDriverBy::id('iframe_content')); + $this->driver->switchTo()->frame($element); + + $this->assertStringContainsString('This is the content of the iFrame', $this->driver->getPageSource()); + } + + /** + * @group exclude-saucelabs + */ + public function testShouldCreateNewWindow(): void + { + self::skipForJsonWireProtocol('Create new window is not supported in JsonWire protocol'); + + // Ensure that the initial context matches. + $initialHandle = $this->driver->getWindowHandle(); + $this->driver->get($this->getTestPageUrl(TestPage::INDEX)); + $this->assertEquals($this->getTestPageUrl(TestPage::INDEX), $this->driver->getCurrentUrl()); + $source = $this->driver->getPageSource(); + $this->assertStringContainsString('

', $source); + $this->assertStringContainsString('Welcome to the php-webdriver testing page.', $source); + $windowHandles = $this->driver->getWindowHandles(); + $this->assertCount(1, $windowHandles); + + // Create a new window + $this->driver->switchTo()->newWindow(); + + $windowHandles = $this->driver->getWindowHandles(); + $this->assertCount(2, $windowHandles); + + $newWindowHandle = $this->driver->getWindowHandle(); + $this->driver->get($this->getTestPageUrl(TestPage::UPLOAD)); + $this->assertEquals($this->getTestPageUrl(TestPage::UPLOAD), $this->driver->getCurrentUrl()); + $this->assertNotEquals($initialHandle, $newWindowHandle); + + // Switch back to original context. + $this->driver->switchTo()->window($initialHandle); + $this->assertEquals($this->getTestPageUrl(TestPage::INDEX), $this->driver->getCurrentUrl()); + } + + /** + * @group exclude-saucelabs + */ + public function testShouldNotAcceptStringAsFrameIdInW3cMode(): void + { + self::skipForJsonWireProtocol(); + + $this->driver->get($this->getTestPageUrl(TestPage::PAGE_WITH_FRAME)); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage( + 'In W3C compliance mode frame must be either instance of WebDriverElement, integer or null' + ); + + $this->driver->switchTo()->frame('iframe_content'); + } + + /** + * @group exclude-saucelabs + */ + public function testShouldAcceptStringAsFrameIdInJsonWireMode(): void + { + self::skipForW3cProtocol(); + + $this->driver->get($this->getTestPageUrl(TestPage::PAGE_WITH_FRAME)); + + $this->driver->switchTo()->frame('iframe_content'); + + $this->assertStringContainsString('This is the content of the iFrame', $this->driver->getPageSource()); + } +} diff --git a/tests/functional/RemoteWebDriverCreateTest.php b/tests/functional/RemoteWebDriverCreateTest.php new file mode 100644 index 000000000..a39a91070 --- /dev/null +++ b/tests/functional/RemoteWebDriverCreateTest.php @@ -0,0 +1,175 @@ +driver = RemoteWebDriver::create( + $this->serverUrl, + $this->desiredCapabilities, + $this->connectionTimeout, + $this->requestTimeout, + null, + null, + null + ); + + $this->assertInstanceOf(RemoteWebDriver::class, $this->driver); + + $this->assertInstanceOf(HttpCommandExecutor::class, $this->driver->getCommandExecutor()); + $this->assertNotEmpty($this->driver->getCommandExecutor()->getAddressOfRemoteServer()); + + $this->assertIsString($this->driver->getSessionID()); + $this->assertNotEmpty($this->driver->getSessionID()); + + $returnedCapabilities = $this->driver->getCapabilities(); + $this->assertInstanceOf(WebDriverCapabilities::class, $returnedCapabilities); + + // MicrosoftEdge on Sauce Labs started to identify itself back as "msedge" + if ($this->desiredCapabilities->getBrowserName() !== WebDriverBrowserType::MICROSOFT_EDGE) { + $this->assertEqualsIgnoringCase( + $this->desiredCapabilities->getBrowserName(), + $returnedCapabilities->getBrowserName() + ); + } + + $this->assertNotEmpty($returnedCapabilities->getPlatform()); + $this->assertNotEmpty($returnedCapabilities); + $this->assertNotEmpty($returnedCapabilities->getVersion()); + } + + public function testShouldAcceptCapabilitiesAsAnArray(): void + { + // Method has a side-effect of converting whole content of desiredCapabilities to an array + $this->desiredCapabilities->toArray(); + + $this->driver = RemoteWebDriver::create( + $this->serverUrl, + $this->desiredCapabilities, + $this->connectionTimeout, + $this->requestTimeout + ); + + $this->assertNotNull($this->driver->getCapabilities()); + } + + public function testShouldCreateWebDriverWithRequiredCapabilities(): void + { + $requiredCapabilities = new DesiredCapabilities(); + + $this->driver = RemoteWebDriver::create( + $this->serverUrl, + $this->desiredCapabilities, + $this->connectionTimeout, + $this->requestTimeout, + null, + null, + $requiredCapabilities + ); + + $this->assertInstanceOf(RemoteWebDriver::class, $this->driver); + } + + /** + * Capabilities (browser name) must be defined when executing via Selenium proxy (standalone server, + * Saucelabs etc.). But when running directly via browser driver, they could be empty. + * However the the browser driver must be able to create non-headless instance (eg. inside xvfb). + * @group exclude-saucelabs + */ + public function testShouldCreateWebDriverWithoutCapabilities(): void + { + if (getenv('GECKODRIVER') !== '1' && empty(getenv('CHROMEDRIVER_PATH'))) { + $this->markTestSkipped('This test makes sense only when run directly via specific browser driver'); + } + + $this->driver = RemoteWebDriver::create($this->serverUrl); + + $this->assertInstanceOf(RemoteWebDriver::class, $this->driver); + $this->assertNotEmpty($this->driver->getSessionID()); + } + + public function testShouldCreateInstanceFromExistingSessionId(): void + { + // Create driver instance and load page "index.html" + $originalDriver = RemoteWebDriver::create( + $this->serverUrl, + $this->desiredCapabilities, + $this->connectionTimeout, + $this->requestTimeout + ); + $originalDriver->get($this->getTestPageUrl(TestPage::INDEX)); + $this->assertStringContainsString('/index.html', $originalDriver->getCurrentURL()); + + // Store session attributes + $sessionId = $originalDriver->getSessionID(); + $isW3cCompliant = $originalDriver->isW3cCompliant(); + $originalCapabilities = $originalDriver->getCapabilities(); + + $capabilitiesForSessionReuse = $originalCapabilities; + if ($this->isSeleniumServerUsed()) { + // do not provide capabilities when selenium server is used, to test they are read from selenium server + $capabilitiesForSessionReuse = null; + } + + // Create new RemoteWebDriver instance based on the session ID + $this->driver = RemoteWebDriver::createBySessionID( + $sessionId, + $this->serverUrl, + null, + null, + $isW3cCompliant, + $capabilitiesForSessionReuse + ); + + // Capabilities should be retrieved and be set to the driver instance + $returnedCapabilities = $this->driver->getCapabilities(); + $this->assertInstanceOf(WebDriverCapabilities::class, $returnedCapabilities); + + $expectedBrowserName = $this->desiredCapabilities->getBrowserName(); + + $this->assertEqualsIgnoringCase( + $expectedBrowserName, + $returnedCapabilities->getBrowserName() + ); + $this->assertEqualsCanonicalizing($originalCapabilities, $this->driver->getCapabilities()); + + // Check we reused the previous instance (window) and it has the same URL + $this->assertStringContainsString('/index.html', $this->driver->getCurrentURL()); + + // Do some interaction with the new driver + $this->assertNotEmpty($this->driver->findElement(WebDriverBy::id('id_test'))->getText()); + } + + public function testShouldRequireCapabilitiesToBeSetToReuseExistingSession(): void + { + $this->expectException(UnexpectedResponseException::class); + $this->expectExceptionMessage( + 'Existing Capabilities were not provided, and they also cannot be read from Selenium Grid' + ); + + // Do not provide capabilities, they also cannot be retrieved from the Selenium Grid + RemoteWebDriver::createBySessionID( + 'sessionId', + 'http://localhost:332/', // nothing should be running there + null, + null + ); + } + + protected function createWebDriver(): void + { + } +} diff --git a/tests/functional/RemoteWebDriverFindElementTest.php b/tests/functional/RemoteWebDriverFindElementTest.php new file mode 100644 index 000000000..f638aa527 --- /dev/null +++ b/tests/functional/RemoteWebDriverFindElementTest.php @@ -0,0 +1,72 @@ +driver->get($this->getTestPageUrl(TestPage::INDEX)); + + $this->expectException(NoSuchElementException::class); + $this->driver->findElement(WebDriverBy::id('not_existing')); + } + + public function testShouldFindElementIfExistsOnAPage(): void + { + $this->driver->get($this->getTestPageUrl(TestPage::INDEX)); + + $element = $this->driver->findElement(WebDriverBy::id('id_test')); + + $this->assertInstanceOf(RemoteWebElement::class, $element); + } + + public function testShouldReturnEmptyArrayIfElementsCannotBeFound(): void + { + $this->driver->get($this->getTestPageUrl(TestPage::INDEX)); + + $elements = $this->driver->findElements(WebDriverBy::cssSelector('not_existing')); + + $this->assertIsArray($elements); + $this->assertCount(0, $elements); + } + + public function testShouldFindMultipleElements(): void + { + $this->driver->get($this->getTestPageUrl(TestPage::INDEX)); + + $elements = $this->driver->findElements(WebDriverBy::cssSelector('ul > li')); + + $this->assertIsArray($elements); + $this->assertCount(5, $elements); + $this->assertContainsOnlyInstancesOf(RemoteWebElement::class, $elements); + } + + /** + * @group exclude-saucelabs + */ + public function testEscapeCssSelector(): void + { + self::skipForJsonWireProtocol( + 'CSS selectors containing special characters are not supported by the legacy protocol' + ); + + $this->driver->get($this->getTestPageUrl(TestPage::ESCAPE_CSS)); + + $element = $this->driver->findElement(WebDriverBy::id('.fo\'oo')); + $this->assertSame('Foo', $element->getText()); + + $element = $this->driver->findElement(WebDriverBy::className('#ba\'r')); + $this->assertSame('Bar', $element->getText()); + + $element = $this->driver->findElement(WebDriverBy::name('.#ba\'z')); + $this->assertSame('Baz', $element->getText()); + } +} diff --git a/tests/functional/RemoteWebDriverTest.php b/tests/functional/RemoteWebDriverTest.php new file mode 100644 index 000000000..95a01b9b7 --- /dev/null +++ b/tests/functional/RemoteWebDriverTest.php @@ -0,0 +1,328 @@ +driver->get($this->getTestPageUrl(TestPage::INDEX)); + + $this->assertEquals( + 'php-webdriver test page', + $this->driver->getTitle() + ); + } + + /** + * @covers ::get + * @covers ::getCurrentURL + */ + public function testShouldGetCurrentUrl(): void + { + $this->driver->get($this->getTestPageUrl(TestPage::INDEX)); + + $this->assertStringEndsWith('/index.html', $this->driver->getCurrentURL()); + } + + /** + * @covers ::getPageSource + */ + public function testShouldGetPageSource(): void + { + $this->driver->get($this->getTestPageUrl(TestPage::INDEX)); + + $source = $this->driver->getPageSource(); + $this->assertStringContainsString('

', $source); + $this->assertStringContainsString('Welcome to the php-webdriver testing page.', $source); + } + + /** + * @covers ::getSessionID + * @covers ::isW3cCompliant + */ + public function testShouldGetSessionId(): void + { + // This tests is intentionally included in another test, to not slow down build. + // @TODO Remove following in 2.0 + if (self::isW3cProtocolBuild()) { + $this->assertTrue($this->driver->isW3cCompliant()); + } else { + $this->assertFalse($this->driver->isW3cCompliant()); + } + + $sessionId = $this->driver->getSessionID(); + + $this->assertIsString($sessionId); + $this->assertNotEmpty($sessionId); + } + + /** + * @group exclude-saucelabs + * @covers ::getAllSessions + */ + public function testShouldGetAllSessions(): void + { + self::skipForW3cProtocol(); + + $sessions = RemoteWebDriver::getAllSessions($this->serverUrl, 30000); + + $this->assertIsArray($sessions); + $this->assertCount(1, $sessions); + + $this->assertArrayHasKey('capabilities', $sessions[0]); + $this->assertArrayHasKey('id', $sessions[0]); + } + + /** + * @group exclude-saucelabs + * @covers ::getAllSessions + * @covers ::getCommandExecutor + * @covers ::quit + */ + public function testShouldQuitAndUnsetExecutor(): void + { + self::skipForW3cProtocol(); + + $this->assertCount( + 1, + RemoteWebDriver::getAllSessions($this->serverUrl) + ); + $this->assertInstanceOf(HttpCommandExecutor::class, $this->driver->getCommandExecutor()); + + $this->driver->quit(); + + // Wait a while until chromedriver finishes deleting the session. + // https://bugs.chromium.org/p/chromedriver/issues/detail?id=3736 + usleep(250000); // 250 ms + + $this->assertCount( + 0, + RemoteWebDriver::getAllSessions($this->serverUrl) + ); + $this->assertNull($this->driver->getCommandExecutor()); + } + + /** + * @covers ::getWindowHandle + * @covers ::getWindowHandles + */ + public function testShouldGetWindowHandles(): void + { + $this->driver->get($this->getTestPageUrl(TestPage::OPEN_NEW_WINDOW)); + + $windowHandle = $this->driver->getWindowHandle(); + $windowHandles = $this->driver->getWindowHandles(); + + $this->assertIsString($windowHandle); + $this->assertNotEmpty($windowHandle); + $this->assertSame([$windowHandle], $windowHandles); + + // Open second window + $this->driver->findElement(WebDriverBy::cssSelector('a'))->click(); + + $this->driver->wait()->until( + WebDriverExpectedCondition::numberOfWindowsToBe(2) + ); + + $this->assertCount(2, $this->driver->getWindowHandles()); + } + + /** + * @covers ::close + */ + public function testShouldCloseWindow(): void + { + $this->driver->get($this->getTestPageUrl(TestPage::OPEN_NEW_WINDOW)); + $this->driver->findElement(WebDriverBy::cssSelector('a'))->click(); + + $this->driver->wait()->until(WebDriverExpectedCondition::numberOfWindowsToBe(2)); + + $this->assertCount(2, $this->driver->getWindowHandles()); + + $this->driver->close(); + + $this->assertCount(1, $this->driver->getWindowHandles()); + } + + /** + * @covers ::executeScript + * @group exclude-saucelabs + */ + public function testShouldExecuteScriptAndDoNotBlockExecution(): void + { + $this->driver->get($this->getTestPageUrl(TestPage::INDEX)); + + $element = $this->driver->findElement(WebDriverBy::id('id_test')); + $this->assertSame('Test by ID', $element->getText()); + + $start = microtime(true); + $scriptResult = $this->driver->executeScript(' + setTimeout( + function(){document.getElementById("id_test").innerHTML = "Text changed by script";}, + 250 + ); + return "returned value"; + '); + $end = microtime(true); + + $this->assertSame('returned value', $scriptResult); + + $this->assertLessThan(250, $end - $start, 'executeScript() should not block execution'); + + // If we wait, the script should be executed and its value changed + usleep(300000); // wait 300 ms + $this->assertSame('Text changed by script', $element->getText()); + } + + /** + * @covers ::executeAsyncScript + * @covers \Facebook\WebDriver\WebDriverTimeouts::setScriptTimeout + */ + public function testShouldExecuteAsyncScriptAndWaitUntilItIsFinished(): void + { + $this->driver->manage()->timeouts()->setScriptTimeout(1); + + $this->driver->get($this->getTestPageUrl(TestPage::INDEX)); + + $element = $this->driver->findElement(WebDriverBy::id('id_test')); + $this->assertSame('Test by ID', $element->getText()); + + $start = microtime(true); + $scriptResult = $this->driver->executeAsyncScript( + 'var callback = arguments[arguments.length - 1]; + setTimeout( + function(){ + document.getElementById("id_test").innerHTML = "Text changed by script"; + callback("returned value"); + }, + 250 + );' + ); + $end = microtime(true); + + $this->assertSame('returned value', $scriptResult); + + $this->assertGreaterThan( + 0.250, + $end - $start, + 'executeAsyncScript() should block execution until callback() is called' + ); + + // The result must be immediately available, as the executeAsyncScript should block the execution until the + // callback is called. + $this->assertSame('Text changed by script', $element->getText()); + } + + /** + * @covers ::executeScript + * @covers ::prepareScriptArguments + * @group exclude-saucelabs + */ + public function testShouldExecuteScriptWithParamsAndReturnValue(): void + { + $this->driver->manage()->timeouts()->setScriptTimeout(1); + + $this->driver->get($this->getTestPageUrl(TestPage::INDEX)); + + $element1 = $this->driver->findElement(WebDriverBy::id('id_test')); + $element2 = $this->driver->findElement(WebDriverBy::className('test_class')); + + $scriptResult = $this->driver->executeScript( + 'var element1 = arguments[0]; + var element2 = arguments[1]; + return "1: " + element1.innerText + ", 2: " + element2.innerText; + ', + [$element1, $element2] + ); + + $this->assertSame('1: Test by ID, 2: Test by Class', $scriptResult); + } + + /** + * @covers ::takeScreenshot + * @covers \Facebook\WebDriver\Support\ScreenshotHelper + */ + public function testShouldTakeScreenshot(): void + { + if (!extension_loaded('gd')) { + $this->markTestSkipped('GD extension must be enabled'); + } + $this->driver->get($this->getTestPageUrl(TestPage::INDEX)); + + $outputPng = $this->driver->takeScreenshot(); + + $image = imagecreatefromstring($outputPng); + $this->assertNotFalse($image); + + $this->assertGreaterThan(0, imagesx($image)); + $this->assertGreaterThan(0, imagesy($image)); + } + + /** + * @covers ::takeScreenshot + * @covers \Facebook\WebDriver\Support\ScreenshotHelper + * @group exclude-safari + * Safari is returning different color profile and it does not have way to configure "force-color-profile" + */ + public function testShouldSaveScreenshotToFile(): void + { + if (!extension_loaded('gd')) { + $this->markTestSkipped('GD extension must be enabled'); + } + + $screenshotPath = sys_get_temp_dir() . '/' . uniqid('php-webdriver-') . '/selenium-screenshot.png'; + + $this->driver->get($this->getTestPageUrl(TestPage::INDEX)); + + $this->driver->takeScreenshot($screenshotPath); + + $image = imagecreatefrompng($screenshotPath); + $this->assertNotFalse($image); + + $this->assertGreaterThan(0, imagesx($image)); + $this->assertGreaterThan(0, imagesy($image)); + + // Validate expected red box is present on the screenshot + $this->assertSame( + ['red' => 255, 'green' => 0, 'blue' => 0, 'alpha' => 0], + imagecolorsforindex($image, imagecolorat($image, 5, 5)) + ); + + // And whitespace has expected background color + $this->assertSame( + ['red' => 250, 'green' => 250, 'blue' => 255, 'alpha' => 0], + imagecolorsforindex($image, imagecolorat($image, 15, 5)) + ); + + unlink($screenshotPath); + rmdir(dirname($screenshotPath)); + } + + /** + * @covers ::getStatus + * @covers \Facebook\WebDriver\Remote\RemoteStatus + * @group exclude-saucelabs + * Status endpoint is not supported on Sauce Labs + */ + public function testShouldGetRemoteEndStatus(): void + { + $status = $this->driver->getStatus(); + + $this->assertIsBool($status->isReady()); + $this->assertIsArray($status->getMeta()); + + if (getenv('BROWSER_NAME') !== 'safari') { + $this->assertNotEmpty($status->getMessage()); + } + } +} diff --git a/tests/functional/RemoteWebElementTest.php b/tests/functional/RemoteWebElementTest.php new file mode 100644 index 000000000..6a21cb36c --- /dev/null +++ b/tests/functional/RemoteWebElementTest.php @@ -0,0 +1,541 @@ +driver->get($this->getTestPageUrl(TestPage::INDEX)); + $elementWithSimpleText = $this->driver->findElement(WebDriverBy::id('text-simple')); + $elementWithTextWithSpaces = $this->driver->findElement(WebDriverBy::id('text-with-spaces')); + + $this->assertEquals('Foo bar text', $elementWithSimpleText->getText()); + + $this->assertEquals('Multiple spaces are stripped', $elementWithTextWithSpaces->getText()); + } + + /** + * @covers ::getAttribute + */ + public function testShouldGetAttributeValue(): void + { + $this->driver->get($this->getTestPageUrl(TestPage::INDEX)); + + $element = $this->driver->findElement(WebDriverBy::id('text-simple')); + + $this->assertSame('note', $element->getAttribute('role')); + $this->assertSame('height: 5em; border: 1px solid black;', $element->getAttribute('style')); + $this->assertSame('text-simple', $element->getAttribute('id')); + $this->assertNull($element->getAttribute('notExisting')); + } + + /** + * @covers ::getDomProperty + */ + public function testShouldGetDomPropertyValue(): void + { + self::skipForJsonWireProtocol(); + + $this->driver->get($this->getTestPageUrl(TestPage::INDEX)); + + $element = $this->driver->findElement(WebDriverBy::id('div-with-html')); + + $this->assertStringContainsString( + '

This div has some more html inside.

', + $element->getDomProperty('innerHTML') + ); + $this->assertSame('foo bar', $element->getDomProperty('className')); // IDL property + $this->assertSame('foo bar', $element->getAttribute('class')); // HTML attribute should be the same + $this->assertSame('DIV', $element->getDomProperty('tagName')); + $this->assertSame(2, $element->getDomProperty('childElementCount')); + $this->assertNull($element->getDomProperty('notExistingProperty')); + } + + /** + * @covers ::getLocation + */ + public function testShouldGetLocation(): void + { + $this->driver->get($this->getTestPageUrl(TestPage::INDEX)); + + $element = $this->driver->findElement(WebDriverBy::id('element-with-location')); + + $elementLocation = $element->getLocation(); + $this->assertInstanceOf(WebDriverPoint::class, $elementLocation); + $this->assertSame(33, $elementLocation->getX()); + $this->assertSame(550, $elementLocation->getY()); + } + + /** + * @covers ::getLocationOnScreenOnceScrolledIntoView + */ + public function testShouldGetLocationOnScreenOnceScrolledIntoView(): void + { + $this->driver->get($this->getTestPageUrl(TestPage::INDEX)); + + $element = $this->driver->findElement(WebDriverBy::id('element-out-of-viewport')); + + // Location before scrolling into view is out of viewport + $elementLocation = $element->getLocation(); + $this->assertInstanceOf(WebDriverPoint::class, $elementLocation); + $this->assertSame(33, $elementLocation->getX()); + $this->assertSame(5000, $elementLocation->getY()); + + // Location once scrolled into view + $elementLocationOnceScrolledIntoView = $element->getLocationOnScreenOnceScrolledIntoView(); + $this->assertInstanceOf(WebDriverPoint::class, $elementLocationOnceScrolledIntoView); + $this->assertSame(33, $elementLocationOnceScrolledIntoView->getX()); + $this->assertLessThan( + 1000, // screen size is ~768, so this should be less + $elementLocationOnceScrolledIntoView->getY() + ); + } + + /** + * @covers ::getSize + */ + public function testShouldGetSize(): void + { + $this->driver->get($this->getTestPageUrl(TestPage::INDEX)); + + $element = $this->driver->findElement(WebDriverBy::id('element-with-location')); + + $elementSize = $element->getSize(); + $this->assertInstanceOf(WebDriverDimension::class, $elementSize); + $this->assertSame(333, $elementSize->getWidth()); + $this->assertSame(66, $elementSize->getHeight()); + } + + /** + * @covers ::getCSSValue + */ + public function testShouldGetCssValue(): void + { + $this->driver->get($this->getTestPageUrl(TestPage::INDEX)); + + $elementWithBorder = $this->driver->findElement(WebDriverBy::id('text-simple')); + $elementWithoutBorder = $this->driver->findElement(WebDriverBy::id('text-with-spaces')); + + $this->assertSame('solid', $elementWithBorder->getCSSValue('border-left-style')); + $this->assertSame('none', $elementWithoutBorder->getCSSValue('border-left-style')); + + // Browser could report color in either rgb (like MS Edge) or rgba (like everyone else) + $this->assertMatchesRegularExpression( + '/rgba?\(0, 0, 0(, 1)?\)/', + $elementWithBorder->getCSSValue('border-left-color') + ); + } + + /** + * @covers ::getTagName + */ + public function testShouldGetTagName(): void + { + $this->driver->get($this->getTestPageUrl(TestPage::INDEX)); + + $paragraphElement = $this->driver->findElement(WebDriverBy::id('id_test')); + + $this->assertSame('p', $paragraphElement->getTagName()); + } + + /** + * @covers ::click + */ + public function testShouldClick(): void + { + $this->driver->get($this->getTestPageUrl(TestPage::INDEX)); + $linkElement = $this->driver->findElement(WebDriverBy::id('a-form')); + + $linkElement->click(); + + $this->driver->wait()->until( + WebDriverExpectedCondition::urlContains(TestPage::FORM) + ); + + $this->assertTrue(true); // To generate coverage, see https://github.com/sebastianbergmann/phpunit/issues/3016 + } + + /** + * This test checks that the workarounds in place for https://github.com/mozilla/geckodriver/issues/653 work as + * expected where child links can be clicked. + * + * @covers ::click + * @covers ::clickChildElement + * @group exclude-chrome + * @group exclude-edge + */ + public function testGeckoDriverShouldClickOnBlockLevelElement(): void + { + self::skipForUnmatchedBrowsers(['firefox']); + + $links = [ + 'a-index-plain', + 'a-index-block-child', + 'a-index-block-child-hidden', + 'a-index-second-child-hidden', + ]; + + foreach ($links as $linkid) { + $this->driver->get($this->getTestPageUrl(TestPage::GECKO_653)); + $linkElement = $this->driver->findElement(WebDriverBy::id($linkid)); + + $linkElement->click(); + $this->assertStringContainsString('index.html', $this->driver->getCurrentUrl()); + } + } + + /** + * This test checks that the workarounds in place for https://github.com/mozilla/geckodriver/issues/653 work as + * expected where child links cannot be clicked, and that appropriate exceptions are thrown. + * + * @covers ::click + * @covers ::clickChildElement + * @group exclude-chrome + * @group exclude-edge + */ + public function testGeckoDriverShouldClickNotInteractable(): void + { + self::skipForUnmatchedBrowsers(['firefox']); + + $this->driver->get($this->getTestPageUrl(TestPage::GECKO_653)); + + $linkElement = $this->driver->findElement(WebDriverBy::id('a-index-plain-hidden')); + + try { + $linkElement->click(); + $this->fail('No exception was thrown when clicking an inaccessible link'); + } catch (ElementNotInteractableException $e) { + $this->assertInstanceOf(ElementNotInteractableException::class, $e); + } + + $linkElement = $this->driver->findElement(WebDriverBy::id('a-index-hidden-block-child')); + + try { + $linkElement->click(); + $this->fail('No exception was thrown when clicking an inaccessible link'); + } catch (ElementNotInteractableException $e) { + $this->assertInstanceOf(ElementNotInteractableException::class, $e); + } + } + + /** + * @covers ::clear + */ + public function testShouldClearFormElementText(): void + { + $this->driver->get($this->getTestPageUrl(TestPage::FORM)); + + $input = $this->driver->findElement(WebDriverBy::id('input-text')); + $textarea = $this->driver->findElement(WebDriverBy::id('textarea')); + + $this->assertSame('Default input text', $input->getAttribute('value')); + $input->clear(); + $this->assertSame('', $input->getAttribute('value')); + + $this->assertSame('Default textarea text', $textarea->getAttribute('value')); + $textarea->clear(); + $this->assertSame('', $textarea->getAttribute('value')); + } + + /** + * @covers ::sendKeys + */ + public function testShouldSendKeysToFormElement(): void + { + $this->driver->get($this->getTestPageUrl(TestPage::FORM)); + + $input = $this->driver->findElement(WebDriverBy::id('input-text')); + $textarea = $this->driver->findElement(WebDriverBy::id('textarea')); + + $input->clear(); + $input->sendKeys('foo bar'); + $this->assertSame('foo bar', $input->getAttribute('value')); + $input->sendKeys(' baz'); + $this->assertSame('foo bar baz', $input->getAttribute('value')); + + $input->clear(); + $input->sendKeys([WebDriverKeys::SHIFT, 'H', WebDriverKeys::NULL, 'ello']); + $this->assertSame('Hello', $input->getAttribute('value')); + + $textarea->clear(); + $textarea->sendKeys('foo bar'); + $this->assertSame('foo bar', $textarea->getAttribute('value')); + $textarea->sendKeys(' baz'); + $this->assertSame('foo bar baz', $textarea->getAttribute('value')); + + $textarea->clear(); + $textarea->sendKeys([WebDriverKeys::SHIFT, 'H', WebDriverKeys::NULL, 'ello']); + $this->assertSame('Hello', $textarea->getAttribute('value')); + + // Send keys as array + $textarea->clear(); + $textarea->sendKeys(['bat', 1, '3', ' ', 3, '7']); + $this->assertSame('bat13 37', $textarea->getAttribute('value')); + } + + /** + * @covers ::isDisplayed + * @covers \Facebook\WebDriver\Remote\RemoteWebDriver::execute + * @covers \Facebook\WebDriver\Support\IsElementDisplayedAtom + */ + public function testShouldDetectElementDisplayedness(): void + { + $this->driver->get($this->getTestPageUrl(TestPage::INDEX)); + + $visibleElement = $this->driver->findElement(WebDriverBy::cssSelector('.test_class')); + $elementOutOfViewport = $this->driver->findElement(WebDriverBy::id('element-out-of-viewport')); + $hiddenElement = $this->driver->findElement(WebDriverBy::id('hidden-element')); + + $this->assertTrue($visibleElement->isDisplayed()); + $this->assertTrue($elementOutOfViewport->isDisplayed()); + $this->assertFalse($hiddenElement->isDisplayed()); + } + + /** + * @covers ::isEnabled + */ + public function testShouldDetectEnabledInputs(): void + { + $this->driver->get($this->getTestPageUrl(TestPage::FORM)); + + $inputEnabled = $this->driver->findElement(WebDriverBy::id('input-text')); + $inputDisabled = $this->driver->findElement(WebDriverBy::id('input-text-disabled')); + + $this->assertTrue($inputEnabled->isEnabled()); + $this->assertFalse($inputDisabled->isEnabled()); + } + + /** + * @covers ::isSelected + */ + public function testShouldSelectedInputsOrOptions(): void + { + $this->driver->get($this->getTestPageUrl(TestPage::FORM)); + + $checkboxSelected = $this->driver->findElement( + WebDriverBy::cssSelector('input[name=checkbox][value=second]') + ); + $checkboxNotSelected = $this->driver->findElement( + WebDriverBy::cssSelector('input[name=checkbox][value=first]') + ); + $this->assertTrue($checkboxSelected->isSelected()); + $this->assertFalse($checkboxNotSelected->isSelected()); + + $radioSelected = $this->driver->findElement(WebDriverBy::cssSelector('input[name=radio][value=second]')); + $radioNotSelected = $this->driver->findElement(WebDriverBy::cssSelector('input[name=radio][value=first]')); + $this->assertTrue($radioSelected->isSelected()); + $this->assertFalse($radioNotSelected->isSelected()); + + $optionSelected = $this->driver->findElement(WebDriverBy::cssSelector('#select option[value=first]')); + $optionNotSelected = $this->driver->findElement(WebDriverBy::cssSelector('#select option[value=second]')); + $this->assertTrue($optionSelected->isSelected()); + $this->assertFalse($optionNotSelected->isSelected()); + } + + /** + * @covers ::submit + * @group exclude-edge + */ + public function testShouldSubmitFormBySubmitEventOnForm(): void + { + $this->driver->get($this->getTestPageUrl(TestPage::FORM)); + + $formElement = $this->driver->findElement(WebDriverBy::cssSelector('form')); + + $formElement->submit(); + + $this->driver->wait()->until( + WebDriverExpectedCondition::titleIs('Form submit endpoint') + ); + + $this->assertSame('Received POST data', $this->driver->findElement(WebDriverBy::cssSelector('h2'))->getText()); + } + + /** + * @covers ::submit + */ + public function testShouldSubmitFormBySubmitEventOnFormInputElement(): void + { + $this->driver->get($this->getTestPageUrl(TestPage::FORM)); + + $inputTextElement = $this->driver->findElement(WebDriverBy::id('input-text')); + + $inputTextElement->submit(); + + $this->driver->wait()->until( + WebDriverExpectedCondition::titleIs('Form submit endpoint') + ); + + $this->assertSame('Received POST data', $this->driver->findElement(WebDriverBy::cssSelector('h2'))->getText()); + } + + /** + * @covers ::click + */ + public function testShouldSubmitFormByClickOnSubmitInput(): void + { + $this->driver->get($this->getTestPageUrl(TestPage::FORM)); + + $submitElement = $this->driver->findElement(WebDriverBy::id('submit')); + + $submitElement->click(); + + $this->driver->wait()->until( + WebDriverExpectedCondition::titleIs('Form submit endpoint') + ); + + $this->assertSame('Received POST data', $this->driver->findElement(WebDriverBy::cssSelector('h2'))->getText()); + } + + /** + * @covers ::equals + */ + public function testShouldCompareEqualsElement(): void + { + $this->driver->get($this->getTestPageUrl(TestPage::INDEX)); + + $firstElement = $this->driver->findElement(WebDriverBy::cssSelector('ul.list')); + $differentElement = $this->driver->findElement(WebDriverBy::cssSelector('#text-simple')); + $againTheFirstElement = $this->driver->findElement(WebDriverBy::cssSelector('ul.list')); + + $this->assertTrue($firstElement->equals($againTheFirstElement)); + $this->assertTrue($againTheFirstElement->equals($firstElement)); + + $this->assertFalse($differentElement->equals($firstElement)); + $this->assertFalse($firstElement->equals($differentElement)); + $this->assertFalse($differentElement->equals($againTheFirstElement)); + } + + /** + * @covers ::findElement + */ + public function testShouldThrowExceptionIfChildElementCannotBeFound(): void + { + $this->driver->get($this->getTestPageUrl(TestPage::INDEX)); + $element = $this->driver->findElement(WebDriverBy::cssSelector('ul.list')); + + $this->expectException(NoSuchElementException::class); + $element->findElement(WebDriverBy::id('not_existing')); + } + + public function testShouldFindChildElementIfExistsOnAPage(): void + { + $this->driver->get($this->getTestPageUrl(TestPage::INDEX)); + $element = $this->driver->findElement(WebDriverBy::cssSelector('ul.list')); + + $childElement = $element->findElement(WebDriverBy::cssSelector('li')); + + $this->assertInstanceOf(RemoteWebElement::class, $childElement); + $this->assertSame('li', $childElement->getTagName()); + $this->assertSame('First', $childElement->getText()); + } + + public function testShouldReturnEmptyArrayIfChildElementsCannotBeFound(): void + { + $this->driver->get($this->getTestPageUrl(TestPage::INDEX)); + $element = $this->driver->findElement(WebDriverBy::cssSelector('ul.list')); + + $childElements = $element->findElements(WebDriverBy::cssSelector('not_existing')); + + $this->assertIsArray($childElements); + $this->assertCount(0, $childElements); + } + + public function testShouldFindMultipleChildElements(): void + { + $this->driver->get($this->getTestPageUrl(TestPage::INDEX)); + $element = $this->driver->findElement(WebDriverBy::cssSelector('ul.list')); + + $allElements = $this->driver->findElements(WebDriverBy::cssSelector('li')); + $childElements = $element->findElements(WebDriverBy::cssSelector('li')); + + $this->assertIsArray($childElements); + $this->assertCount(5, $allElements); // there should be 5
  • elements on page + $this->assertCount(3, $childElements); // but we should find only subelements of one
      + $this->assertContainsOnlyInstancesOf(RemoteWebElement::class, $childElements); + } + + /** + * @covers ::takeElementScreenshot + * @covers \Facebook\WebDriver\Support\ScreenshotHelper + * @group exclude-saucelabs + */ + public function testShouldTakeAndSaveElementScreenshot(): void + { + self::skipForJsonWireProtocol('Take element screenshot is only part of W3C protocol'); + + if (!extension_loaded('gd')) { + $this->markTestSkipped('GD extension must be enabled'); + } + + // When running this test on real devices, it has a retina display so 5px will be converted into 10px + $isCi = (new CiDetector())->isCiDetected(); + $isSafari = getenv('BROWSER_NAME') === 'safari'; + + $screenshotPath = sys_get_temp_dir() . '/' . uniqid('php-webdriver-') . '/element-screenshot.png'; + + $this->driver->get($this->getTestPageUrl(TestPage::INDEX)); + + $element = $this->driver->findElement(WebDriverBy::id('red-box')); + + $outputPngString = $element->takeElementScreenshot($screenshotPath); + + // Assert file output + $imageFromFile = imagecreatefrompng($screenshotPath); + + if ($isSafari && !$isCi) { + $this->assertEquals(10, imagesx($imageFromFile)); + $this->assertEquals(10, imagesy($imageFromFile)); + } else { + $this->assertEquals(5, imagesx($imageFromFile)); + $this->assertEquals(5, imagesy($imageFromFile)); + } + + // Validate element is actually red + $this->assertSame( + ['red' => 255, 'green' => 0, 'blue' => 0, 'alpha' => 0], + imagecolorsforindex($imageFromFile, imagecolorat($imageFromFile, 0, 0)) + ); + + // Assert string output + $imageFromString = imagecreatefromstring($outputPngString); + if (version_compare(phpversion(), '8.0.0', '>=')) { + $this->assertInstanceOf(\GdImage::class, $imageFromString); + } else { + $this->assertTrue(is_resource($imageFromString)); + } + + if ($isSafari && !$isCi) { + $this->assertEquals(10, imagesx($imageFromString)); + $this->assertEquals(10, imagesy($imageFromString)); + } else { + $this->assertEquals(5, imagesx($imageFromString)); + $this->assertEquals(5, imagesy($imageFromString)); + } + + // Validate element is actually red + $this->assertSame( + ['red' => 255, 'green' => 0, 'blue' => 0, 'alpha' => 0], + imagecolorsforindex($imageFromString, imagecolorat($imageFromString, 0, 0)) + ); + + unlink($screenshotPath); + rmdir(dirname($screenshotPath)); + } +} diff --git a/tests/functional/ReportSauceLabsStatusListener.php b/tests/functional/ReportSauceLabsStatusListener.php new file mode 100644 index 000000000..1a9077807 --- /dev/null +++ b/tests/functional/ReportSauceLabsStatusListener.php @@ -0,0 +1,110 @@ +driver instanceof RemoteWebDriver) { + return; + } + + /** @var WebDriverTestCase $test */ + if (!$test->isSauceLabsBuild()) { + return; + } + + $testStatus = $test->getStatus(); + + if ($this->testWasSkippedOrIncomplete($testStatus)) { + return; + } + + $endpointUrl = sprintf( + 'https://saucelabs.com/rest/v1/%s/jobs/%s', + getenv('SAUCE_USERNAME'), + $test->driver->getSessionID() + ); + + $data = [ + 'passed' => ($testStatus === \PHPUnit\Runner\BaseTestRunner::STATUS_PASSED), + 'custom-data' => ['message' => $test->getStatusMessage()], + ]; + + $this->submitToSauceLabs($endpointUrl, $data); + } + + public function addError(\PHPUnit\Framework\Test $test, Throwable $t, float $time): void + { + } + + public function addWarning(\PHPUnit\Framework\Test $test, \PHPUnit\Framework\Warning $w, float $time): void + { + } + + public function addFailure( + \PHPUnit\Framework\Test $test, + \PHPUnit\Framework\AssertionFailedError $e, + float $time + ): void { + } + + public function addIncompleteTest(\PHPUnit\Framework\Test $test, Throwable $t, float $time): void + { + } + + public function addRiskyTest(\PHPUnit\Framework\Test $test, Throwable $t, float $time): void + { + } + + public function addSkippedTest(\PHPUnit\Framework\Test $test, Throwable $t, float $time): void + { + } + + public function startTestSuite(\PHPUnit\Framework\TestSuite $suite): void + { + } + + public function endTestSuite(\PHPUnit\Framework\TestSuite $suite): void + { + } + + public function startTest(\PHPUnit\Framework\Test $test): void + { + } + + private function testWasSkippedOrIncomplete(int $testStatus): bool + { + if ($testStatus === \PHPUnit\Runner\BaseTestRunner::STATUS_SKIPPED + || $testStatus === \PHPUnit\Runner\BaseTestRunner::STATUS_INCOMPLETE) { + return true; + } + + return false; + } + + private function submitToSauceLabs(string $url, array $data): void + { + $curl = curl_init($url); + curl_setopt($curl, CURLOPT_CUSTOMREQUEST, 'PUT'); + curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); + curl_setopt($curl, CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + curl_setopt($curl, CURLOPT_USERPWD, getenv('SAUCE_USERNAME') . ':' . getenv('SAUCE_ACCESS_KEY')); + curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($data, JSON_THROW_ON_ERROR)); + // Disable sending 'Expect: 100-Continue' header, as it is causing issues with eg. squid proxy + curl_setopt($curl, CURLOPT_HTTPHEADER, ['Expect:']); + + curl_exec($curl); + + if (curl_errno($curl)) { + throw new \Exception(sprintf('Error publishing test results to SauceLabs: %s', curl_error($curl))); + } + + curl_close($curl); + } +} diff --git a/tests/functional/RetrieveEventsTrait.php b/tests/functional/RetrieveEventsTrait.php new file mode 100644 index 000000000..addaf1c47 --- /dev/null +++ b/tests/functional/RetrieveEventsTrait.php @@ -0,0 +1,33 @@ +retrieveLoggerEvents(WebDriverBy::id('keyboardEventsLog')); + } + + private function retrieveLoggedMouseEvents(): array + { + return $this->retrieveLoggerEvents(WebDriverBy::id('mouseEventsLog')); + } + + /** + * @return false|string[] + */ + private function retrieveLoggerEvents(WebDriverBy $by) + { + $logElement = $this->driver->findElement($by); + + $text = trim($logElement->getText()); + + return array_map('trim', explode("\n", $text)); + } +} diff --git a/tests/functional/ShadowDomTest.php b/tests/functional/ShadowDomTest.php new file mode 100644 index 000000000..dc95eaa84 --- /dev/null +++ b/tests/functional/ShadowDomTest.php @@ -0,0 +1,75 @@ +driver->get($this->getTestPageUrl(TestPage::WEB_COMPONENTS)); + + $element = $this->driver->findElement(WebDriverBy::cssSelector('custom-checkbox-element')); + + $shadowRoot = $element->getShadowRoot(); + + $this->assertInstanceOf(ShadowRoot::class, $shadowRoot); + } + + public function testShouldThrowExceptionWhenGettingShadowRootWithElementNotHavingShadowRoot(): void + { + $this->driver->get($this->getTestPageUrl(TestPage::WEB_COMPONENTS)); + + $element = $this->driver->findElement(WebDriverBy::cssSelector('#no-shadow-root')); + + $this->expectException(NoSuchShadowRootException::class); + $element->getShadowRoot(); + } + + public function testShouldFindElementUnderShadowRoot(): void + { + $this->driver->get($this->getTestPageUrl(TestPage::WEB_COMPONENTS)); + + $element = $this->driver->findElement(WebDriverBy::cssSelector('custom-checkbox-element')); + + $shadowRoot = $element->getShadowRoot(); + + $elementInShadow = $shadowRoot->findElement(WebDriverBy::cssSelector('input')); + $this->assertSame('checkbox', $elementInShadow->getAttribute('type')); + + $elementsInShadow = $shadowRoot->findElements(WebDriverBy::cssSelector('div')); + $this->assertCount(2, $elementsInShadow); + } + + public function testShouldReferenceTheSameShadowRootAsFromExecuteScript(): void + { + $this->driver->get($this->getTestPageUrl(TestPage::WEB_COMPONENTS)); + + $element = $this->driver->findElement(WebDriverBy::cssSelector('custom-checkbox-element')); + + /** @var WebDriverElement $elementFromScript */ + $elementFromScript = $this->driver->executeScript( + 'return arguments[0].shadowRoot;', + [$element] + ); + + $shadowRoot = $element->getShadowRoot(); + + $this->assertSame($shadowRoot->getId(), reset($elementFromScript)); + } +} diff --git a/tests/functional/TestPage.php b/tests/functional/TestPage.php new file mode 100644 index 000000000..92629ed54 --- /dev/null +++ b/tests/functional/TestPage.php @@ -0,0 +1,25 @@ +driver->get($this->getTestPageUrl(TestPage::EVENTS)); + + $element = $this->driver->findElement(WebDriverBy::id('item-1')); + + $this->driver->action() + ->click($element) + ->perform(); + + $logs = ['mouseover item-1', 'mousedown item-1', 'mouseup item-1', 'click item-1']; + $loggedEvents = $this->retrieveLoggedMouseEvents(); + + if (getenv('GECKODRIVER') === '1') { + $loggedEvents = array_slice($loggedEvents, 0, count($logs)); + // Firefox sometimes triggers some extra events + // it's not related to Geckodriver, it's Firefox's own behavior + } + + $this->assertSame($logs, $loggedEvents); + } + + public function testShouldClickAndHoldOnElementAndRelease(): void + { + $this->driver->get($this->getTestPageUrl(TestPage::EVENTS)); + + $element = $this->driver->findElement(WebDriverBy::id('item-1')); + + $this->driver->action() + ->clickAndHold($element) + ->release() + ->perform(); + + if (self::isW3cProtocolBuild()) { + $this->assertContains('mouseover item-1', $this->retrieveLoggedMouseEvents()); + $this->assertContains('mousedown item-1', $this->retrieveLoggedMouseEvents()); + } else { + $this->assertSame( + ['mouseover item-1', 'mousedown item-1', 'mouseup item-1', 'click item-1'], + $this->retrieveLoggedMouseEvents() + ); + } + } + + /** + * @group exclude-saucelabs + */ + public function testShouldContextClickOnElement(): void + { + if ($this->desiredCapabilities->getBrowserName() === WebDriverBrowserType::MICROSOFT_EDGE) { + $this->markTestSkipped('Getting stuck in EdgeDriver'); + } + + $this->driver->get($this->getTestPageUrl(TestPage::EVENTS)); + + $element = $this->driver->findElement(WebDriverBy::id('item-2')); + + $this->driver->action() + ->contextClick($element) + ->perform(); + + $loggedEvents = $this->retrieveLoggedMouseEvents(); + + $this->assertContains('mousedown item-2', $loggedEvents); + $this->assertContains('mouseup item-2', $loggedEvents); + $this->assertContains('contextmenu item-2', $loggedEvents); + } + + /** + * @group exclude-safari + * https://github.com/webdriverio/webdriverio/issues/231 + */ + public function testShouldDoubleClickOnElement(): void + { + $this->driver->get($this->getTestPageUrl(TestPage::EVENTS)); + + $element = $this->driver->findElement(WebDriverBy::id('item-3')); + + $this->driver->action() + ->doubleClick($element) + ->perform(); + + $this->assertContains('dblclick item-3', $this->retrieveLoggedMouseEvents()); + } + + /** + * @group exclude-saucelabs + */ + public function testShouldSendKeysUpAndDown(): void + { + $this->driver->get($this->getTestPageUrl(TestPage::EVENTS)); + + $this->driver->action() + ->keyDown(null, WebDriverKeys::CONTROL) + ->keyUp(null, WebDriverKeys::CONTROL) + ->sendKeys(null, 'ab') + ->perform(); + + $events = $this->retrieveLoggedKeyboardEvents(); + + $this->assertEquals( + [ + 'keydown "Control"', + 'keyup "Control"', + 'keydown "a"', + 'keyup "a"', + 'keydown "b"', + 'keyup "b"', + ], + $events + ); + } + + /** + * @group exclude-safari + * https://developer.apple.com/forums/thread/662677 + */ + public function testShouldMoveToElement(): void + { + $this->driver->get($this->getTestPageUrl(TestPage::SORTABLE)); + + $item13 = $this->driver->findElement(WebDriverBy::id('item-1-3')); + $item24 = $this->driver->findElement(WebDriverBy::id('item-2-4')); + + $this->driver->action() + ->clickAndHold($item13) + ->moveToElement($item24) + ->release() + ->perform(); + + $this->assertSame( + [['1-1', '1-2', '1-4', '1-5'], ['2-1', '2-2', '2-3', '2-4', '1-3', '2-5']], + $this->retrieveListContent() + ); + } + + /** + * @group exclude-safari + * https://developer.apple.com/forums/thread/662677 + */ + public function testShouldMoveByOffset(): void + { + $this->driver->get($this->getTestPageUrl(TestPage::SORTABLE)); + + $item13 = $this->driver->findElement(WebDriverBy::id('item-1-3')); + + $this->driver->action() + ->clickAndHold($item13) + ->moveByOffset(100, 55) + ->release() + ->perform(); + + $this->assertSame( + [['1-1', '1-2', '1-4', '1-5'], ['2-1', '2-2', '2-3', '2-4', '1-3', '2-5']], + $this->retrieveListContent() + ); + } + + /** + * @group exclude-safari + * https://developer.apple.com/forums/thread/662677 + * @group exclude-saucelabs + */ + public function testShouldDragAndDrop(): void + { + $this->driver->get($this->getTestPageUrl(TestPage::SORTABLE)); + + $item13 = $this->driver->findElement(WebDriverBy::id('item-1-3')); + $item24 = $this->driver->findElement(WebDriverBy::id('item-2-4')); + + $this->driver->action() + ->dragAndDrop($item13, $item24) + ->perform(); + + $this->assertSame( + [['1-1', '1-2', '1-4', '1-5'], ['2-1', '2-2', '2-3', '2-4', '1-3', '2-5']], + $this->retrieveListContent() + ); + + $item21 = $this->driver->findElement(WebDriverBy::id('item-2-1')); + + $this->driver->action() + ->dragAndDrop($item24, $item21) + ->perform(); + + $this->assertSame( + [['1-1', '1-2', '1-4', '1-5'], ['2-4', '2-1', '2-2', '2-3', '1-3', '2-5']], + $this->retrieveListContent() + ); + } + + /** + * @group exclude-safari + * https://developer.apple.com/forums/thread/662677 + * it does not work even with Python Selenium, looks like Safaridriver does not implements Interaction API + */ + public function testShouldDragAndDropBy(): void + { + $this->driver->get($this->getTestPageUrl(TestPage::SORTABLE)); + + $item13 = $this->driver->findElement(WebDriverBy::id('item-1-3')); + + $this->driver->action() + ->dragAndDropBy($item13, 100, 55) + ->perform(); + + $this->assertSame( + [['1-1', '1-2', '1-4', '1-5'], ['2-1', '2-2', '2-3', '2-4', '1-3', '2-5']], + $this->retrieveListContent() + ); + + $item25 = $this->driver->findElement(WebDriverBy::id('item-2-5')); + $item22 = $this->driver->findElement(WebDriverBy::id('item-2-2')); + + $this->driver->action() + ->dragAndDropBy($item25, 0, -130) + ->dragAndDropBy($item22, -100, -35) + ->perform(); + + $this->assertSame( + [['1-1', '2-2', '1-2', '1-4', '1-5'], ['2-1', '2-5', '2-3', '2-4', '1-3']], + $this->retrieveListContent() + ); + } + + private function retrieveListContent(): array + { + return [ + $this->retrieveLoggerEvents(WebDriverBy::cssSelector('#sortable1')), + $this->retrieveLoggerEvents(WebDriverBy::cssSelector('#sortable2')), + ]; + } +} diff --git a/tests/functional/WebDriverAlertTest.php b/tests/functional/WebDriverAlertTest.php new file mode 100644 index 000000000..0d2e92a44 --- /dev/null +++ b/tests/functional/WebDriverAlertTest.php @@ -0,0 +1,86 @@ +driver->get($this->getTestPageUrl(TestPage::ALERT)); + } + + public function testShouldAcceptAlert(): void + { + // Open alert (it is delayed for 1 second, to make sure following wait for alertIsPresent works properly) + $this->driver->findElement(WebDriverBy::id('open-alert-delayed'))->click(); + + // Wait until present + $this->driver->wait()->until(WebDriverExpectedCondition::alertIsPresent()); + + $this->assertSame('This is alert', $this->driver->switchTo()->alert()->getText()); + + $this->driver->switchTo()->alert()->accept(); + + if (self::isW3cProtocolBuild()) { + $this->expectException(NoSuchAlertException::class); + } else { + $this->expectException(NoAlertOpenException::class); + } + + $this->driver->switchTo()->alert()->accept(); + } + + public function testShouldAcceptAndDismissConfirmation(): void + { + // Open confirmation + $this->driver->findElement(WebDriverBy::id('open-confirm'))->click(); + + // Wait until present + $this->driver->wait()->until(WebDriverExpectedCondition::alertIsPresent()); + + $this->assertSame('Do you confirm?', $this->driver->switchTo()->alert()->getText()); + + // Test accepting + $this->driver->switchTo()->alert()->accept(); + $this->assertSame('accepted', $this->getResultText()); + + // Open confirmation + $this->driver->findElement(WebDriverBy::id('open-confirm'))->click(); + + // Test dismissal + $this->driver->switchTo()->alert()->dismiss(); + $this->assertSame('dismissed', $this->getResultText()); + } + + public function testShouldSubmitPromptText(): void + { + // Open confirmation + $this->driver->findElement(WebDriverBy::id('open-prompt'))->click(); + + // Wait until present + $this->driver->wait()->until(WebDriverExpectedCondition::alertIsPresent()); + + $this->assertSame('Enter prompt value', $this->driver->switchTo()->alert()->getText()); + + $this->driver->switchTo()->alert()->sendKeys('Text entered to prompt'); + $this->driver->switchTo()->alert()->accept(); + + $this->assertSame('Text entered to prompt', $this->getResultText()); + } + + private function getResultText(): string + { + return $this->driver + ->findElement(WebDriverBy::id('result')) + ->getText(); + } +} diff --git a/tests/functional/WebDriverByTest.php b/tests/functional/WebDriverByTest.php new file mode 100644 index 000000000..2aadf8de9 --- /dev/null +++ b/tests/functional/WebDriverByTest.php @@ -0,0 +1,54 @@ +driver->get($this->getTestPageUrl(TestPage::INDEX)); + + $by = call_user_func([WebDriverBy::class, $webDriverByLocatorMethod], $webDriverByLocatorValue); + $element = $this->driver->findElement($by); + + $this->assertInstanceOf(RemoteWebElement::class, $element); + + if ($expectedText !== null) { + $this->assertEquals($expectedText, $element->getText()); + } + + if ($expectedAttributeValue !== null) { + $this->assertEquals($expectedAttributeValue, $element->getAttribute('value')); + } + } + + /** + * @return array[] + */ + public function provideTextElements(): array + { + return [ + 'id' => ['id', 'id_test', 'Test by ID'], + 'className' => ['className', 'test_class', 'Test by Class'], + 'cssSelector' => ['cssSelector', '.test_class', 'Test by Class'], + 'linkText' => ['linkText', 'Click here', 'Click here'], + 'partialLinkText' => ['partialLinkText', 'Click', 'Click here'], + 'xpath' => ['xpath', '//input[@name="test_name"]', '', 'Test Value'], + 'name' => ['name', 'test_name', '', 'Test Value'], + 'tagName' => ['tagName', 'input', '', 'Test Value'], + ]; + } +} diff --git a/tests/functional/WebDriverCheckboxesTest.php b/tests/functional/WebDriverCheckboxesTest.php new file mode 100644 index 000000000..64372a4c8 --- /dev/null +++ b/tests/functional/WebDriverCheckboxesTest.php @@ -0,0 +1,235 @@ +driver->get($this->getTestPageUrl(TestPage::FORM_CHECKBOX_RADIO)); + } + + public function testIsMultiple(): void + { + $checkboxes = new WebDriverCheckboxes( + $this->driver->findElement(WebDriverBy::xpath('//input[@type="checkbox"]')) + ); + + $this->assertTrue($checkboxes->isMultiple()); + } + + public function testGetOptions(): void + { + $checkboxes = new WebDriverCheckboxes( + $this->driver->findElement(WebDriverBy::xpath('//form[2]//input[@type="checkbox"]')) + ); + + $this->assertNotEmpty($checkboxes->getOptions()); + } + + public function testGetFirstSelectedOption(): void + { + $checkboxes = new WebDriverCheckboxes( + $this->driver->findElement(WebDriverBy::xpath('//input[@type="checkbox"]')) + ); + + $checkboxes->selectByValue('j2a'); + + $this->assertSame('j2a', $checkboxes->getFirstSelectedOption()->getAttribute('value')); + } + + public function testShouldGetFirstSelectedOptionConsideringOnlyElementsAssociatedWithCurrentForm(): void + { + $checkboxes = new WebDriverCheckboxes( + $this->driver->findElement(WebDriverBy::xpath('//input[@id="j5b"]')) + ); + + $this->assertEquals('j5b', $checkboxes->getFirstSelectedOption()->getAttribute('value')); + } + + public function testShouldGetFirstSelectedOptionConsideringOnlyElementsAssociatedWithCurrentFormWithoutId(): void + { + $checkboxes = new WebDriverCheckboxes( + $this->driver->findElement(WebDriverBy::xpath('//input[@id="j5d"]')) + ); + + $this->assertEquals('j5c', $checkboxes->getFirstSelectedOption()->getAttribute('value')); + } + + public function testSelectByValue(): void + { + $selectedOptions = ['j2b', 'j2c']; + + $checkboxes = new WebDriverCheckboxes( + $this->driver->findElement(WebDriverBy::xpath('//input[@type="checkbox"]')) + ); + foreach ($selectedOptions as $index => $selectedOption) { + $checkboxes->selectByValue($selectedOption); + } + + $selectedValues = []; + foreach ($checkboxes->getAllSelectedOptions() as $option) { + $selectedValues[] = $option->getAttribute('value'); + } + $this->assertSame($selectedOptions, $selectedValues); + } + + public function testSelectByValueInvalid(): void + { + $checkboxes = new WebDriverCheckboxes( + $this->driver->findElement(WebDriverBy::xpath('//input[@type="checkbox"]')) + ); + + $this->expectException(NoSuchElementException::class); + $this->expectExceptionMessage('Cannot locate checkbox with value: notexist'); + $checkboxes->selectByValue('notexist'); + } + + public function testSelectByIndex(): void + { + $selectedOptions = [1 => 'j2b', 2 => 'j2c']; + + $checkboxes = new WebDriverCheckboxes( + $this->driver->findElement(WebDriverBy::xpath('//input[@type="checkbox"]')) + ); + foreach ($selectedOptions as $index => $selectedOption) { + $checkboxes->selectByIndex($index); + } + + $selectedValues = []; + foreach ($checkboxes->getAllSelectedOptions() as $option) { + $selectedValues[] = $option->getAttribute('value'); + } + $this->assertSame(array_values($selectedOptions), $selectedValues); + } + + public function testSelectByIndexInvalid(): void + { + $checkboxes = new WebDriverCheckboxes( + $this->driver->findElement(WebDriverBy::xpath('//input[@type="checkbox"]')) + ); + + $this->expectException(NoSuchElementException::class); + $this->expectExceptionMessage('Cannot locate checkbox with index: ' . PHP_INT_MAX); + $checkboxes->selectByIndex(PHP_INT_MAX); + } + + /** + * @dataProvider provideSelectByVisibleTextData + */ + public function testSelectByVisibleText(string $text, string $value): void + { + $checkboxes = new WebDriverCheckboxes( + $this->driver->findElement(WebDriverBy::xpath('//input[@type="checkbox"]')) + ); + + $checkboxes->selectByVisibleText($text); + + $this->assertSame($value, $checkboxes->getFirstSelectedOption()->getAttribute('value')); + } + + /** + * @return array[] + */ + public function provideSelectByVisibleTextData(): array + { + return [ + ['J 2 B', 'j2b'], + ['J2C', 'j2c'], + ]; + } + + /** + * @dataProvider provideSelectByVisiblePartialTextData + */ + public function testSelectByVisiblePartialText(string $text, string $value): void + { + $checkboxes = new WebDriverCheckboxes( + $this->driver->findElement(WebDriverBy::xpath('//input[@type="checkbox"]')) + ); + + $checkboxes->selectByVisiblePartialText($text); + + $this->assertSame($value, $checkboxes->getFirstSelectedOption()->getAttribute('value')); + } + + /** + * @return array[] + */ + public function provideSelectByVisiblePartialTextData(): array + { + return [ + ['2 B', 'j2b'], + ['2C', 'j2c'], + ]; + } + + public function testDeselectAll(): void + { + $checkboxes = new WebDriverCheckboxes( + $this->driver->findElement(WebDriverBy::xpath('//input[@type="checkbox"]')) + ); + + $checkboxes->selectByIndex(0); + $this->assertCount(1, $checkboxes->getAllSelectedOptions()); + $checkboxes->deselectAll(); + $this->assertEmpty($checkboxes->getAllSelectedOptions()); + } + + public function testDeselectByIndex(): void + { + $checkboxes = new WebDriverCheckboxes( + $this->driver->findElement(WebDriverBy::xpath('//input[@type="checkbox"]')) + ); + + $checkboxes->selectByIndex(0); + $this->assertCount(1, $checkboxes->getAllSelectedOptions()); + $checkboxes->deselectByIndex(0); + $this->assertEmpty($checkboxes->getAllSelectedOptions()); + } + + public function testDeselectByValue(): void + { + $checkboxes = new WebDriverCheckboxes( + $this->driver->findElement(WebDriverBy::xpath('//input[@type="checkbox"]')) + ); + + $checkboxes->selectByValue('j2a'); + $this->assertCount(1, $checkboxes->getAllSelectedOptions()); + $checkboxes->deselectByValue('j2a'); + $this->assertEmpty($checkboxes->getAllSelectedOptions()); + } + + public function testDeselectByVisibleText(): void + { + $checkboxes = new WebDriverCheckboxes( + $this->driver->findElement(WebDriverBy::xpath('//input[@type="checkbox"]')) + ); + + $checkboxes->selectByVisibleText('J 2 B'); + $this->assertCount(1, $checkboxes->getAllSelectedOptions()); + $checkboxes->deselectByVisibleText('J 2 B'); + $this->assertEmpty($checkboxes->getAllSelectedOptions()); + } + + public function testDeselectByVisiblePartialText(): void + { + $checkboxes = new WebDriverCheckboxes( + $this->driver->findElement(WebDriverBy::xpath('//input[@type="checkbox"]')) + ); + + $checkboxes->selectByVisiblePartialText('2C'); + $this->assertCount(1, $checkboxes->getAllSelectedOptions()); + $checkboxes->deselectByVisiblePartialText('2C'); + $this->assertEmpty($checkboxes->getAllSelectedOptions()); + } +} diff --git a/tests/functional/WebDriverNavigationTest.php b/tests/functional/WebDriverNavigationTest.php new file mode 100644 index 000000000..400ef86f0 --- /dev/null +++ b/tests/functional/WebDriverNavigationTest.php @@ -0,0 +1,74 @@ +driver->navigate()->to($this->getTestPageUrl(TestPage::INDEX)); + + $this->assertStringEndsWith('/index.html', $this->driver->getCurrentURL()); + } + + /** + * @covers ::back + * @covers ::forward + */ + public function testShouldNavigateBackAndForward(): void + { + $this->driver->get($this->getTestPageUrl(TestPage::INDEX)); + $linkElement = $this->driver->findElement(WebDriverBy::id('a-form')); + + $linkElement->click(); + + $this->driver->wait()->until( + WebDriverExpectedCondition::urlContains(TestPage::FORM) + ); + + $this->driver->navigate()->back(); + + $this->driver->wait()->until( + WebDriverExpectedCondition::urlContains(TestPage::INDEX) + ); + + $this->driver->navigate()->forward(); + + $this->driver->wait()->until( + WebDriverExpectedCondition::urlContains(TestPage::FORM) + ); + + $this->assertTrue(true); // To generate coverage, see https://github.com/sebastianbergmann/phpunit/issues/3016 + } + + /** + * @covers ::refresh + */ + public function testShouldRefreshPage(): void + { + $this->driver->get($this->getTestPageUrl(TestPage::INDEX)); + + // Change input element content, to make sure it was refreshed (=> cleared to original value) + $inputElement = $this->driver->findElement(WebDriverBy::name('test_name')); + $inputElementOriginalValue = $inputElement->getAttribute('value'); + $inputElement->clear()->sendKeys('New value'); + $this->assertSame('New value', $inputElement->getAttribute('value')); + + $this->driver->navigate()->refresh(); + + $this->driver->wait()->until( + WebDriverExpectedCondition::stalenessOf($inputElement) + ); + + $inputElementAfterRefresh = $this->driver->findElement(WebDriverBy::name('test_name')); + + $this->assertSame($inputElementOriginalValue, $inputElementAfterRefresh->getAttribute('value')); + } +} diff --git a/tests/functional/WebDriverOptionsCookiesTest.php b/tests/functional/WebDriverOptionsCookiesTest.php new file mode 100644 index 000000000..a71ebafc0 --- /dev/null +++ b/tests/functional/WebDriverOptionsCookiesTest.php @@ -0,0 +1,94 @@ +driver->get($this->getTestPageUrl(TestPage::INDEX)); + } + + public function testShouldSetGetAndDeleteCookies(): void + { + $cookie1 = new Cookie('cookie1', 'cookie1Value'); + $cookie2 = new Cookie('cookie2', 'cookie2Value'); + + // Verify initial state - no cookies are present + $this->assertSame([], $this->driver->manage()->getCookies()); + + // Add cookie1 + $this->driver->manage()->addCookie($cookie1); + + // get all cookies + $cookiesWithOneCookie = $this->driver->manage()->getCookies(); + $this->assertCount(1, $cookiesWithOneCookie); + $this->assertContainsOnlyInstancesOf(Cookie::class, $cookiesWithOneCookie); + $this->assertSame('cookie1', $cookiesWithOneCookie[0]->getName()); + $this->assertSame('cookie1Value', $cookiesWithOneCookie[0]->getValue()); + $this->assertSame('/', $cookiesWithOneCookie[0]->getPath()); + $this->assertSame('localhost', $cookiesWithOneCookie[0]->getDomain()); + + // Add cookie2 + $this->driver->manage()->addCookie($cookie2); + + // get all cookies + $cookiesWithTwoCookies = $this->driver->manage()->getCookies(); + + $this->assertCount(2, $cookiesWithTwoCookies); + $this->assertContainsOnlyInstancesOf(Cookie::class, $cookiesWithTwoCookies); + + // normalize received cookies (their order is arbitrary) + $normalizedCookies = [ + $cookiesWithTwoCookies[0]->getName() => $cookiesWithTwoCookies[0]->getValue(), + $cookiesWithTwoCookies[1]->getName() => $cookiesWithTwoCookies[1]->getValue(), + ]; + ksort($normalizedCookies); + $this->assertSame(['cookie1' => 'cookie1Value', 'cookie2' => 'cookie2Value'], $normalizedCookies); + + // getCookieNamed() + $onlyCookieOne = $this->driver->manage()->getCookieNamed('cookie1'); + $this->assertInstanceOf(Cookie::class, $onlyCookieOne); + $this->assertSame('cookie1', $onlyCookieOne->getName()); + $this->assertSame('cookie1Value', $onlyCookieOne->getValue()); + + // deleteCookieNamed() + $this->driver->manage()->deleteCookieNamed('cookie1'); + $cookiesWithOnlySecondCookie = $this->driver->manage()->getCookies(); + $this->assertCount(1, $cookiesWithOnlySecondCookie); + $this->assertSame('cookie2', $cookiesWithOnlySecondCookie[0]->getName()); + + // getting non-existent cookie should throw an exception in W3C mode but return null in JsonWire mode + if (self::isW3cProtocolBuild()) { + try { + $noSuchCookieExceptionThrown = false; + $this->driver->manage()->getCookieNamed('cookie1'); + } catch (NoSuchCookieException $e) { + $noSuchCookieExceptionThrown = true; + } finally { + $this->assertTrue($noSuchCookieExceptionThrown, 'NoSuchCookieException was not thrown'); + } + } else { + $this->assertNull($this->driver->manage()->getCookieNamed('cookie1')); + } + + // deleting non-existent cookie shod not throw an error + $this->driver->manage()->deleteCookieNamed('cookie1'); + + // Add cookie3 + $this->driver->manage()->addCookie($cookie1); + $this->assertCount(2, $this->driver->manage()->getCookies()); + + // Delete all cookies + $this->driver->manage()->deleteAllCookies(); + + $this->assertSame([], $this->driver->manage()->getCookies()); + } +} diff --git a/tests/functional/WebDriverRadiosTest.php b/tests/functional/WebDriverRadiosTest.php new file mode 100644 index 000000000..f35d37a7c --- /dev/null +++ b/tests/functional/WebDriverRadiosTest.php @@ -0,0 +1,190 @@ +driver->get($this->getTestPageUrl(TestPage::FORM_CHECKBOX_RADIO)); + } + + public function testIsMultiple(): void + { + $radios = new WebDriverRadios($this->driver->findElement(WebDriverBy::xpath('//input[@type="radio"]'))); + + $this->assertFalse($radios->isMultiple()); + } + + public function testGetOptions(): void + { + $radios = new WebDriverRadios($this->driver->findElement(WebDriverBy::xpath('//input[@type="radio"]'))); + $values = []; + foreach ($radios->getOptions() as $option) { + $values[] = $option->getAttribute('value'); + } + + $this->assertSame(['j3a', 'j3b', 'j3c'], $values); + } + + public function testGetFirstSelectedOption(): void + { + $radios = new WebDriverRadios($this->driver->findElement(WebDriverBy::xpath('//input[@type="radio"]'))); + + $radios->selectByValue('j3a'); + + $this->assertSame('j3a', $radios->getFirstSelectedOption()->getAttribute('value')); + } + + public function testShouldGetFirstSelectedOptionConsideringOnlyElementsAssociatedWithCurrentForm(): void + { + $radios = new WebDriverRadios($this->driver->findElement(WebDriverBy::xpath('//input[@id="j4b"]'))); + + $this->assertEquals('j4b', $radios->getFirstSelectedOption()->getAttribute('value')); + } + + public function testShouldGetFirstSelectedOptionConsideringOnlyElementsAssociatedWithCurrentFormWithoutId(): void + { + $radios = new WebDriverRadios( + $this->driver->findElement(WebDriverBy::xpath('//input[@id="j4c"]')) + ); + + $this->assertEquals('j4c', $radios->getFirstSelectedOption()->getAttribute('value')); + } + + public function testSelectByValue(): void + { + $radios = new WebDriverRadios($this->driver->findElement(WebDriverBy::xpath('//input[@type="radio"]'))); + $radios->selectByValue('j3b'); + + $selectedOptions = $radios->getAllSelectedOptions(); + + $this->assertCount(1, $selectedOptions); + $this->assertSame('j3b', $selectedOptions[0]->getAttribute('value')); + } + + public function testSelectByValueInvalid(): void + { + $radios = new WebDriverRadios($this->driver->findElement(WebDriverBy::xpath('//input[@type="radio"]'))); + + $this->expectException(NoSuchElementException::class); + $this->expectExceptionMessage('Cannot locate radio with value: notexist'); + $radios->selectByValue('notexist'); + } + + public function testSelectByIndex(): void + { + $radios = new WebDriverRadios($this->driver->findElement(WebDriverBy::xpath('//input[@type="radio"]'))); + $radios->selectByIndex(1); + + $allSelectedOptions = $radios->getAllSelectedOptions(); + $this->assertCount(1, $allSelectedOptions); + $this->assertSame('j3b', $allSelectedOptions[0]->getAttribute('value')); + } + + public function testSelectByIndexInvalid(): void + { + $radios = new WebDriverRadios($this->driver->findElement(WebDriverBy::xpath('//input[@type="radio"]'))); + + $this->expectException(NoSuchElementException::class); + $this->expectExceptionMessage('Cannot locate radio with index: ' . PHP_INT_MAX); + $radios->selectByIndex(PHP_INT_MAX); + } + + /** + * @dataProvider provideSelectByVisibleTextData + */ + public function testSelectByVisibleText(string $text, string $value): void + { + $radios = new WebDriverRadios($this->driver->findElement(WebDriverBy::xpath('//input[@type="radio"]'))); + $radios->selectByVisibleText($text); + $this->assertSame($value, $radios->getFirstSelectedOption()->getAttribute('value')); + } + + /** + * @return array[] + */ + public function provideSelectByVisibleTextData(): array + { + return [ + ['J 3 B', 'j3b'], + ['J3C', 'j3c'], + ]; + } + + /** + * @dataProvider provideSelectByVisiblePartialTextData + */ + public function testSelectByVisiblePartialText(string $text, string $value): void + { + $radios = new WebDriverRadios($this->driver->findElement(WebDriverBy::xpath('//input[@type="radio"]'))); + $radios->selectByVisiblePartialText($text); + $this->assertSame($value, $radios->getFirstSelectedOption()->getAttribute('value')); + } + + /** + * @return array[] + */ + public function provideSelectByVisiblePartialTextData(): array + { + return [ + ['3 B', 'j3b'], + ['3C', 'j3c'], + ]; + } + + public function testDeselectAllRadio(): void + { + $radios = new WebDriverRadios($this->driver->findElement(WebDriverBy::xpath('//input[@type="radio"]'))); + + $this->expectException(UnsupportedOperationException::class); + $this->expectExceptionMessage('You cannot deselect radio buttons'); + $radios->deselectAll(); + } + + public function testDeselectByIndexRadio(): void + { + $radios = new WebDriverRadios($this->driver->findElement(WebDriverBy::xpath('//input[@type="radio"]'))); + + $this->expectException(UnsupportedOperationException::class); + $this->expectExceptionMessage('You cannot deselect radio buttons'); + $radios->deselectByIndex(0); + } + + public function testDeselectByValueRadio(): void + { + $radios = new WebDriverRadios($this->driver->findElement(WebDriverBy::xpath('//input[@type="radio"]'))); + + $this->expectException(UnsupportedOperationException::class); + $this->expectExceptionMessage('You cannot deselect radio buttons'); + $radios->deselectByValue('val'); + } + + public function testDeselectByVisibleTextRadio(): void + { + $radios = new WebDriverRadios($this->driver->findElement(WebDriverBy::xpath('//input[@type="radio"]'))); + + $this->expectException(UnsupportedOperationException::class); + $this->expectExceptionMessage('You cannot deselect radio buttons'); + $radios->deselectByVisibleText('AB'); + } + + public function testDeselectByVisiblePartialTextRadio(): void + { + $radios = new WebDriverRadios($this->driver->findElement(WebDriverBy::xpath('//input[@type="radio"]'))); + + $this->expectException(UnsupportedOperationException::class); + $this->expectExceptionMessage('You cannot deselect radio buttons'); + $radios->deselectByVisiblePartialText('AB'); + } +} diff --git a/tests/functional/WebDriverSelectTest.php b/tests/functional/WebDriverSelectTest.php new file mode 100644 index 000000000..a5640b1be --- /dev/null +++ b/tests/functional/WebDriverSelectTest.php @@ -0,0 +1,464 @@ +driver->get($this->getTestPageUrl(TestPage::FORM)); + } + + /** + * @dataProvider multipleSelectDataProvider + */ + public function testShouldCreateNewInstanceForSelectElementAndDetectIfItIsMultiple(string $selector): void + { + $originalElement = $this->driver->findElement(WebDriverBy::cssSelector('#select')); + $originalMultipleElement = $this->driver->findElement(WebDriverBy::cssSelector($selector)); + + $select = new WebDriverSelect($originalElement); + $selectMultiple = new WebDriverSelect($originalMultipleElement); + + $this->assertInstanceOf(WebDriverSelect::class, $select); + $this->assertFalse($select->isMultiple()); + + $this->assertInstanceOf(WebDriverSelect::class, $selectMultiple); + $this->assertTrue($selectMultiple->isMultiple()); + } + + public static function multipleSelectDataProvider(): array + { + return [ + ['#select-multiple'], + ['#select-multiple-2'], + ['#select-multiple-3'], + ]; + } + + public function testShouldThrowExceptionWhenNotInstantiatedOnSelectElement(): void + { + $notSelectElement = $this->driver->findElement(WebDriverBy::cssSelector('textarea')); + + $this->expectException(UnexpectedTagNameException::class); + $this->expectExceptionMessage('Element should have been "select" but was "textarea"'); + new WebDriverSelect($notSelectElement); + } + + /** + * @dataProvider provideSelectSelector + */ + public function testShouldGetOptionsOfSelect(string $selector): void + { + $originalElement = $this->driver->findElement(WebDriverBy::cssSelector($selector)); + $select = new WebDriverSelect($originalElement); + + $options = $select->getOptions(); + + $this->assertContainsOnlyInstancesOf(WebDriverElement::class, $options); + $this->assertCount(5, $options); + } + + /** + * @return array[] + */ + public function provideSelectSelector(): array + { + return [ + 'simple with multiple attribute' => ['#select-multiple'], + ]; + } + + public function testShouldDefaultSelectedOptionOfSimpleSelect(): void + { + $select = $this->getWebDriverSelectForSimpleSelect(); + + $selectedOptions = $select->getAllSelectedOptions(); + $firstSelectedOption = $select->getFirstSelectedOption(); + + $this->assertContainsOnlyInstancesOf(WebDriverElement::class, $selectedOptions); + $this->assertCount(1, $selectedOptions); + $this->assertSame('First', $selectedOptions[0]->getText()); + + $this->assertInstanceOf(WebDriverElement::class, $firstSelectedOption); + $this->assertSame('First', $firstSelectedOption->getText()); + } + + public function testShouldReturnEmptyArrayIfNoOptionsOfMultipleSelectSelected(): void + { + $select = $this->getWebDriverSelectForMultipleSelect(); + + $selectedOptions = $select->getAllSelectedOptions(); + + $this->assertSame([], $selectedOptions); + } + + public function testShouldThrowExceptionIfThereIsNoFirstSelectedOptionOfMultipleSelect(): void + { + $select = $this->getWebDriverSelectForMultipleSelect(); + + $this->expectException(NoSuchElementException::class); + $this->expectExceptionMessage('No options are selected'); + $select->getFirstSelectedOption(); + } + + public function testShouldSelectOptionOfSimpleSelectByIndex(): void + { + $select = $this->getWebDriverSelectForSimpleSelect(); + $this->assertSame('first', $select->getFirstSelectedOption()->getAttribute('value')); + + $select->selectByIndex(1); + $select->selectByIndex(1); // should be selected even if selected again + $this->assertSame('second', $select->getFirstSelectedOption()->getAttribute('value')); + + $select->selectByIndex(3); + $this->assertSame('fourth', $select->getFirstSelectedOption()->getAttribute('value')); + } + + /** + * @group exclude-edge + * https://connect.microsoft.com/IE/feedback/details/2020772/-microsoft-edge-webdriver-cannot-select-multiple-on-select-html-tag + */ + public function testShouldSelectOptionOfMultipleSelectByIndex(): void + { + $select = $this->getWebDriverSelectForMultipleSelect(); + $this->assertSame([], $select->getAllSelectedOptions()); + + $select->selectByIndex(1); + $select->selectByIndex(1); // should be selected even if selected again + $this->assertSame('second', $select->getFirstSelectedOption()->getAttribute('value')); + $this->assertContainsOptionsWithValues(['second'], $select->getAllSelectedOptions()); + + $select->selectByIndex(4); + $select->selectByIndex(3); + // the first selected option is still the same + $this->assertSame('second', $select->getFirstSelectedOption()->getAttribute('value')); + + $this->assertContainsOptionsWithValues(['second', 'fourth', 'fifth'], $select->getAllSelectedOptions()); + } + + public function testShouldThrowExceptionIfThereIsNoOptionIndexToSelect(): void + { + $select = $this->getWebDriverSelectForSimpleSelect(); + + $this->expectException(NoSuchElementException::class); + $this->expectExceptionMessage('Cannot locate option with index: 1337'); + $select->selectByIndex(1337); + } + + public function testShouldSelectOptionOfSimpleSelectByValue(): void + { + $select = $this->getWebDriverSelectForSimpleSelect(); + $this->assertSame('first', $select->getFirstSelectedOption()->getAttribute('value')); + + $select->selectByValue('second'); + $select->selectByValue('second'); // should be selected even if selected again + $this->assertSame('second', $select->getFirstSelectedOption()->getAttribute('value')); + + $select->selectByValue('fourth'); + $this->assertSame('fourth', $select->getFirstSelectedOption()->getAttribute('value')); + } + + /** + * @group exclude-edge + * https://connect.microsoft.com/IE/feedback/details/2020772/-microsoft-edge-webdriver-cannot-select-multiple-on-select-html-tag + */ + public function testShouldSelectOptionOfMultipleSelectByValue(): void + { + $select = $this->getWebDriverSelectForMultipleSelect(); + $this->assertSame([], $select->getAllSelectedOptions()); + + $select->selectByValue('second'); + $select->selectByValue('second'); // should be selected even if selected again + $this->assertSame('second', $select->getFirstSelectedOption()->getAttribute('value')); + $this->assertContainsOptionsWithValues(['second'], $select->getAllSelectedOptions()); + + $select->selectByValue('fifth'); + $select->selectByValue('fourth'); + // the first selected option is still the same + $this->assertSame('second', $select->getFirstSelectedOption()->getAttribute('value')); + + $this->assertContainsOptionsWithValues(['second', 'fourth', 'fifth'], $select->getAllSelectedOptions()); + } + + public function testShouldThrowExceptionIfThereIsNoOptionValueToSelect(): void + { + $select = $this->getWebDriverSelectForSimpleSelect(); + + $this->expectException(NoSuchElementException::class); + $this->expectExceptionMessage('Cannot locate option with value: 1337'); + $select->selectByValue(1337); + } + + public function testShouldSelectOptionOfSimpleSelectByVisibleText(): void + { + $select = $this->getWebDriverSelectForSimpleSelect(); + $this->assertSame('first', $select->getFirstSelectedOption()->getAttribute('value')); + + $select->selectByVisibleText('Fourth with spaces inside'); + $select->selectByVisibleText('Fourth with spaces inside'); // should be selected even if selected again + $this->assertSame('fourth', $select->getFirstSelectedOption()->getAttribute('value')); + + $select->selectByVisibleText('Fifth surrounded by spaces'); + $this->assertSame('fifth', $select->getFirstSelectedOption()->getAttribute('value')); + } + + /** + * @group exclude-edge + * https://connect.microsoft.com/IE/feedback/details/2020772/-microsoft-edge-webdriver-cannot-select-multiple-on-select-html-tag + */ + public function testShouldSelectOptionOfMultipleSelectByVisibleText(): void + { + $select = $this->getWebDriverSelectForMultipleSelect(); + $this->assertSame([], $select->getAllSelectedOptions()); + + $select->selectByVisibleText('This is second option'); + $select->selectByVisibleText('This is second option'); // should be selected even if selected again + $this->assertSame('second', $select->getFirstSelectedOption()->getAttribute('value')); + $this->assertContainsOptionsWithValues(['second'], $select->getAllSelectedOptions()); + + $select->selectByVisibleText('Fifth surrounded by spaces'); + $select->selectByVisibleText('Fourth with spaces inside'); + // the first selected option is still the same + $this->assertSame('second', $select->getFirstSelectedOption()->getAttribute('value')); + + $this->assertContainsOptionsWithValues(['second', 'fourth', 'fifth'], $select->getAllSelectedOptions()); + } + + public function testShouldThrowExceptionIfThereIsNoOptionVisibleTextToSelect(): void + { + $select = $this->getWebDriverSelectForSimpleSelect(); + + $this->expectException(NoSuchElementException::class); + $this->expectExceptionMessage('Cannot locate option with text: second'); + $select->selectByVisibleText('second'); // the option is "This is second option" + } + + public function testShouldSelectOptionOfSimpleSelectByVisiblePartialText(): void + { + $select = $this->getWebDriverSelectForSimpleSelect(); + $this->assertSame('first', $select->getFirstSelectedOption()->getAttribute('value')); + + $select->selectByVisiblePartialText('not second'); + $this->assertSame('third', $select->getFirstSelectedOption()->getAttribute('value')); + + $select->selectByVisiblePartialText('Fourth with spaces'); + $select->selectByVisiblePartialText('Fourth with spaces'); // should be selected even if selected again + $this->assertSame('fourth', $select->getFirstSelectedOption()->getAttribute('value')); + } + + /** + * @group exclude-edge + * https://connect.microsoft.com/IE/feedback/details/2020772/-microsoft-edge-webdriver-cannot-select-multiple-on-select-html-tag + */ + public function testShouldSelectOptionOfMultipleSelectByVisiblePartialText(): void + { + $select = $this->getWebDriverSelectForMultipleSelect(); + $this->assertSame([], $select->getAllSelectedOptions()); + + $select->selectByVisiblePartialText('Firs'); + $select->selectByVisiblePartialText('Firs'); // should be selected even if selected again + $this->assertSame('first', $select->getFirstSelectedOption()->getAttribute('value')); + $this->assertContainsOptionsWithValues(['first'], $select->getAllSelectedOptions()); + + $select->selectByVisiblePartialText('second'); // matches options 'second' and 'third' + $select->selectByVisiblePartialText('Fourth with spaces'); + // the first selected option is still the same + $this->assertSame('first', $select->getFirstSelectedOption()->getAttribute('value')); + + $this->assertContainsOptionsWithValues( + ['first', 'second', 'third', 'fourth'], + $select->getAllSelectedOptions() + ); + } + + public function testShouldThrowExceptionIfThereIsNoOptionVisiblePartialTextToSelect(): void + { + $select = $this->getWebDriverSelectForSimpleSelect(); + + $this->expectException(NoSuchElementException::class); + $this->expectExceptionMessage('Cannot locate option with text: Not existing option'); + $select->selectByVisiblePartialText('Not existing option'); + } + + public function testShouldThrowExceptionWhenDeselectingOnSimpleSelect(): void + { + $select = $this->getWebDriverSelectForSimpleSelect(); + + $this->expectException(UnsupportedOperationException::class); + $this->expectExceptionMessage('You may only deselect all options of a multi-select'); + $select->deselectAll(); + } + + /** + * @group exclude-edge + * https://connect.microsoft.com/IE/feedback/details/2020772/-microsoft-edge-webdriver-cannot-select-multiple-on-select-html-tag + */ + public function testShouldDeselectAllOptionsOnMultipleSelect(): void + { + $select = $this->getWebDriverSelectForMultipleSelect(); + + $select->selectByIndex(1); + $select->selectByIndex(3); + $select->selectByIndex(4); + $this->assertCount(3, $select->getAllSelectedOptions()); + + $select->deselectAll(); + + $this->assertCount(0, $select->getAllSelectedOptions()); + } + + /** + * @group exclude-edge + * https://connect.microsoft.com/IE/feedback/details/2020772/-microsoft-edge-webdriver-cannot-select-multiple-on-select-html-tag + */ + public function testShouldDeselectOptionOnMultipleSelectByIndex(): void + { + $select = $this->getWebDriverSelectForMultipleSelect(); + $select->selectByValue('fourth'); // index 3 + $select->selectByValue('second'); // index 1 + + $select->deselectByIndex(3); + $select->deselectByIndex(3); // should be deselected even if deselected again + $select->deselectByIndex(4); // should not select unselected option + + $this->assertContainsOptionsWithValues(['second'], $select->getAllSelectedOptions()); + } + + public function testShouldThrowExceptionIfDeselectingSimpleSelectByIndex(): void + { + $select = $this->getWebDriverSelectForSimpleSelect(); + + $this->expectException(UnsupportedOperationException::class); + $this->expectExceptionMessage('You may only deselect options of a multi-select'); + $select->deselectByIndex(0); + } + + /** + * @group exclude-edge + * https://connect.microsoft.com/IE/feedback/details/2020772/-microsoft-edge-webdriver-cannot-select-multiple-on-select-html-tag + */ + public function testShouldDeselectOptionOnMultipleSelectByValue(): void + { + $select = $this->getWebDriverSelectForMultipleSelect(); + $select->selectByValue('third'); + $select->selectByValue('first'); + + $select->deselectByValue('third'); + $select->deselectByValue('third'); // should be deselected even if deselected again + $select->deselectByValue('second'); // should not select unselected option + + $this->assertContainsOptionsWithValues(['first'], $select->getAllSelectedOptions()); + } + + public function testShouldThrowExceptionIfDeselectingSimpleSelectByValue(): void + { + $select = $this->getWebDriverSelectForSimpleSelect(); + + $this->expectException(UnsupportedOperationException::class); + $this->expectExceptionMessage('You may only deselect options of a multi-select'); + $select->deselectByValue('first'); + } + + /** + * @group exclude-edge + * https://connect.microsoft.com/IE/feedback/details/2020772/-microsoft-edge-webdriver-cannot-select-multiple-on-select-html-tag + */ + public function testShouldDeselectOptionOnMultipleSelectByVisibleText(): void + { + $select = $this->getWebDriverSelectForMultipleSelect(); + $select->selectByValue('fourth'); // text 'Fourth with spaces inside' + $select->selectByValue('fifth'); // text ' Fifth surrounded by spaces ' + $select->selectByValue('second'); // text 'This is second option' + + $select->deselectByVisibleText('Fourth with spaces inside'); + $select->deselectByVisibleText('Fourth with spaces inside'); // should be deselected even if deselected again + $select->deselectByVisibleText('Fifth surrounded by spaces'); + $select->deselectByVisibleText('First'); // should not select unselected option + + $this->assertContainsOptionsWithValues(['second'], $select->getAllSelectedOptions()); + } + + public function testShouldThrowExceptionIfDeselectingSimpleSelectByVisibleText(): void + { + $select = $this->getWebDriverSelectForSimpleSelect(); + + $this->expectException(UnsupportedOperationException::class); + $this->expectExceptionMessage('You may only deselect options of a multi-select'); + $select->deselectByVisibleText('First'); + } + + /** + * @group exclude-edge + * https://connect.microsoft.com/IE/feedback/details/2020772/-microsoft-edge-webdriver-cannot-select-multiple-on-select-html-tag + */ + public function testShouldDeselectOptionOnMultipleSelectByVisiblePartialText(): void + { + $select = $this->getWebDriverSelectForMultipleSelect(); + $select->selectByValue('fourth'); // text 'Fourth with spaces inside' + $select->selectByValue('fifth'); // text ' Fifth surrounded by spaces ' + $select->selectByValue('second'); // text 'This is second option' + $select->selectByValue('third'); // text 'This is not second option' + $select->selectByValue('first'); // text 'First' + $this->assertCount(5, $select->getAllSelectedOptions()); + + $select->deselectByVisiblePartialText('second'); // should deselect two options + $this->assertContainsOptionsWithValues(['first', 'fourth', 'fifth'], $select->getAllSelectedOptions()); + + $select->deselectByVisiblePartialText('Fourth with spaces'); + $select->deselectByVisiblePartialText('Fourth with spaces'); // should be deselected even if deselected again + $this->assertContainsOptionsWithValues(['first', 'fifth'], $select->getAllSelectedOptions()); + + $select->deselectByVisiblePartialText('Fifth surrounded'); + $this->assertContainsOptionsWithValues(['first'], $select->getAllSelectedOptions()); + } + + public function testShouldThrowExceptionIfDeselectingSimpleSelectByVisiblePartialText(): void + { + $select = $this->getWebDriverSelectForSimpleSelect(); + + $this->expectException(UnsupportedOperationException::class); + $this->expectExceptionMessage('You may only deselect options of a multi-select'); + $select->deselectByVisiblePartialText('First'); + } + + protected function getWebDriverSelectForSimpleSelect(): WebDriverSelect + { + $originalElement = $this->driver->findElement(WebDriverBy::cssSelector('#select')); + + return new WebDriverSelect($originalElement); + } + + protected function getWebDriverSelectForMultipleSelect(): WebDriverSelect + { + $originalElement = $this->driver->findElement(WebDriverBy::cssSelector('#select-multiple')); + + return new WebDriverSelect($originalElement); + } + + /** + * @param string[] $expectedValues + */ + private function assertContainsOptionsWithValues(array $expectedValues, array $options): void + { + $expectedCount = count($expectedValues); + $this->assertContainsOnlyInstancesOf(WebDriverElement::class, $options); + $this->assertCount($expectedCount, $options); + + for ($i = 0; $i < $expectedCount; $i++) { + $this->assertSame($expectedValues[$i], $options[$i]->getAttribute('value')); + } + } +} diff --git a/tests/functional/WebDriverTestCase.php b/tests/functional/WebDriverTestCase.php index 6ebe98b61..db0fd8b0f 100644 --- a/tests/functional/WebDriverTestCase.php +++ b/tests/functional/WebDriverTestCase.php @@ -1,59 +1,248 @@ -driver = RemoteWebDriver::create( - 'http://localhost:4444/wd/hub', - [ - WebDriverCapabilityType::BROWSER_NAME - //=> WebDriverBrowserType::FIREFOX, - => WebDriverBrowserType::HTMLUNIT, - ] - ); + $this->desiredCapabilities = new DesiredCapabilities(); + + if (static::isSauceLabsBuild()) { + $this->setUpSauceLabs(); + } else { + $browserName = getenv('BROWSER_NAME'); + $disableHeadless = filter_var(getenv('DISABLE_HEADLESS') ?: '', FILTER_VALIDATE_BOOLEAN); + if ($browserName === '' || $browserName === false) { + $this->markTestSkipped( + 'To execute functional tests browser name must be provided in BROWSER_NAME environment variable' + ); + } + + if ($browserName === WebDriverBrowserType::CHROME) { + $chromeOptions = new ChromeOptions(); + + $chromeOptions->addArguments([ + '--screen-info={1280x720}', + '--no-sandbox', // workaround for https://github.com/SeleniumHQ/selenium/issues/4961 + '--force-color-profile=srgb', + '--disable-search-engine-choice-screen', + ]); + + if (!$disableHeadless) { + $chromeOptions->addArguments(['--headless']); + } + + if (!static::isW3cProtocolBuild()) { + $chromeOptions->setExperimentalOption('w3c', false); + } + + $this->desiredCapabilities->setCapability(ChromeOptions::CAPABILITY, $chromeOptions); + } elseif ($browserName === WebDriverBrowserType::FIREFOX) { + $firefoxOptions = new FirefoxOptions(); + + if (!$disableHeadless) { + $firefoxOptions->addArguments(['-headless']); + } + + $this->desiredCapabilities->setCapability(FirefoxOptions::CAPABILITY, $firefoxOptions); + } + + $this->desiredCapabilities->setBrowserName($browserName); + } + + $this->createWebDriver(); } - protected function tearDown() + protected function tearDown(): void { - if ($this->driver) { - $this->driver->quit(); + if ($this->driver !== null) { + try { + $this->driver->quit(); + } catch (NoSuchWindowException $e) { + // browser may have died or is already closed + } + $this->driver = null; + + if (getenv('BROWSER_NAME') === 'safari') { + // The Safari instance is already paired with another WebDriver session + usleep(200000); // 200ms + } + } + } + + public static function isSauceLabsBuild(): bool + { + return getenv('SAUCELABS') ? true : false; + } + + public static function isW3cProtocolBuild(): bool + { + return getenv('DISABLE_W3C_PROTOCOL') !== '1'; + } + + public static function isSeleniumServerUsed(): bool + { + return getenv('SELENIUM_SERVER') === '1'; + } + + public static function skipForW3cProtocol($message = 'Not supported by W3C specification'): void + { + if (static::isW3cProtocolBuild()) { + static::markTestSkipped($message); + } + } + + public static function skipForJsonWireProtocol($message = 'Not supported by JsonWire protocol'): void + { + if (!static::isW3cProtocolBuild()) { + static::markTestSkipped($message); } } /** - * Get the URL of the test html. + * Mark a test as skipped if the current browser is not in the list of browsers. * - * @param $path - * @return string + * @param string[] $browsers List of browsers for this test + */ + public static function skipForUnmatchedBrowsers(array $browsers = [], ?string $message = null): void + { + $browserName = (string) getenv('BROWSER_NAME'); + if (!in_array($browserName, $browsers, true)) { + if (!$message) { + $browserList = implode(', ', $browsers); + $message = 'Browser ' . $browserName . ' not supported for this test (' . $browserList . ')'; + } + + static::markTestSkipped($message); + } + } + + /** + * Rerun failed tests. + * @TODO Replace with PHPUnit 7.3+ builtin functionality once upgraded to PHP 7.1+ */ - protected function getTestPath($path) + public function runBare(): void { - return 'file:///' . __DIR__ . '/html/' . $path; + $e = null; + $numberOfRetires = 3; + + for ($i = 0; $i < $numberOfRetires; ++$i) { + try { + parent::runBare(); + + return; + } catch (WebDriverException $e) { + // repeat + } + } + + if ($e !== null) { + throw $e; + } + } + + /** + * Get the URL of given test HTML on running webserver. + */ + protected function getTestPageUrl(string $path): string + { + $host = 'http://localhost:8000/'; + if ($alternateHost = getenv('FIXTURES_HOST')) { + $host = $alternateHost; + } + + return $host . '/' . $path; + } + + protected function setUpSauceLabs(): void + { + $this->serverUrl = sprintf( + 'http://%s:%s@ondemand.saucelabs.com/wd/hub', + getenv('SAUCE_USERNAME'), + getenv('SAUCE_ACCESS_KEY') + ); + $this->desiredCapabilities->setBrowserName(getenv('BROWSER_NAME')); + $this->desiredCapabilities->setVersion(getenv('VERSION')); + $this->desiredCapabilities->setPlatform(getenv('PLATFORM')); + $name = get_class($this) . '::' . $this->getName(); + $tags = [get_class($this)]; + + $ciDetector = new CiDetector(); + if ($ciDetector->isCiDetected()) { + $ci = $ciDetector->detect(); + if (!empty($ci->getBuildNumber())) { + // SAUCE_TUNNEL_NAME appended as a workaround for GH actions not having environment value + // to distinguish runs of the matrix + $build = $ci->getBuildNumber() . '.' . getenv('SAUCE_TUNNEL_NAME'); + } + } + + if (getenv('SAUCE_TUNNEL_NAME')) { + $tunnelName = getenv('SAUCE_TUNNEL_NAME'); + } + + if (static::isW3cProtocolBuild()) { + $sauceOptions = [ + 'name' => $name, + 'tags' => $tags, + ]; + if (isset($build)) { + $sauceOptions['build'] = $build; + } + if (isset($tunnelName)) { + $sauceOptions['tunnelName'] = $tunnelName; + } + $this->desiredCapabilities->setCapability('sauce:options', (object) $sauceOptions); + } else { + $this->desiredCapabilities->setCapability('name', $name); + $this->desiredCapabilities->setCapability('tags', $tags); + + if (isset($tunnelName)) { + $this->desiredCapabilities->setCapability('tunnel-identifier', $tunnelName); + } + if (isset($build)) { + $this->desiredCapabilities->setCapability('build', $build); + } + } + } + + protected function createWebDriver(): void + { + $this->driver = RemoteWebDriver::create( + $this->serverUrl, + $this->desiredCapabilities, + $this->connectionTimeout, + $this->requestTimeout, + null, + null, + null + ); } } diff --git a/tests/functional/WebDriverTimeoutsTest.php b/tests/functional/WebDriverTimeoutsTest.php new file mode 100644 index 000000000..52534ae37 --- /dev/null +++ b/tests/functional/WebDriverTimeoutsTest.php @@ -0,0 +1,58 @@ +driver->get($this->getTestPageUrl(TestPage::DELAYED_ELEMENT)); + + $this->expectException(NoSuchElementException::class); + $this->driver->findElement(WebDriverBy::id('delayed')); + } + + /** + * @covers ::__construct + * @covers ::implicitlyWait + */ + public function testShouldGetDelayedElementWithImplicitWait(): void + { + $this->driver->get($this->getTestPageUrl(TestPage::DELAYED_ELEMENT)); + + $this->driver->manage()->timeouts()->implicitlyWait(2); + $element = $this->driver->findElement(WebDriverBy::id('delayed')); + + $this->assertInstanceOf(RemoteWebElement::class, $element); + } + + /** + * @group exclude-saucelabs + * @covers ::__construct + * @covers ::pageLoadTimeout + */ + public function testShouldFailIfPageIsLoadingLongerThanPageLoadTimeout(): void + { + $this->driver->manage()->timeouts()->pageLoadTimeout(1); + + try { + $this->driver->get($this->getTestPageUrl(TestPage::SLOW_LOADING)); + $this->fail('ScriptTimeoutException or TimeoutException exception should be thrown'); + } catch (TimeoutException $e) { // thrown by Selenium 3.0.0+ + } catch (ScriptTimeoutException $e) { // thrown by Selenium 2 + } + + $this->assertTrue(true); // To generate coverage, see https://github.com/sebastianbergmann/phpunit/issues/3016 + } +} diff --git a/tests/functional/WebDriverWindowTest.php b/tests/functional/WebDriverWindowTest.php new file mode 100644 index 000000000..219126a7e --- /dev/null +++ b/tests/functional/WebDriverWindowTest.php @@ -0,0 +1,135 @@ +driver->manage() + ->window() + ->getPosition(); + + $this->assertGreaterThanOrEqual(0, $position->getX()); + $this->assertGreaterThanOrEqual(0, $position->getY()); + } + + public function testShouldGetSize(): void + { + $size = $this->driver->manage() + ->window() + ->getSize(); + + $this->assertGreaterThan(0, $size->getWidth()); + $this->assertGreaterThan(0, $size->getHeight()); + } + + public function testShouldMaximizeWindow(): void + { + $sizeBefore = $this->driver->manage() + ->window() + ->getSize(); + + $this->driver->manage() + ->window() + ->maximize(); + + $sizeAfter = $this->driver->manage() + ->window() + ->getSize(); + + $this->assertGreaterThanOrEqual($sizeBefore->getWidth(), $sizeAfter->getWidth()); + $this->assertGreaterThanOrEqual($sizeBefore->getHeight(), $sizeAfter->getHeight()); + } + + /** + * @group exclude-edge + * @group exclude-safari + * @group exclude-saucelabs + */ + public function testShouldFullscreenWindow(): void + { + self::skipForJsonWireProtocol('"fullscreen" window is not supported in JsonWire protocol'); + + $this->driver->manage() + ->window() + ->setSize(new WebDriverDimension(400, 300)); + + $this->driver->manage() + ->window() + ->fullscreen(); + + $sizeAfter = $this->driver->manage() + ->window() + ->getSize(); + + // Note: Headless browsers see no effect. + $this->assertGreaterThanOrEqual(400, $sizeAfter->getWidth()); + $this->assertGreaterThanOrEqual(300, $sizeAfter->getHeight()); + } + + /** + * @see https://bugs.chromium.org/p/chromium/issues/detail?id=1038050 + * @group exclude-chrome + * @group exclude-safari + * @group exclude-saucelabs + */ + public function testShouldMinimizeWindow(): void + { + self::skipForJsonWireProtocol('"minimize" window is not supported in JsonWire protocol'); + + $this->assertSame('visible', $this->driver->executeScript('return document.visibilityState;')); + + $this->driver->manage() + ->window() + ->minimize(); + + $this->assertSame('hidden', $this->driver->executeScript('return document.visibilityState;')); + } + + /** + * @group exclude-saucelabs + */ + public function testShouldSetSize(): void + { + $sizeBefore = $this->driver->manage() + ->window() + ->getSize(); + $this->assertNotSame(500, $sizeBefore->getWidth()); + $this->assertNotSame(666, $sizeBefore->getHeight()); + + $this->driver->manage() + ->window() + ->setSize(new WebDriverDimension(500, 666)); + + $sizeAfter = $this->driver->manage() + ->window() + ->getSize(); + + $this->assertSame(500, $sizeAfter->getWidth()); + $this->assertSame(666, $sizeAfter->getHeight()); + } + + /** + * @todo Skip when running headless mode + */ + public function testShouldSetWindowPosition(): void + { + $this->driver->manage() + ->window() + ->setPosition(new WebDriverPoint(33, 66)); + + $positionAfter = $this->driver->manage() + ->window() + ->getPosition(); + + $this->assertSame(33, $positionAfter->getX()); + $this->assertSame(66, $positionAfter->getY()); + } +} diff --git a/tests/functional/html/index.html b/tests/functional/html/index.html deleted file mode 100644 index 6bafefdc9..000000000 --- a/tests/functional/html/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - php-webdriver test page - - -

      Welcome to the facebook/php-webdriver testing page.

      -

      Test by ID

      -

      Test by Class

      - Click here - - - \ No newline at end of file diff --git a/tests/functional/html/upload.html b/tests/functional/html/upload.html deleted file mode 100644 index ed03702d3..000000000 --- a/tests/functional/html/upload.html +++ /dev/null @@ -1,10 +0,0 @@ - - - Upload a file - - -
      - -
      - - diff --git a/tests/functional/web/alert.html b/tests/functional/web/alert.html new file mode 100644 index 000000000..4bf78680a --- /dev/null +++ b/tests/functional/web/alert.html @@ -0,0 +1,40 @@ + + + + + Open an alert + + + +

      + Open alert +
      + + + Open alert after 1 second + +
      + + Open confirm +
      + + Open prompt +
      +

      + +
      + + + + + diff --git a/tests/functional/web/delayed_element.html b/tests/functional/web/delayed_element.html new file mode 100644 index 000000000..4572cd342 --- /dev/null +++ b/tests/functional/web/delayed_element.html @@ -0,0 +1,19 @@ + + + + + php-webdriver test page with delayed element appearing + + + +
      + + + + + diff --git a/tests/functional/web/escape_css.html b/tests/functional/web/escape_css.html new file mode 100644 index 000000000..48213084a --- /dev/null +++ b/tests/functional/web/escape_css.html @@ -0,0 +1,14 @@ + + + + + Test CSS selector escaping + + + +
      Foo
      +
      Bar
      +
      Baz
      + + + diff --git a/tests/functional/web/events.html b/tests/functional/web/events.html new file mode 100644 index 000000000..45241e1c2 --- /dev/null +++ b/tests/functional/web/events.html @@ -0,0 +1,98 @@ + + + + + Events + + + + +
        + +
      + +
        +
      • First item
      • +
      • Second item
      • +
      • Third item
      • +
      + +

      Mouse events:

      +
      
      +
      +

      Keyboard events:

      +
      
      +
      +
      +
      +
      +
      diff --git a/tests/functional/web/form.html b/tests/functional/web/form.html
      new file mode 100644
      index 000000000..597baca2e
      --- /dev/null
      +++ b/tests/functional/web/form.html
      @@ -0,0 +1,92 @@
      +
      +
      +
      +    
      +    php-webdriver form test page
      +
      +
      +    
      + +
      + + +
      + + +
      + + +
      + + + +
      + + + + + + + +
      + +
      + Checkboxes + First
      + Second (preselected)
      + Third (preselected)
      + Fourth
      +
      + +
      + Radio buttons + First
      + Second (preselected)
      + Third
      + Fourth
      +
      + +

      + +

      + + +
      + + diff --git a/tests/functional/web/form_checkbox_radio.html b/tests/functional/web/form_checkbox_radio.html new file mode 100644 index 000000000..fe59896d9 --- /dev/null +++ b/tests/functional/web/form_checkbox_radio.html @@ -0,0 +1,69 @@ + + + + + Form + + +
      + + + + + + + + + + + + + + + + + +
      + + + + + + + + + + + + + + +
      + +
      + + +
      + + + + + + + +
      + + + diff --git a/tests/functional/web/gecko653.html b/tests/functional/web/gecko653.html new file mode 100644 index 000000000..fcf943d66 --- /dev/null +++ b/tests/functional/web/gecko653.html @@ -0,0 +1,35 @@ + + + + Test page for geckodriver#653 workaround + + + + + Link text + + + + +
      Link in a block element
      +
      + + + + + +
      Link in a block element
      +
      + + + Link text + + + + diff --git a/tests/functional/web/iframe_content.html b/tests/functional/web/iframe_content.html new file mode 100644 index 000000000..db4fed288 --- /dev/null +++ b/tests/functional/web/iframe_content.html @@ -0,0 +1,10 @@ + + + + + php-webdriver iFrame content page + + +

      This is the content of the iFrame

      + + diff --git a/tests/functional/web/index.html b/tests/functional/web/index.html new file mode 100644 index 000000000..6202e3e9b --- /dev/null +++ b/tests/functional/web/index.html @@ -0,0 +1,86 @@ + + + + + php-webdriver test page + + + +
      + +

      Welcome to the php-webdriver testing page.

      + + Form test page + | + Form with file upload + | + Form with checkboxes and radios + | + New window opener test page + | + Delayed render + | + Slow loading page + | + Javascript alerts + | + Events + | + Sortable + | + WebComponents + +

      Test by ID

      +

      Test by Class

      + Click here + + +
        +
      • First
      • +
      • Second
      • +
      • Third
      • +
      + +
        +
      • Foo
      • +
      • Bar
      • +
      + +

      Foo bar text

      +

      + Multiple spaces are + stripped +

      + +
      +

      This div has some more html inside.

      +
      +
      + + + +
      + Foo +
      + +
      + Bar +
      + + + + + diff --git a/tests/functional/web/open_new_window.html b/tests/functional/web/open_new_window.html new file mode 100644 index 000000000..3c790226a --- /dev/null +++ b/tests/functional/web/open_new_window.html @@ -0,0 +1,10 @@ + + + + + php-webdriver test page + + + open new window + + diff --git a/tests/functional/web/page_with_frame.html b/tests/functional/web/page_with_frame.html new file mode 100644 index 000000000..666f6aa75 --- /dev/null +++ b/tests/functional/web/page_with_frame.html @@ -0,0 +1,13 @@ + + + + + php-webdriver test page + + +

      This is the host page which contains an iFrame

      + + + + + diff --git a/tests/functional/web/slow_loading.html b/tests/functional/web/slow_loading.html new file mode 100644 index 000000000..984272bf3 --- /dev/null +++ b/tests/functional/web/slow_loading.html @@ -0,0 +1,14 @@ + + + + + php-webdriver test page which is taking long to load + + + +

      This page is loading slowly

      + +Slowly loading pixel + + + diff --git a/tests/functional/web/slow_pixel.png.php b/tests/functional/web/slow_pixel.png.php new file mode 100644 index 000000000..06885539a --- /dev/null +++ b/tests/functional/web/slow_pixel.png.php @@ -0,0 +1,11 @@ + + + + + Sortable via jQuery + + + + + + + + + + +

      Sortable using jQuery

      + + +
      +
      +
        +
      • 1-1
      • +
      • 1-2
      • +
      • 1-3
      • +
      • 1-4
      • +
      • 1-5
      • +
      +
      +
      +
        +
      • 2-1
      • +
      • 2-2
      • +
      • 2-3
      • +
      • 2-4
      • +
      • 2-5
      • +
      +
      +
      + + + diff --git a/tests/functional/web/submit.php b/tests/functional/web/submit.php new file mode 100644 index 000000000..9d1942cee --- /dev/null +++ b/tests/functional/web/submit.php @@ -0,0 +1,29 @@ + + + + + Form submit endpoint + + + +POST data not detected
  • '; +} else { + echo '

    Received POST data

    '; + echo '
      '; + foreach ($_POST as $key => $value) { + echo sprintf( + '
    • %s: %s
    • ' . "\n", + $key, + $key, + $value + ); + } + echo '
    '; +} +?> + + + diff --git a/tests/functional/web/upload.html b/tests/functional/web/upload.html new file mode 100644 index 000000000..6762873c3 --- /dev/null +++ b/tests/functional/web/upload.html @@ -0,0 +1,18 @@ + + + + + Upload a file + + +
    +

    + + +

    +

    + +

    +
    + + diff --git a/tests/functional/web/upload.php b/tests/functional/web/upload.php new file mode 100644 index 000000000..92b52b57a --- /dev/null +++ b/tests/functional/web/upload.php @@ -0,0 +1,30 @@ + + + + + File upload endpoint + + + +File upload not detected'; +} elseif (isset($_FILES['upload']) && $_FILES['upload']['error'] == 4) { + echo '

    Form was submitted but no file was selected for upload

    '; +} else { + echo sprintf('

    Received %d uploaded file(s)

    ', count($_FILES)); + echo '
      '; + foreach ($_FILES as $file) { + echo sprintf( + '
    • File name: %s, size: %d
    • ', + $file['name'], + $file['size'] + ); + } + echo '
    '; +} +?> + + + diff --git a/tests/functional/web/web_components.html b/tests/functional/web/web_components.html new file mode 100644 index 000000000..65e458c30 --- /dev/null +++ b/tests/functional/web/web_components.html @@ -0,0 +1,38 @@ + + + + + WebComponents and Shadow DOM tests + + + + +

    WebComponents and Shadow DOM tests

    + +

    Element out of Shadow DOM

    +
    + +
    + + + + diff --git a/tests/unit/CookieTest.php b/tests/unit/CookieTest.php new file mode 100644 index 000000000..444569499 --- /dev/null +++ b/tests/unit/CookieTest.php @@ -0,0 +1,200 @@ +setPath('/bar'); + $cookie->setDomain('foo.com'); + $cookie->setExpiry(1485388387); + $cookie->setSecure(true); + $cookie->setHttpOnly(true); + $cookie->setSameSite('Lax'); + + $this->assertSame('cookieName', $cookie->getName()); + $this->assertSame('someValue', $cookie->getValue()); + $this->assertSame('/bar', $cookie->getPath()); + $this->assertSame('foo.com', $cookie->getDomain()); + $this->assertSame(1485388387, $cookie->getExpiry()); + $this->assertTrue($cookie->isSecure()); + $this->assertTrue($cookie->isHttpOnly()); + $this->assertSame('Lax', $cookie->getSameSite()); + + return $cookie; + } + + /** + * @depends testShouldSetAllProperties + */ + public function testShouldBeConvertibleToArray(Cookie $cookie): void + { + $this->assertSame( + [ + 'name' => 'cookieName', + 'value' => 'someValue', + 'path' => '/bar', + 'domain' => 'foo.com', + 'expiry' => 1485388387, + 'secure' => true, + 'httpOnly' => true, + 'sameSite' => 'Lax', + ], + $cookie->toArray() + ); + } + + /** + * Test that there are no null values in the cookie array. + * + * Both JsonWireProtocol and w3c protocol say to leave an entry off + * rather than having a null value. + * + * https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol + * https://w3c.github.io/webdriver/#add-cookie + */ + public function testShouldNotContainNullValues(): void + { + $cookie = new Cookie('cookieName', 'someValue'); + + $cookie->setHttpOnly(null); + $cookie->setPath(null); + $cookie->setSameSite(null); + $cookieArray = $cookie->toArray(); + + foreach ($cookieArray as $key => $value) { + $this->assertNotNull($value, $key . ' should not be null'); + } + } + + /** + * @depends testShouldSetAllProperties + */ + public function testShouldProvideArrayAccessToProperties(Cookie $cookie): void + { + $this->assertSame('cookieName', $cookie['name']); + $this->assertSame('someValue', $cookie['value']); + $this->assertSame('/bar', $cookie['path']); + $this->assertSame('foo.com', $cookie['domain']); + $this->assertSame(1485388387, $cookie['expiry']); + $this->assertTrue($cookie['secure']); + $this->assertTrue($cookie['httpOnly']); + $this->assertSame('Lax', $cookie['sameSite']); + + $cookie->offsetSet('domain', 'bar.com'); + $this->assertSame('bar.com', $cookie['domain']); + $cookie->offsetUnset('domain'); + $this->assertArrayNotHasKey('domain', $cookie); + } + + public function testShouldBeCreatableFromAnArrayWithBasicValues(): void + { + $sourceArray = [ + 'name' => 'cookieName', + 'value' => 'someValue', + ]; + + $cookie = Cookie::createFromArray($sourceArray); + + $this->assertSame('cookieName', $cookie['name']); + $this->assertSame('someValue', $cookie['value']); + + $this->assertArrayNotHasKey('path', $cookie); + $this->assertNull($cookie['path']); + $this->assertNull($cookie->getPath()); + + $this->assertArrayNotHasKey('domain', $cookie); + $this->assertNull($cookie['domain']); + $this->assertNull($cookie->getDomain()); + + $this->assertArrayNotHasKey('expiry', $cookie); + $this->assertNull($cookie['expiry']); + $this->assertNull($cookie->getExpiry()); + + $this->assertArrayNotHasKey('secure', $cookie); + $this->assertNull($cookie['secure']); + $this->assertNull($cookie->isSecure()); + + $this->assertArrayNotHasKey('httpOnly', $cookie); + $this->assertNull($cookie['httpOnly']); + $this->assertNull($cookie->isHttpOnly()); + + $this->assertArrayNotHasKey('sameSite', $cookie); + $this->assertNull($cookie['sameSite']); + $this->assertNull($cookie->getSameSite()); + } + + public function testShouldBeCreatableFromAnArrayWithAllValues(): void + { + $sourceArray = [ + 'name' => 'cookieName', + 'value' => 'someValue', + 'path' => '/bar', + 'domain' => 'foo', + 'expiry' => 1485388333, + 'secure' => false, + 'httpOnly' => false, + 'sameSite' => 'Lax', + ]; + + $cookie = Cookie::createFromArray($sourceArray); + + $this->assertSame('cookieName', $cookie['name']); + $this->assertSame('someValue', $cookie['value']); + $this->assertSame('/bar', $cookie['path']); + $this->assertSame('foo', $cookie['domain']); + $this->assertSame(1485388333, $cookie['expiry']); + $this->assertFalse($cookie['secure']); + $this->assertFalse($cookie['httpOnly']); + $this->assertSame('Lax', $cookie['sameSite']); + } + + /** + * @dataProvider provideInvalidCookie + */ + public function testShouldValidateCookieOnConstruction( + ?string $name, + ?string $value, + ?string $domain, + ?string $expectedMessage + ): void { + if ($expectedMessage) { + $this->expectException(LogicException::class); + $this->expectExceptionMessage($expectedMessage); + } + + $cookie = new Cookie($name, $value); + if ($domain !== null) { + $cookie->setDomain($domain); + } + + $this->assertInstanceOf(Cookie::class, $cookie); + } + + /** + * @return array[] + */ + public function provideInvalidCookie(): array + { + return [ + // $name, $value, $domain, $expectedMessage + 'name cannot be empty' => ['', 'foo', null, 'Cookie name should be non-empty'], + 'name cannot be null' => [null, 'foo', null, 'Cookie name should be non-empty'], + 'name cannot contain semicolon' => ['name;semicolon', 'foo', null, 'Cookie name should not contain a ";"'], + 'value could be empty string' => ['name', '', null, null], + 'value cannot be null' => ['name', null, null, 'Cookie value is required when setting a cookie'], + 'domain cannot containt port' => [ + 'name', + 'value', + 'localhost:443', + 'Cookie domain "localhost:443" should not contain a port', + ], + 'cookie with valid values' => ['name', 'value', '*.localhost', null], + ]; + } +} diff --git a/tests/unit/Exception/Internal/DriverServerDiedExceptionTest.php b/tests/unit/Exception/Internal/DriverServerDiedExceptionTest.php new file mode 100644 index 000000000..2e2fc5617 --- /dev/null +++ b/tests/unit/Exception/Internal/DriverServerDiedExceptionTest.php @@ -0,0 +1,17 @@ +assertSame($dummyPreviousException, $exception->getPrevious()); + } +} diff --git a/tests/unit/Exception/Internal/IOExceptionTest.php b/tests/unit/Exception/Internal/IOExceptionTest.php new file mode 100644 index 000000000..240991963 --- /dev/null +++ b/tests/unit/Exception/Internal/IOExceptionTest.php @@ -0,0 +1,15 @@ +assertSame('Error message ("/file/path.txt")', $exception->getMessage()); + } +} diff --git a/tests/unit/Exception/Internal/LogicExceptionTest.php b/tests/unit/Exception/Internal/LogicExceptionTest.php new file mode 100644 index 000000000..ba09d58d8 --- /dev/null +++ b/tests/unit/Exception/Internal/LogicExceptionTest.php @@ -0,0 +1,26 @@ +assertSame('Error message', $exception->getMessage()); + } + + public function testShouldCreateExceptionForInvalidHttpMethod(): void + { + $exception = LogicException::forInvalidHttpMethod('http://foo.bar/', 'FOO', ['key' => 'val']); + + $this->assertSame( + 'The http method called for "http://foo.bar/" is "FOO", but it has to be POST if you want to pass' + . ' the JSON params {"key":"val"}', + $exception->getMessage() + ); + } +} diff --git a/tests/unit/Exception/Internal/RuntimeExceptionTest.php b/tests/unit/Exception/Internal/RuntimeExceptionTest.php new file mode 100644 index 000000000..97adf87b3 --- /dev/null +++ b/tests/unit/Exception/Internal/RuntimeExceptionTest.php @@ -0,0 +1,34 @@ +assertSame('Error message', $exception->getMessage()); + } + + public function testShouldCreateExceptionForDriverError(): void + { + $processMock = $this->createConfiguredMock( + Process::class, + [ + 'getCommandLine' => '/bin/true --force', + 'getErrorOutput' => 'may the force be with you', + ] + ); + + $exception = RuntimeException::forDriverError($processMock); + + $this->assertSame( + 'Error starting driver executable "/bin/true --force": may the force be with you', + $exception->getMessage() + ); + } +} diff --git a/tests/unit/Exception/Internal/UnexpectedResponseExceptionTest.php b/tests/unit/Exception/Internal/UnexpectedResponseExceptionTest.php new file mode 100644 index 000000000..80f4b4a0d --- /dev/null +++ b/tests/unit/Exception/Internal/UnexpectedResponseExceptionTest.php @@ -0,0 +1,30 @@ +assertSame('Error message', $exception->getMessage()); + } + + public function testShouldCreateExceptionForJsonDecodingError(): void + { + $exception = UnexpectedResponseException::forJsonDecodingError(JSON_ERROR_SYNTAX, 'foo'); + + $this->assertSame( + <<getMessage() + ); + } +} diff --git a/tests/unit/Exception/Internal/WebDriverCurlExceptionTest.php b/tests/unit/Exception/Internal/WebDriverCurlExceptionTest.php new file mode 100644 index 000000000..acc94cb88 --- /dev/null +++ b/tests/unit/Exception/Internal/WebDriverCurlExceptionTest.php @@ -0,0 +1,34 @@ +assertSame( + <<getMessage() + ); + } + + public function provideParams(): array + { + return [ + 'null params' => [null, ''], + 'empty params' => [[], ''], + 'array of params' => [['bar' => 'foo', 'baz' => 'bat'], ' with params: {"bar":"foo","baz":"bat"}'], + ]; + } +} diff --git a/tests/unit/Exception/WebDriverExceptionTest.php b/tests/unit/Exception/WebDriverExceptionTest.php new file mode 100644 index 000000000..c770533d2 --- /dev/null +++ b/tests/unit/Exception/WebDriverExceptionTest.php @@ -0,0 +1,116 @@ +assertInstanceOf(WebDriverException::class, $exception); + $this->assertSame('exception message', $exception->getMessage()); + $this->assertSame(['foo', 'bar'], $exception->getResults()); + } + + /** + * @dataProvider provideJsonWireStatusCode + * @dataProvider provideW3CWebDriverErrorCode + * @param int|string $errorCode + */ + public function testShouldThrowProperExceptionBasedOnWebDriverErrorCode( + $errorCode, + string $expectedExceptionType + ): void { + try { + WebDriverException::throwException($errorCode, 'exception message', ['results']); + } catch (WebDriverException $e) { + $this->assertInstanceOf($expectedExceptionType, $e); + + $this->assertSame('exception message', $e->getMessage()); + $this->assertSame(['results'], $e->getResults()); + } + } + + /** + * @return array[] + */ + public function provideW3CWebDriverErrorCode(): array + { + return [ + ['element click intercepted', ElementClickInterceptedException::class], + ['element not interactable', ElementNotInteractableException::class], + ['element not interactable', ElementNotInteractableException::class], + ['insecure certificate', InsecureCertificateException::class], + ['invalid argument', InvalidArgumentException::class], + ['invalid cookie domain', InvalidCookieDomainException::class], + ['invalid element state', InvalidElementStateException::class], + ['invalid selector', InvalidSelectorException::class], + ['invalid session id', InvalidSessionIdException::class], + ['javascript error', JavascriptErrorException::class], + ['move target out of bounds', MoveTargetOutOfBoundsException::class], + ['no such alert', NoSuchAlertException::class], + ['no such cookie', NoSuchCookieException::class], + ['no such element', NoSuchElementException::class], + ['no such frame', NoSuchFrameException::class], + ['no such window', NoSuchWindowException::class], + ['script timeout', ScriptTimeoutException::class], + ['session not created', SessionNotCreatedException::class], + ['stale element reference', StaleElementReferenceException::class], + ['timeout', TimeoutException::class], + ['unable to set cookie', UnableToSetCookieException::class], + ['unable to capture screen', UnableToCaptureScreenException::class], + ['unexpected alert open', UnexpectedAlertOpenException::class], + ['unknown command', UnknownCommandException::class], + ['unknown error', UnknownErrorException::class], + ['unknown method', UnknownMethodException::class], + ['unsupported operation', UnsupportedOperationException::class], + ]; + } + + /** + * @return array[] + */ + public function provideJsonWireStatusCode(): array + { + return [ + [1337, UnrecognizedExceptionException::class], + [1, IndexOutOfBoundsException::class], + [2, NoCollectionException::class], + [3, NoStringException::class], + [4, NoStringLengthException::class], + [5, NoStringWrapperException::class], + [6, NoSuchDriverException::class], + [7, NoSuchElementException::class], + [8, NoSuchFrameException::class], + [9, UnknownCommandException::class], + [10, StaleElementReferenceException::class], + [11, ElementNotVisibleException::class], + [12, InvalidElementStateException::class], + [13, UnknownServerException::class], + [14, ExpectedException::class], + [15, ElementNotSelectableException::class], + [16, NoSuchDocumentException::class], + [17, UnexpectedJavascriptException::class], + [18, NoScriptResultException::class], + [19, XPathLookupException::class], + [20, NoSuchCollectionException::class], + [21, TimeoutException::class], + [22, NullPointerException::class], + [23, NoSuchWindowException::class], + [24, InvalidCookieDomainException::class], + [25, UnableToSetCookieException::class], + [26, UnexpectedAlertOpenException::class], + [27, NoAlertOpenException::class], + [28, ScriptTimeoutException::class], + [29, InvalidCoordinatesException::class], + [30, IMENotAvailableException::class], + [31, IMEEngineActivationFailedException::class], + [32, InvalidSelectorException::class], + [33, SessionNotCreatedException::class], + [34, MoveTargetOutOfBoundsException::class], + ]; + } +} diff --git a/tests/unit/Firefox/FirefoxOptionsTest.php b/tests/unit/Firefox/FirefoxOptionsTest.php new file mode 100644 index 000000000..c8ac09f02 --- /dev/null +++ b/tests/unit/Firefox/FirefoxOptionsTest.php @@ -0,0 +1,121 @@ + */ + public const EXPECTED_DEFAULT_PREFS = [ + FirefoxPreferences::READER_PARSE_ON_LOAD_ENABLED => false, + FirefoxPreferences::DEVTOOLS_JSONVIEW => false, + ]; + + public function testShouldBeConstructedWithDefaultOptions(): void + { + $options = new FirefoxOptions(); + + $this->assertSame( + [ + 'prefs' => self::EXPECTED_DEFAULT_PREFS, + ], + $options->toArray() + ); + } + + public function testShouldAddCustomOptions(): void + { + $options = new FirefoxOptions(); + + $options->setOption('binary', '/usr/local/firefox/bin/firefox'); + + $this->assertSame( + [ + 'binary' => '/usr/local/firefox/bin/firefox', + 'prefs' => self::EXPECTED_DEFAULT_PREFS, + ], + $options->toArray() + ); + } + + public function testShouldOverwriteDefaultOptionsWhenSpecified(): void + { + $options = new FirefoxOptions(); + + $options->setPreference(FirefoxPreferences::READER_PARSE_ON_LOAD_ENABLED, true); + + $this->assertSame( + [ + 'prefs' => [ + FirefoxPreferences::READER_PARSE_ON_LOAD_ENABLED => true, + FirefoxPreferences::DEVTOOLS_JSONVIEW => false, + ], + ], + $options->toArray() + ); + } + + public function testShouldSetCustomPreference(): void + { + $options = new FirefoxOptions(); + + $options->setPreference('browser.startup.homepage', 'https://github.com/php-webdriver/php-webdriver/'); + + $this->assertSame( + [ + 'prefs' => [ + FirefoxPreferences::READER_PARSE_ON_LOAD_ENABLED => false, + FirefoxPreferences::DEVTOOLS_JSONVIEW => false, + 'browser.startup.homepage' => 'https://github.com/php-webdriver/php-webdriver/', + ], + ], + $options->toArray() + ); + } + + public function testShouldAddArguments(): void + { + $options = new FirefoxOptions(); + + $options->addArguments(['-headless', '-profile', '/path/to/profile']); + + $this->assertSame( + [ + 'args' => ['-headless', '-profile', '/path/to/profile'], + 'prefs' => self::EXPECTED_DEFAULT_PREFS, + ], + $options->toArray() + ); + } + + public function testShouldJsonSerializeToArrayObject(): void + { + $options = new FirefoxOptions(); + $options->setOption('binary', '/usr/local/firefox/bin/firefox'); + + $jsonSerialized = $options->jsonSerialize(); + + $this->assertInstanceOf(\ArrayObject::class, $jsonSerialized); + $this->assertSame('/usr/local/firefox/bin/firefox', $jsonSerialized['binary']); + } + + public function testShouldNotAllowToSetArgumentsOptionDirectly(): void + { + $options = new FirefoxOptions(); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Use addArguments() method to add Firefox arguments'); + $options->setOption('args', []); + } + + public function testShouldNotAllowToSetPreferencesOptionDirectly(): void + { + $options = new FirefoxOptions(); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Use setPreference() method to set Firefox preferences'); + $options->setOption('prefs', []); + } +} diff --git a/tests/unit/Interactions/Internal/WebDriverButtonReleaseActionTest.php b/tests/unit/Interactions/Internal/WebDriverButtonReleaseActionTest.php index f2bb1b647..f14bad0c2 100644 --- a/tests/unit/Interactions/Internal/WebDriverButtonReleaseActionTest.php +++ b/tests/unit/Interactions/Internal/WebDriverButtonReleaseActionTest.php @@ -1,33 +1,21 @@ -webDriverMouse = $this->getMockBuilder(WebDriverMouse::class)->getMock(); $this->locationProvider = $this->getMockBuilder(WebDriverLocatable::class)->getMock(); @@ -37,12 +25,12 @@ public function setUp() ); } - public function testPerformSendsMouseUpCommand() + public function testPerformSendsMouseUpCommand(): void { $coords = $this->getMockBuilder(WebDriverCoordinates::class) ->disableOriginalConstructor()->getMock(); $this->webDriverMouse->expects($this->once())->method('mouseUp')->with($coords); - $this->locationProvider->expects($this->once())->method('getCoordinates')->will($this->returnValue($coords)); + $this->locationProvider->expects($this->once())->method('getCoordinates')->willReturn($coords); $this->webDriverButtonReleaseAction->perform(); } } diff --git a/tests/unit/Interactions/Internal/WebDriverClickActionTest.php b/tests/unit/Interactions/Internal/WebDriverClickActionTest.php index fdb868141..de12aab26 100644 --- a/tests/unit/Interactions/Internal/WebDriverClickActionTest.php +++ b/tests/unit/Interactions/Internal/WebDriverClickActionTest.php @@ -1,33 +1,21 @@ -webDriverMouse = $this->getMockBuilder(WebDriverMouse::class)->getMock(); $this->locationProvider = $this->getMockBuilder(WebDriverLocatable::class)->getMock(); @@ -37,12 +25,12 @@ public function setUp() ); } - public function testPerformSendsClickCommand() + public function testPerformSendsClickCommand(): void { $coords = $this->getMockBuilder(WebDriverCoordinates::class) ->disableOriginalConstructor()->getMock(); $this->webDriverMouse->expects($this->once())->method('click')->with($coords); - $this->locationProvider->expects($this->once())->method('getCoordinates')->will($this->returnValue($coords)); + $this->locationProvider->expects($this->once())->method('getCoordinates')->willReturn($coords); $this->webDriverClickAction->perform(); } } diff --git a/tests/unit/Interactions/Internal/WebDriverClickAndHoldActionTest.php b/tests/unit/Interactions/Internal/WebDriverClickAndHoldActionTest.php index eb3f4bc6f..da5a18dee 100644 --- a/tests/unit/Interactions/Internal/WebDriverClickAndHoldActionTest.php +++ b/tests/unit/Interactions/Internal/WebDriverClickAndHoldActionTest.php @@ -1,33 +1,21 @@ -webDriverMouse = $this->getMockBuilder(WebDriverMouse::class)->getMock(); $this->locationProvider = $this->getMockBuilder(WebDriverLocatable::class)->getMock(); @@ -37,12 +25,12 @@ public function setUp() ); } - public function testPerformSendsMouseDownCommand() + public function testPerformSendsMouseDownCommand(): void { $coords = $this->getMockBuilder(WebDriverCoordinates::class) ->disableOriginalConstructor()->getMock(); $this->webDriverMouse->expects($this->once())->method('mouseDown')->with($coords); - $this->locationProvider->expects($this->once())->method('getCoordinates')->will($this->returnValue($coords)); + $this->locationProvider->expects($this->once())->method('getCoordinates')->willReturn($coords); $this->webDriverClickAndHoldAction->perform(); } } diff --git a/tests/unit/Interactions/Internal/WebDriverContextClickActionTest.php b/tests/unit/Interactions/Internal/WebDriverContextClickActionTest.php index e9162f239..f3e66f6ee 100644 --- a/tests/unit/Interactions/Internal/WebDriverContextClickActionTest.php +++ b/tests/unit/Interactions/Internal/WebDriverContextClickActionTest.php @@ -1,33 +1,21 @@ -webDriverMouse = $this->getMockBuilder(WebDriverMouse::class)->getMock(); $this->locationProvider = $this->getMockBuilder(WebDriverLocatable::class)->getMock(); @@ -37,12 +25,12 @@ public function setUp() ); } - public function testPerformSendsContextClickCommand() + public function testPerformSendsContextClickCommand(): void { $coords = $this->getMockBuilder(WebDriverCoordinates::class) ->disableOriginalConstructor()->getMock(); $this->webDriverMouse->expects($this->once())->method('contextClick')->with($coords); - $this->locationProvider->expects($this->once())->method('getCoordinates')->will($this->returnValue($coords)); + $this->locationProvider->expects($this->once())->method('getCoordinates')->willReturn($coords); $this->webDriverContextClickAction->perform(); } } diff --git a/tests/unit/Interactions/Internal/WebDriverCoordinatesTest.php b/tests/unit/Interactions/Internal/WebDriverCoordinatesTest.php index d782edf8c..f8faee8d8 100644 --- a/tests/unit/Interactions/Internal/WebDriverCoordinatesTest.php +++ b/tests/unit/Interactions/Internal/WebDriverCoordinatesTest.php @@ -1,46 +1,25 @@ -getAuxiliary()); + $this->assertEquals(new WebDriverPoint(0, 0), $webDriverCoordinates->inViewPort()); + $this->assertEquals(new WebDriverPoint(10, 10), $webDriverCoordinates->onPage()); + $this->assertSame('auxiliary', $webDriverCoordinates->getAuxiliary()); } } diff --git a/tests/unit/Interactions/Internal/WebDriverDoubleClickActionTest.php b/tests/unit/Interactions/Internal/WebDriverDoubleClickActionTest.php index acd74550f..d4ae699f1 100644 --- a/tests/unit/Interactions/Internal/WebDriverDoubleClickActionTest.php +++ b/tests/unit/Interactions/Internal/WebDriverDoubleClickActionTest.php @@ -1,33 +1,21 @@ -webDriverMouse = $this->getMockBuilder(WebDriverMouse::class)->getMock(); $this->locationProvider = $this->getMockBuilder(WebDriverLocatable::class)->getMock(); @@ -37,12 +25,12 @@ public function setUp() ); } - public function testPerformSendsDoubleClickCommand() + public function testPerformSendsDoubleClickCommand(): void { $coords = $this->getMockBuilder(WebDriverCoordinates::class) ->disableOriginalConstructor()->getMock(); $this->webDriverMouse->expects($this->once())->method('doubleClick')->with($coords); - $this->locationProvider->expects($this->once())->method('getCoordinates')->will($this->returnValue($coords)); + $this->locationProvider->expects($this->once())->method('getCoordinates')->willReturn($coords); $this->webDriverDoubleClickAction->perform(); } } diff --git a/tests/unit/Interactions/Internal/WebDriverKeyDownActionTest.php b/tests/unit/Interactions/Internal/WebDriverKeyDownActionTest.php index 267b58b3e..0fd8aa9dd 100644 --- a/tests/unit/Interactions/Internal/WebDriverKeyDownActionTest.php +++ b/tests/unit/Interactions/Internal/WebDriverKeyDownActionTest.php @@ -1,54 +1,43 @@ -webDriverKeyboard = $this->getMockBuilder(WebDriverKeyboard::class)->getMock(); - $this->webDriverMouse = $this->getMockBuilder(WebDriverMouse::class)->getMock(); - $this->locationProvider = $this->getMockBuilder(WebDriverLocatable::class)->getMock(); + $this->webDriverKeyboard = $this->createMock(WebDriverKeyboard::class); + $this->webDriverMouse = $this->createMock(WebDriverMouse::class); + $this->locationProvider = $this->createMock(WebDriverLocatable::class); $this->webDriverKeyDownAction = new WebDriverKeyDownAction( $this->webDriverKeyboard, $this->webDriverMouse, - $this->locationProvider + $this->locationProvider, + WebDriverKeys::LEFT_SHIFT ); } - public function testPerformFocusesOnElementAndSendPressKeyCommand() + public function testPerformFocusesOnElementAndSendPressKeyCommand(): void { - $coords = $this->getMockBuilder(WebDriverCoordinates::class) - ->disableOriginalConstructor()->getMock(); + $coords = $this->createMock(WebDriverCoordinates::class); $this->webDriverMouse->expects($this->once())->method('click')->with($coords); - $this->locationProvider->expects($this->once())->method('getCoordinates')->will($this->returnValue($coords)); + $this->locationProvider->expects($this->once())->method('getCoordinates')->willReturn($coords); $this->webDriverKeyboard->expects($this->once())->method('pressKey'); $this->webDriverKeyDownAction->perform(); } diff --git a/tests/unit/Interactions/Internal/WebDriverKeyUpActionTest.php b/tests/unit/Interactions/Internal/WebDriverKeyUpActionTest.php index 970a35b0f..f10d53b09 100644 --- a/tests/unit/Interactions/Internal/WebDriverKeyUpActionTest.php +++ b/tests/unit/Interactions/Internal/WebDriverKeyUpActionTest.php @@ -1,36 +1,25 @@ -webDriverKeyboard = $this->getMockBuilder(WebDriverKeyboard::class)->getMock(); $this->webDriverMouse = $this->getMockBuilder(WebDriverMouse::class)->getMock(); @@ -40,17 +29,16 @@ public function setUp() $this->webDriverKeyboard, $this->webDriverMouse, $this->locationProvider, - 'a' + WebDriverKeys::LEFT_SHIFT ); } - public function testPerformFocusesOnElementAndSendPressKeyCommand() + public function testPerformFocusesOnElementAndSendPressKeyCommand(): void { - $coords = $this->getMockBuilder(WebDriverCoordinates::class) - ->disableOriginalConstructor()->getMock(); + $coords = $this->createMock(WebDriverCoordinates::class); $this->webDriverMouse->expects($this->once())->method('click')->with($coords); - $this->locationProvider->expects($this->once())->method('getCoordinates')->will($this->returnValue($coords)); - $this->webDriverKeyboard->expects($this->once())->method('releaseKey')->with('a'); + $this->locationProvider->expects($this->once())->method('getCoordinates')->willReturn($coords); + $this->webDriverKeyboard->expects($this->once())->method('releaseKey')->with(WebDriverKeys::LEFT_SHIFT); $this->webDriverKeyUpAction->perform(); } } diff --git a/tests/unit/Interactions/Internal/WebDriverMouseMoveActionTest.php b/tests/unit/Interactions/Internal/WebDriverMouseMoveActionTest.php index 565530ad8..f23594852 100644 --- a/tests/unit/Interactions/Internal/WebDriverMouseMoveActionTest.php +++ b/tests/unit/Interactions/Internal/WebDriverMouseMoveActionTest.php @@ -1,33 +1,21 @@ -webDriverMouse = $this->getMockBuilder(WebDriverMouse::class)->getMock(); $this->locationProvider = $this->getMockBuilder(WebDriverLocatable::class)->getMock(); @@ -38,12 +26,12 @@ public function setUp() ); } - public function testPerformFocusesOnElementAndSendPressKeyCommand() + public function testPerformFocusesOnElementAndSendPressKeyCommand(): void { $coords = $this->getMockBuilder(WebDriverCoordinates::class) ->disableOriginalConstructor()->getMock(); $this->webDriverMouse->expects($this->once())->method('mouseMove')->with($coords); - $this->locationProvider->expects($this->once())->method('getCoordinates')->will($this->returnValue($coords)); + $this->locationProvider->expects($this->once())->method('getCoordinates')->willReturn($coords); $this->webDriverMouseMoveAction->perform(); } } diff --git a/tests/unit/Interactions/Internal/WebDriverMouseToOffsetActionTest.php b/tests/unit/Interactions/Internal/WebDriverMouseToOffsetActionTest.php index d2d5286a1..966b88325 100644 --- a/tests/unit/Interactions/Internal/WebDriverMouseToOffsetActionTest.php +++ b/tests/unit/Interactions/Internal/WebDriverMouseToOffsetActionTest.php @@ -1,35 +1,23 @@ -webDriverMouse = $this->getMockBuilder(WebDriverMouse::class)->getMock(); $this->locationProvider = $this->getMockBuilder(WebDriverLocatable::class)->getMock(); @@ -42,12 +30,12 @@ public function setUp() ); } - public function testPerformFocusesOnElementAndSendPressKeyCommand() + public function testPerformFocusesOnElementAndSendPressKeyCommand(): void { $coords = $this->getMockBuilder(WebDriverCoordinates::class) ->disableOriginalConstructor()->getMock(); $this->webDriverMouse->expects($this->once())->method('mouseMove')->with($coords, 150, 200); - $this->locationProvider->expects($this->once())->method('getCoordinates')->will($this->returnValue($coords)); + $this->locationProvider->expects($this->once())->method('getCoordinates')->willReturn($coords); $this->webDriverMoveToOffsetAction->perform(); } } diff --git a/tests/unit/Interactions/Internal/WebDriverSendKeysActionTest.php b/tests/unit/Interactions/Internal/WebDriverSendKeysActionTest.php index 9b06b3df7..b610d083f 100644 --- a/tests/unit/Interactions/Internal/WebDriverSendKeysActionTest.php +++ b/tests/unit/Interactions/Internal/WebDriverSendKeysActionTest.php @@ -1,38 +1,26 @@ -webDriverKeyboard = $this->getMockBuilder(WebDriverKeyboard::class)->getMock(); $this->webDriverMouse = $this->getMockBuilder(WebDriverMouse::class)->getMock(); @@ -47,13 +35,13 @@ public function setUp() ); } - public function testPerformFocusesOnElementAndSendPressKeyCommand() + public function testPerformFocusesOnElementAndSendPressKeyCommand(): void { $coords = $this->getMockBuilder(WebDriverCoordinates::class) ->disableOriginalConstructor()->getMock(); $this->webDriverKeyboard->expects($this->once())->method('sendKeys')->with($this->keys); $this->webDriverMouse->expects($this->once())->method('click')->with($coords); - $this->locationProvider->expects($this->once())->method('getCoordinates')->will($this->returnValue($coords)); + $this->locationProvider->expects($this->once())->method('getCoordinates')->willReturn($coords); $this->webDriverSendKeysAction->perform(); } } diff --git a/tests/unit/Interactions/Internal/WebDriverSingleKeyActionTest.php b/tests/unit/Interactions/Internal/WebDriverSingleKeyActionTest.php new file mode 100644 index 000000000..9a3e61160 --- /dev/null +++ b/tests/unit/Interactions/Internal/WebDriverSingleKeyActionTest.php @@ -0,0 +1,32 @@ +expectException(LogicException::class); + $this->expectExceptionMessage( + 'keyDown / keyUp actions can only be used for modifier keys, but "foo" was given' + ); + + new WebDriverKeyUpAction( + $this->createMock(WebDriverKeyboard::class), + $this->createMock(WebDriverMouse::class), + $this->createMock(WebDriverLocatable::class), + 'foo' + ); + + $this->assertTrue(true); // To generate coverage, see https://github.com/sebastianbergmann/phpunit/issues/3016 + } +} diff --git a/tests/unit/Remote/CustomWebDriverCommandTest.php b/tests/unit/Remote/CustomWebDriverCommandTest.php new file mode 100644 index 000000000..8e829c615 --- /dev/null +++ b/tests/unit/Remote/CustomWebDriverCommandTest.php @@ -0,0 +1,38 @@ + 'bar'] + ); + + $this->assertSame('/some-url', $command->getCustomUrl()); + $this->assertSame('POST', $command->getCustomMethod()); + } + + public function testCustomCommandInvalidUrlExceptionInit(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('URL of custom command has to start with / but is "url-without-leading-slash"'); + + new CustomWebDriverCommand('session-id-123', 'url-without-leading-slash', 'POST', []); + } + + public function testCustomCommandInvalidMethodExceptionInit(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Invalid custom method "invalid-method", must be one of [GET, POST]'); + + new CustomWebDriverCommand('session-id-123', '/some-url', 'invalid-method', []); + } +} diff --git a/tests/unit/Remote/DesiredCapabilitiesTest.php b/tests/unit/Remote/DesiredCapabilitiesTest.php index dacdcd139..e57b2a4d3 100644 --- a/tests/unit/Remote/DesiredCapabilitiesTest.php +++ b/tests/unit/Remote/DesiredCapabilitiesTest.php @@ -1,28 +1,18 @@ - 'fooVal', WebDriverCapabilityType::PLATFORM => WebDriverPlatform::ANY] @@ -37,7 +27,7 @@ public function testShouldInstantiateWithCapabilitiesGivenInConstructor() ); } - public function testShouldInstantiateEmptyInstance() + public function testShouldInstantiateEmptyInstance(): void { $capabilities = new DesiredCapabilities(); @@ -45,7 +35,7 @@ public function testShouldInstantiateEmptyInstance() $this->assertSame([], $capabilities->toArray()); } - public function testShouldProvideAccessToCapabilitiesUsingSettersAndGetters() + public function testShouldProvideAccessToCapabilitiesUsingSettersAndGetters(): void { $capabilities = new DesiredCapabilities(); // generic capability setter @@ -61,18 +51,32 @@ public function testShouldProvideAccessToCapabilitiesUsingSettersAndGetters() $this->assertSame(333, $capabilities->getVersion()); } - /** - * @expectedException \Exception - * @expectedExceptionMessage isJavascriptEnable() is a htmlunit-only option - */ - public function testShouldNotAllowToDisableJavascriptForNonHtmlUnitBrowser() + public function testShouldAccessCapabilitiesIsser(): void { + $capabilities = new DesiredCapabilities(); + + $capabilities->setCapability('custom', 1337); + $capabilities->setCapability('customBooleanTrue', true); + $capabilities->setCapability('customBooleanFalse', false); + $capabilities->setCapability('customNull', null); + + $this->assertTrue($capabilities->is('custom')); + $this->assertTrue($capabilities->is('customBooleanTrue')); + $this->assertFalse($capabilities->is('customBooleanFalse')); + $this->assertFalse($capabilities->is('customNull')); + } + + public function testShouldNotAllowToDisableJavascriptForNonHtmlUnitBrowser(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('isJavascriptEnabled() is a htmlunit-only option'); + $capabilities = new DesiredCapabilities(); $capabilities->setBrowserName(WebDriverBrowserType::FIREFOX); $capabilities->setJavascriptEnabled(false); } - public function testShouldAllowToDisableJavascriptForHtmlUnitBrowser() + public function testShouldAllowToDisableJavascriptForHtmlUnitBrowser(): void { $capabilities = new DesiredCapabilities(); $capabilities->setBrowserName(WebDriverBrowserType::HTMLUNIT); @@ -82,16 +86,13 @@ public function testShouldAllowToDisableJavascriptForHtmlUnitBrowser() } /** - * @dataProvider browserCapabilitiesProvider - * @param string $setupMethod - * @param string $expectedBrowser - * @param string $expectedPlatform + * @dataProvider provideBrowserCapabilities */ public function testShouldProvideShortcutSetupForCapabilitiesOfEachBrowser( - $setupMethod, - $expectedBrowser, - $expectedPlatform - ) { + string $setupMethod, + string $expectedBrowser, + string $expectedPlatform + ): void { /** @var DesiredCapabilities $capabilities */ $capabilities = call_user_func([DesiredCapabilities::class, $setupMethod]); @@ -100,9 +101,9 @@ public function testShouldProvideShortcutSetupForCapabilitiesOfEachBrowser( } /** - * @return array + * @return array[] */ - public function browserCapabilitiesProvider() + public function provideBrowserCapabilities(): array { return [ ['android', WebDriverBrowserType::ANDROID, WebDriverPlatform::ANDROID], @@ -120,14 +121,199 @@ public function browserCapabilitiesProvider() ]; } - public function testShouldSetupFirefoxProfileAndDisableReaderViewForFirefoxBrowser() + public function testShouldSetupFirefoxWithDefaultOptions(): void { + $capabilitiesArray = DesiredCapabilities::firefox()->toArray(); + + $this->assertSame('firefox', $capabilitiesArray['browserName']); + $this->assertSame( + [ + 'prefs' => FirefoxOptionsTest::EXPECTED_DEFAULT_PREFS, + ], + $capabilitiesArray['moz:firefoxOptions'] + ); + } + + public function testShouldSetupFirefoxWithCustomOptions(): void + { + $firefoxOptions = new FirefoxOptions(); + $firefoxOptions->addArguments(['-headless']); + $firefoxOptions->setOption('binary', '/foo/bar/firefox'); + $capabilities = DesiredCapabilities::firefox(); + $capabilities->setCapability(FirefoxOptions::CAPABILITY, $firefoxOptions); + + $capabilitiesArray = $capabilities->toArray(); - /** @var FirefoxProfile $firefoxProfile */ - $firefoxProfile = $capabilities->getCapability(FirefoxDriver::PROFILE); - $this->assertInstanceOf(FirefoxProfile::class, $firefoxProfile); + $this->assertSame('firefox', $capabilitiesArray['browserName']); + $this->assertSame( + [ + 'binary' => '/foo/bar/firefox', + 'args' => ['-headless'], + 'prefs' => FirefoxOptionsTest::EXPECTED_DEFAULT_PREFS, + ], + $capabilitiesArray['moz:firefoxOptions'] + ); + } - $this->assertSame('false', $firefoxProfile->getPreference(FirefoxPreferences::READER_PARSE_ON_LOAD_ENABLED)); + public function testShouldNotOverwriteDefaultFirefoxOptionsWhenAddingFirefoxOptionAsArray(): void + { + $capabilities = DesiredCapabilities::firefox(); + $capabilities->setCapability('moz:firefoxOptions', ['args' => ['-headless']]); + + $this->assertSame( + [ + 'prefs' => FirefoxOptionsTest::EXPECTED_DEFAULT_PREFS, + 'args' => ['-headless'], + ], + $capabilities->toArray()['moz:firefoxOptions'] + ); + } + + /** + * @dataProvider provideW3cCapabilities + */ + public function testShouldConvertCapabilitiesToW3cCompatible( + DesiredCapabilities $inputJsonWireCapabilities, + array $expectedW3cCapabilities + ): void { + $this->assertJsonStringEqualsJsonString( + json_encode($expectedW3cCapabilities, JSON_THROW_ON_ERROR), + json_encode($inputJsonWireCapabilities->toW3cCompatibleArray(), JSON_THROW_ON_ERROR) + ); + } + + /** + * @return array[] + */ + public function provideW3cCapabilities(): array + { + $chromeOptions = new ChromeOptions(); + $chromeOptions->addArguments(['--headless']); + + $firefoxOptions = new FirefoxOptions(); + $firefoxOptions->addArguments(['-headless']); + + $firefoxProfileEncoded = (new FirefoxProfile())->encode(); + + return [ + 'changed name' => [ + new DesiredCapabilities([ + WebDriverCapabilityType::BROWSER_NAME => WebDriverBrowserType::CHROME, + WebDriverCapabilityType::VERSION => '67.0.1', + WebDriverCapabilityType::PLATFORM => WebDriverPlatform::LINUX, + WebDriverCapabilityType::ACCEPT_SSL_CERTS => true, + ]), + [ + 'browserName' => 'chrome', + 'browserVersion' => '67.0.1', + 'platformName' => 'linux', + 'acceptInsecureCerts' => true, + ], + ], + 'removed capabilities' => [ + new DesiredCapabilities([ + WebDriverCapabilityType::WEB_STORAGE_ENABLED => true, + WebDriverCapabilityType::TAKES_SCREENSHOT => false, + ]), + [], + ], + 'custom invalid capability should be removed' => [ + new DesiredCapabilities([ + 'customInvalidCapability' => 'shouldBeRemoved', + ]), + [], + ], + 'already W3C capabilities' => [ + new DesiredCapabilities([ + 'pageLoadStrategy' => 'eager', + 'strictFileInteractability' => false, + ]), + [ + 'pageLoadStrategy' => 'eager', + 'strictFileInteractability' => false, + ], + ], + '"ANY" platform should be completely removed' => [ + new DesiredCapabilities([ + WebDriverCapabilityType::PLATFORM => WebDriverPlatform::ANY, + ]), + [], + ], + 'custom vendor extension' => [ + new DesiredCapabilities([ + 'vendor:prefix' => 'vendor extension should be kept', + ]), + [ + 'vendor:prefix' => 'vendor extension should be kept', + ], + ], + 'chromeOptions should be an object if empty' => [ + new DesiredCapabilities([ + ChromeOptions::CAPABILITY => new ChromeOptions(), + ]), + [ + 'goog:chromeOptions' => new \ArrayObject(), + ], + ], + 'chromeOptions should be converted' => [ + new DesiredCapabilities([ + ChromeOptions::CAPABILITY => $chromeOptions, + ]), + [ + 'goog:chromeOptions' => new \ArrayObject( + [ + 'args' => ['--headless'], + ] + ), + ], + ], + 'chromeOptions as W3C capability should be converted' => [ + new DesiredCapabilities([ + ChromeOptions::CAPABILITY_W3C => $chromeOptions, + ]), + [ + 'goog:chromeOptions' => new \ArrayObject( + [ + 'args' => ['--headless'], + ] + ), + ], + ], + 'firefox_profile should be converted' => [ + new DesiredCapabilities([ + FirefoxDriver::PROFILE => $firefoxProfileEncoded, + ]), + [ + 'moz:firefoxOptions' => [ + 'profile' => $firefoxProfileEncoded, + ], + ], + ], + 'firefox_profile should not be overwritten if already present' => [ + new DesiredCapabilities([ + FirefoxDriver::PROFILE => $firefoxProfileEncoded, + FirefoxOptions::CAPABILITY => ['profile' => 'w3cProfile'], + ]), + [ + 'moz:firefoxOptions' => [ + 'profile' => 'w3cProfile', + ], + ], + ], + 'firefox_profile should be merged with moz:firefoxOptions if they already exists' => [ + new DesiredCapabilities([ + FirefoxDriver::PROFILE => $firefoxProfileEncoded, + FirefoxOptions::CAPABILITY => $firefoxOptions, + ]), + [ + 'moz:firefoxOptions' => [ + 'profile' => $firefoxProfileEncoded, + 'args' => ['-headless'], + 'prefs' => FirefoxOptionsTest::EXPECTED_DEFAULT_PREFS, + ], + ], + ], + ]; } } diff --git a/tests/unit/Remote/HttpCommandExecutorTest.php b/tests/unit/Remote/HttpCommandExecutorTest.php index 268b3e873..b29d788b1 100644 --- a/tests/unit/Remote/HttpCommandExecutorTest.php +++ b/tests/unit/Remote/HttpCommandExecutorTest.php @@ -1,50 +1,55 @@ -executor = new HttpCommandExecutor('http://localhost:4444/'); } /** - * @dataProvider commandProvider - * @param int $command - * @param array $params - * @param string $expectedUrl - * @param string $expectedPostData + * @dataProvider provideCommand */ - public function testShouldSendRequestToAssembledUrl($command, array $params, $expectedUrl, $expectedPostData) - { - $command = new WebDriverCommand('foo-123', $command, $params); + public function testShouldSendRequestToAssembledUrl( + WebDriverCommand $command, + bool $shouldResetExpectHeader, + string $expectedUrl, + ?string $expectedPostData + ): void { + $expectedCurlSetOptCalls = [ + [$this->anything(), CURLOPT_URL, $expectedUrl], + [$this->anything()], + ]; - $curlSetoptMock = $this->getFunctionMock(__NAMESPACE__, 'curl_setopt'); - $curlSetoptMock->expects($this->at(0)) - ->with($this->anything(), CURLOPT_URL, $expectedUrl); + if ($shouldResetExpectHeader) { + $expectedCurlSetOptCalls[] = [ + $this->anything(), + CURLOPT_HTTPHEADER, + ['Content-Type: application/json;charset=UTF-8', 'Accept: application/json', 'Expect:'], + ]; + } else { + $expectedCurlSetOptCalls[] = [ + $this->anything(), + CURLOPT_HTTPHEADER, + ['Content-Type: application/json;charset=UTF-8', 'Accept: application/json'], + ]; + } - $curlSetoptMock->expects($this->at(2)) - ->with($this->anything(), CURLOPT_POSTFIELDS, $expectedPostData); + $expectedCurlSetOptCalls[] = [$this->anything(), CURLOPT_POSTFIELDS, $expectedPostData]; + + $curlSetoptMock = $this->getFunctionMock(__NAMESPACE__, 'curl_setopt'); + $curlSetoptMock->expects($this->exactly(4)) + ->withConsecutive(...$expectedCurlSetOptCalls); $curlExecMock = $this->getFunctionMock(__NAMESPACE__, 'curl_exec'); $curlExecMock->expects($this->once()) @@ -56,39 +61,65 @@ public function testShouldSendRequestToAssembledUrl($command, array $params, $ex /** * @return array[] */ - public function commandProvider() + public function provideCommand(): array { return [ 'POST command having :id placeholder in url' => [ - DriverCommand::SEND_KEYS_TO_ELEMENT, - ['value' => 'submitted-value', ':id' => '1337'], - 'http://localhost:4444/session/foo-123/element/1337/value', + new WebDriverCommand( + 'fooSession', + DriverCommand::SEND_KEYS_TO_ELEMENT, + ['value' => 'submitted-value', ':id' => '1337'] + ), + true, + 'http://localhost:4444/session/fooSession/element/1337/value', '{"value":"submitted-value"}', ], 'POST command without :id placeholder in url' => [ - DriverCommand::TOUCH_UP, - ['x' => 3, 'y' => 6], - 'http://localhost:4444/session/foo-123/touch/up', + new WebDriverCommand('fooSession', DriverCommand::TOUCH_UP, ['x' => 3, 'y' => 6]), + true, + 'http://localhost:4444/session/fooSession/touch/up', '{"x":3,"y":6}', ], 'Extra useless placeholder parameter should be removed' => [ - DriverCommand::TOUCH_UP, - ['x' => 3, 'y' => 6, ':useless' => 'foo'], - 'http://localhost:4444/session/foo-123/touch/up', + new WebDriverCommand('fooSession', DriverCommand::TOUCH_UP, ['x' => 3, 'y' => 6, ':useless' => 'foo']), + true, + 'http://localhost:4444/session/fooSession/touch/up', '{"x":3,"y":6}', ], 'DELETE command' => [ - DriverCommand::DELETE_COOKIE, - [':name' => 'cookie-name'], - 'http://localhost:4444/session/foo-123/cookie/cookie-name', + new WebDriverCommand('fooSession', DriverCommand::DELETE_COOKIE, [':name' => 'cookie-name']), + false, + 'http://localhost:4444/session/fooSession/cookie/cookie-name', null, ], 'GET command without session in URL' => [ - DriverCommand::GET_ALL_SESSIONS, - [], + new WebDriverCommand('fooSession', DriverCommand::GET_ALL_SESSIONS, []), + false, 'http://localhost:4444/sessions', null, ], + 'Custom GET command' => [ + new CustomWebDriverCommand( + 'fooSession', + '/session/:sessionId/custom-command/:someParameter', + 'GET', + [':someParameter' => 'someValue'] + ), + false, + 'http://localhost:4444/session/fooSession/custom-command/someValue', + null, + ], + 'Custom POST command' => [ + new CustomWebDriverCommand( + 'fooSession', + '/session/:sessionId/custom-post-command/', + 'POST', + ['someParameter' => 'someValue'] + ), + true, + 'http://localhost:4444/session/fooSession/custom-post-command/', + '{"someParameter":"someValue"}', + ], ]; } } diff --git a/tests/unit/Remote/LocalFileDetectorTest.php b/tests/unit/Remote/LocalFileDetectorTest.php new file mode 100644 index 000000000..dc6112636 --- /dev/null +++ b/tests/unit/Remote/LocalFileDetectorTest.php @@ -0,0 +1,24 @@ +getLocalFile(__DIR__ . '/./' . basename(__FILE__)); + + $this->assertSame(__FILE__, $file); + } + + public function testShouldReturnNullIfFileNotDetected(): void + { + $detector = new LocalFileDetector(); + + $this->assertNull($detector->getLocalFile('/this/is/not/a/file')); + } +} diff --git a/tests/unit/Remote/RemoteWebDriverTest.php b/tests/unit/Remote/RemoteWebDriverTest.php new file mode 100644 index 000000000..666ac9e5c --- /dev/null +++ b/tests/unit/Remote/RemoteWebDriverTest.php @@ -0,0 +1,134 @@ +driver = RemoteWebDriver::createBySessionID( + 'session-id', + 'http://foo.bar:4444/', + null, + null, + true, + new DesiredCapabilities([]) + ); + } + + /** + * @covers ::manage + */ + public function testShouldCreateWebDriverOptionsInstance(): void + { + $wait = $this->driver->manage(); + + $this->assertInstanceOf(WebDriverOptions::class, $wait); + } + + /** + * @covers ::navigate + */ + public function testShouldCreateWebDriverNavigationInstance(): void + { + $wait = $this->driver->navigate(); + + $this->assertInstanceOf(WebDriverNavigation::class, $wait); + } + + /** + * @covers ::switchTo + */ + public function testShouldCreateRemoteTargetLocatorInstance(): void + { + $wait = $this->driver->switchTo(); + + $this->assertInstanceOf(RemoteTargetLocator::class, $wait); + } + + /** + * @covers ::getMouse + */ + public function testShouldCreateRemoteMouseInstance(): void + { + $wait = $this->driver->getMouse(); + + $this->assertInstanceOf(RemoteMouse::class, $wait); + } + + /** + * @covers ::getKeyboard + */ + public function testShouldCreateRemoteKeyboardInstance(): void + { + $wait = $this->driver->getKeyboard(); + + $this->assertInstanceOf(RemoteKeyboard::class, $wait); + } + + /** + * @covers ::getTouch + */ + public function testShouldCreateRemoteTouchScreenInstance(): void + { + $wait = $this->driver->getTouch(); + + $this->assertInstanceOf(RemoteTouchScreen::class, $wait); + } + + /** + * @covers ::action + */ + public function testShouldCreateWebDriverActionsInstance(): void + { + $wait = $this->driver->action(); + + $this->assertInstanceOf(WebDriverActions::class, $wait); + } + + /** + * @covers ::wait + */ + public function testShouldCreateWebDriverWaitInstance(): void + { + $wait = $this->driver->wait(15, 1337); + + $this->assertInstanceOf(WebDriverWait::class, $wait); + } + + /** + * @covers ::findElements + * @covers \Facebook\WebDriver\Exception\Internal\UnexpectedResponseException + */ + public function testShouldThrowExceptionOnUnexpectedValueFromRemoteEndWhenFindingElements(): void + { + $executorMock = $this->createMock(HttpCommandExecutor::class); + $executorMock->expects($this->once()) + ->method('execute') + ->with($this->isInstanceOf(WebDriverCommand::class)) + ->willReturn(new WebDriverResponse('session-id')); + + $this->driver->setCommandExecutor($executorMock); + + $this->expectException(UnexpectedResponseException::class); + $this->expectExceptionMessage('Server response to findElements command is not an array'); + $this->driver->findElements($this->createMock(WebDriverBy::class)); + } +} diff --git a/tests/unit/Remote/RemoteWebElementTest.php b/tests/unit/Remote/RemoteWebElementTest.php new file mode 100644 index 000000000..a7ec76d8a --- /dev/null +++ b/tests/unit/Remote/RemoteWebElementTest.php @@ -0,0 +1,25 @@ +createMock(RemoteExecuteMethod::class); + $element = new RemoteWebElement($executeMethod, 333); + + $this->assertSame(333, $element->getID()); + } +} diff --git a/tests/unit/Remote/WebDriverCommandTest.php b/tests/unit/Remote/WebDriverCommandTest.php index 9993c6ce5..182b72d94 100644 --- a/tests/unit/Remote/WebDriverCommandTest.php +++ b/tests/unit/Remote/WebDriverCommandTest.php @@ -1,23 +1,12 @@ - 'bar']); @@ -25,4 +14,13 @@ public function testShouldSetOptionsUsingConstructor() $this->assertSame('bar-baz-name', $command->getName()); $this->assertSame(['foo' => 'bar'], $command->getParameters()); } + + public function testShouldCreateNewSessionCommand(): void + { + $command = WebDriverCommand::newSession(['bar' => 'baz']); + + $this->assertNull($command->getSessionID()); + $this->assertSame('newSession', $command->getName()); + $this->assertSame(['bar' => 'baz'], $command->getParameters()); + } } diff --git a/tests/unit/Support/ScreenshotHelperTest.php b/tests/unit/Support/ScreenshotHelperTest.php new file mode 100644 index 000000000..5f7ff0ca6 --- /dev/null +++ b/tests/unit/Support/ScreenshotHelperTest.php @@ -0,0 +1,103 @@ +assertDirectoryDoesNotExist($directoryPath); + + $executorMock = $this->createMock(RemoteExecuteMethod::class); + $executorMock->expects($this->once()) + ->method('execute') + ->with($this->equalTo(DriverCommand::SCREENSHOT)) + ->willReturn(self::BLACK_PIXEL); + + $helper = new ScreenshotHelper($executorMock); + $output = $helper->takePageScreenshot($fullFilePath); + + $this->assertSame(base64_decode(self::BLACK_PIXEL, true), $output); + + $this->assertDirectoryExists($directoryPath); + $this->assertFileExists($fullFilePath); + + unlink($fullFilePath); + rmdir($directoryPath); + } + + public function testShouldOnlyReturnBase64IfDirectoryNotProvided(): void + { + $executorMock = $this->createMock(RemoteExecuteMethod::class); + $executorMock->expects($this->once()) + ->method('execute') + ->with($this->equalTo(DriverCommand::SCREENSHOT)) + ->willReturn(self::BLACK_PIXEL); + + $helper = new ScreenshotHelper($executorMock); + $output = $helper->takePageScreenshot(); + + $this->assertSame(base64_decode(self::BLACK_PIXEL, true), $output); + } + + public function testShouldSaveElementScreenshotToSubdirectoryIfNotExists(): void + { + $fullFilePath = sys_get_temp_dir() . '/' . uniqid('php-webdriver-', true) . '/screenshot.png'; + $directoryPath = dirname($fullFilePath); + $this->assertDirectoryDoesNotExist($directoryPath); + $elementId = 'foo-id'; + + $executorMock = $this->createMock(RemoteExecuteMethod::class); + $executorMock->expects($this->once()) + ->method('execute') + ->with(DriverCommand::TAKE_ELEMENT_SCREENSHOT, [':id' => $elementId]) + ->willReturn(self::BLACK_PIXEL); + + $helper = new ScreenshotHelper($executorMock); + $output = $helper->takeElementScreenshot($elementId, $fullFilePath); + + $this->assertSame(base64_decode(self::BLACK_PIXEL, true), $output); + $this->assertDirectoryExists($directoryPath); + $this->assertFileExists($fullFilePath); + + unlink($fullFilePath); + rmdir($directoryPath); + } + + /** + * @dataProvider provideInvalidData + * @param mixed $data + */ + public function testShouldThrowExceptionWhenInvalidDataReceived($data, string $expectedExceptionMessage): void + { + $executorMock = $this->createMock(RemoteExecuteMethod::class); + $executorMock->expects($this->once()) + ->method('execute') + ->with($this->equalTo(DriverCommand::SCREENSHOT)) + ->willReturn($data); + + $helper = new ScreenshotHelper($executorMock); + + $this->expectException(UnexpectedResponseException::class); + $this->expectExceptionMessage($expectedExceptionMessage); + $helper->takePageScreenshot(); + } + + public function provideInvalidData(): array + { + return [ + 'empty response' => [null, 'Error taking screenshot, no data received from the remote end'], + 'not valid base64 response' => ['invalid%base64', 'Error decoding screenshot data'], + ]; + } +} diff --git a/tests/unit/Support/XPathEscaperTest.php b/tests/unit/Support/XPathEscaperTest.php new file mode 100644 index 000000000..9804d93e1 --- /dev/null +++ b/tests/unit/Support/XPathEscaperTest.php @@ -0,0 +1,36 @@ +assertSame($expectedOutput, $output); + } + + /** + * @return array[] + */ + public function provideXpath(): array + { + return [ + 'empty string encapsulate in single quotes' => ['', "''"], + 'string without quotes encapsulate in single quotes' => ['foo bar', "'foo bar'"], + 'string with single quotes encapsulate in double quotes' => ['foo\'bar\'', '"foo\'bar\'"'], + 'string with double quotes encapsulate in single quotes' => ['foo"bar"', '\'foo"bar"\''], + 'string with both types of quotes concatenate' => ['\'"', "concat('', \"'\" ,'\"')"], + 'string with multiple both types of quotes concatenate' => [ + 'a \'b\'"c"', + "concat('a ', \"'\" ,'b', \"'\" ,'\"c\"')", + ], + ]; + } +} diff --git a/tests/unit/WebDriverExpectedConditionTest.php b/tests/unit/WebDriverExpectedConditionTest.php new file mode 100644 index 000000000..4ac75336b --- /dev/null +++ b/tests/unit/WebDriverExpectedConditionTest.php @@ -0,0 +1,430 @@ +driverMock = $this->createMock(RemoteWebDriver::class); + $this->wait = new WebDriverWait($this->driverMock, 1, 1); + } + + public function testShouldDetectTitleIsCondition(): void + { + $this->driverMock->expects($this->any()) + ->method('getTitle') + ->willReturnOnConsecutiveCalls('old', 'oldwithnew', 'new'); + + $condition = WebDriverExpectedCondition::titleIs('new'); + + $this->assertFalse(call_user_func($condition->getApply(), $this->driverMock)); + $this->assertFalse(call_user_func($condition->getApply(), $this->driverMock)); + $this->assertTrue(call_user_func($condition->getApply(), $this->driverMock)); + } + + public function testShouldDetectTitleContainsCondition(): void + { + $this->driverMock->expects($this->any()) + ->method('getTitle') + ->willReturnOnConsecutiveCalls('old', 'oldwithnew', 'new'); + + $condition = WebDriverExpectedCondition::titleContains('new'); + + $this->assertFalse(call_user_func($condition->getApply(), $this->driverMock)); + $this->assertTrue(call_user_func($condition->getApply(), $this->driverMock)); + $this->assertTrue(call_user_func($condition->getApply(), $this->driverMock)); + } + + public function testShouldDetectTitleMatchesCondition(): void + { + $this->driverMock->expects($this->any()) + ->method('getTitle') + ->willReturnOnConsecutiveCalls('non-matching', 'matching-not', 'matching-123'); + + $condition = WebDriverExpectedCondition::titleMatches('/matching-\d{3}/'); + + $this->assertFalse(call_user_func($condition->getApply(), $this->driverMock)); + $this->assertFalse(call_user_func($condition->getApply(), $this->driverMock)); + $this->assertTrue(call_user_func($condition->getApply(), $this->driverMock)); + } + + public function testShouldDetectUrlIsCondition(): void + { + $this->driverMock->expects($this->any()) + ->method('getCurrentURL') + ->willReturnOnConsecutiveCalls('https://old/', 'https://oldwithnew/', 'https://new/'); + + $condition = WebDriverExpectedCondition::urlIs('https://new/'); + + $this->assertFalse(call_user_func($condition->getApply(), $this->driverMock)); + $this->assertFalse(call_user_func($condition->getApply(), $this->driverMock)); + $this->assertTrue(call_user_func($condition->getApply(), $this->driverMock)); + } + + public function testShouldDetectUrlContainsCondition(): void + { + $this->driverMock->expects($this->any()) + ->method('getCurrentURL') + ->willReturnOnConsecutiveCalls('https://old/', 'https://oldwithnew/', 'https://new/'); + + $condition = WebDriverExpectedCondition::urlContains('new'); + + $this->assertFalse(call_user_func($condition->getApply(), $this->driverMock)); + $this->assertTrue(call_user_func($condition->getApply(), $this->driverMock)); + $this->assertTrue(call_user_func($condition->getApply(), $this->driverMock)); + } + + public function testShouldDetectUrlMatchesCondition(): void + { + $this->driverMock->expects($this->any()) + ->method('getCurrentURL') + ->willReturnOnConsecutiveCalls('https://non/matching/', 'https://matching/not/', 'https://matching/123/'); + + $condition = WebDriverExpectedCondition::urlMatches('/matching\/\d{3}\/$/'); + + $this->assertFalse(call_user_func($condition->getApply(), $this->driverMock)); + $this->assertFalse(call_user_func($condition->getApply(), $this->driverMock)); + $this->assertTrue(call_user_func($condition->getApply(), $this->driverMock)); + } + + public function testShouldDetectPresenceOfElementLocatedCondition(): void + { + $element = new RemoteWebElement(new RemoteExecuteMethod($this->driverMock), 'id'); + + $this->driverMock->expects($this->exactly(2)) + ->method('findElement') + ->with($this->isInstanceOf(WebDriverBy::class)) + ->willReturnOnConsecutiveCalls( + $this->throwException(new NoSuchElementException('')), + $element + ); + + $condition = WebDriverExpectedCondition::presenceOfElementLocated(WebDriverBy::cssSelector('.foo')); + + $this->assertSame($element, $this->wait->until($condition)); + } + + public function testShouldDetectNotPresenceOfElementLocatedCondition(): void + { + $element = new RemoteWebElement(new RemoteExecuteMethod($this->driverMock), 'id'); + + $this->driverMock->expects($this->exactly(2)) + ->method('findElement') + ->with($this->isInstanceOf(WebDriverBy::class)) + ->willReturnOnConsecutiveCalls( + $element, + $this->throwException(new NoSuchElementException('')) + ); + + $condition = WebDriverExpectedCondition::not( + WebDriverExpectedCondition::presenceOfElementLocated(WebDriverBy::cssSelector('.foo')) + ); + + $this->assertFalse(call_user_func($condition->getApply(), $this->driverMock)); + $this->assertTrue(call_user_func($condition->getApply(), $this->driverMock)); + } + + public function testShouldDetectPresenceOfAllElementsLocatedByCondition(): void + { + $element = $this->createMock(RemoteWebElement::class); + + $this->driverMock->expects($this->exactly(2)) + ->method('findElements') + ->with($this->isInstanceOf(WebDriverBy::class)) + ->willReturnOnConsecutiveCalls( + [], + [$element] + ); + + $condition = WebDriverExpectedCondition::presenceOfAllElementsLocatedBy(WebDriverBy::cssSelector('.foo')); + + $this->assertSame([$element], $this->wait->until($condition)); + } + + public function testShouldDetectVisibilityOfElementLocatedCondition(): void + { + // Set-up the consecutive calls to apply() as follows: + // Call #1: throws NoSuchElementException + // Call #2: return Element, but isDisplayed will throw StaleElementReferenceException + // Call #3: return Element, but isDisplayed will return false + // Call #4: return Element, isDisplayed will return true and condition will match + + $element = $this->createMock(RemoteWebElement::class); + $element->expects($this->exactly(3)) + ->method('isDisplayed') + ->willReturnOnConsecutiveCalls( + $this->throwException(new StaleElementReferenceException('')), + false, + true + ); + + $this->setupDriverToReturnElementAfterAnException($element, 3); + + $condition = WebDriverExpectedCondition::visibilityOfElementLocated(WebDriverBy::cssSelector('.foo')); + + $this->assertSame($element, $this->wait->until($condition)); + } + + public function testShouldDetectVisibilityOfAnyElementLocated(): void + { + $elementList = [ + $this->createMock(RemoteWebElement::class), + $this->createMock(RemoteWebElement::class), + $this->createMock(RemoteWebElement::class), + ]; + + $elementList[0]->expects($this->once()) + ->method('isDisplayed') + ->willReturn(false); + + $elementList[1]->expects($this->once()) + ->method('isDisplayed') + ->willReturn(true); + + $elementList[2]->expects($this->once()) + ->method('isDisplayed') + ->willReturn(true); + + $this->driverMock->expects($this->once()) + ->method('findElements') + ->with($this->isInstanceOf(WebDriverBy::class)) + ->willReturn($elementList); + + $condition = WebDriverExpectedCondition::visibilityOfAnyElementLocated(WebDriverBy::cssSelector('.foo')); + + $this->assertSame([$elementList[1], $elementList[2]], $this->wait->until($condition)); + } + + public function testShouldDetectInvisibilityOfElementLocatedConditionOnNoSuchElementException(): void + { + $element = $this->createMock(RemoteWebElement::class); + + $this->driverMock->expects($this->exactly(2)) + ->method('findElement') + ->with($this->isInstanceOf(WebDriverBy::class)) + ->willReturn( + $element, + $this->throwException(new NoSuchElementException('')) + ); + + $element->expects($this->once()) + ->method('isDisplayed') + ->willReturn(true); + + $condition = WebDriverExpectedCondition::invisibilityOfElementLocated(WebDriverBy::cssSelector('.foo')); + + $this->assertTrue($this->wait->until($condition)); + } + + public function testShouldDetectInvisibilityOfElementLocatedConditionOnStaleElementReferenceException(): void + { + $element = $this->createMock(RemoteWebElement::class); + + $this->driverMock->expects($this->exactly(2)) + ->method('findElement') + ->with($this->isInstanceOf(WebDriverBy::class)) + ->willReturn($element); + + $element->expects($this->exactly(2)) + ->method('isDisplayed') + ->willReturnOnConsecutiveCalls( + true, + $this->throwException(new StaleElementReferenceException('')) + ); + + $condition = WebDriverExpectedCondition::invisibilityOfElementLocated(WebDriverBy::cssSelector('.foo')); + + $this->assertTrue($this->wait->until($condition)); + } + + public function testShouldDetectInvisibilityOfElementLocatedConditionWhenElementBecamesInvisible(): void + { + $element = $this->createMock(RemoteWebElement::class); + + $this->driverMock->expects($this->exactly(2)) + ->method('findElement') + ->with($this->isInstanceOf(WebDriverBy::class)) + ->willReturn($element); + + $element->expects($this->exactly(2)) + ->method('isDisplayed') + ->willReturnOnConsecutiveCalls( + true, + false + ); + + $condition = WebDriverExpectedCondition::invisibilityOfElementLocated(WebDriverBy::cssSelector('.foo')); + + $this->assertTrue($this->wait->until($condition)); + } + + public function testShouldDetectVisibilityOfCondition(): void + { + $element = $this->createMock(RemoteWebElement::class); + $element->expects($this->exactly(2)) + ->method('isDisplayed') + ->willReturn( + false, + true + ); + + $condition = WebDriverExpectedCondition::visibilityOf($element); + + $this->assertSame($element, $this->wait->until($condition)); + } + + public function testShouldDetectElementTextContainsCondition(): void + { + // Set-up the consecutive calls to apply() as follows: + // Call #1: throws NoSuchElementException + // Call #2: return Element, but getText returns an old text + // Call #3: return Element, but getText will throw StaleElementReferenceException + // Call #4: return Element, getText will return new text and condition will match + + $element = $this->createMock(RemoteWebElement::class); + $element->expects($this->exactly(3)) + ->method('getText') + ->willReturnOnConsecutiveCalls( + 'this is an old text', + $this->throwException(new StaleElementReferenceException('')), + 'this is a new text' + ); + + $this->setupDriverToReturnElementAfterAnException($element, 3); + + $condition = WebDriverExpectedCondition::elementTextContains(WebDriverBy::cssSelector('.foo'), 'new'); + + $this->assertTrue($this->wait->until($condition)); + } + + public function testShouldDetectElementTextIsCondition(): void + { + // Set-up the consecutive calls to apply() as follows: + // Call #1: throws NoSuchElementException + // Call #2: return Element, but getText will throw StaleElementReferenceException + // Call #3: return Element, getText will return not-matching text + // Call #4: return Element, getText will return new text and condition will match + + $element = $this->createMock(RemoteWebElement::class); + $element->expects($this->exactly(3)) + ->method('getText') + ->willReturnOnConsecutiveCalls( + $this->throwException(new StaleElementReferenceException('')), + 'this is a new text, but not exactly', + 'this is a new text' + ); + + $this->setupDriverToReturnElementAfterAnException($element, 3); + + $condition = WebDriverExpectedCondition::elementTextIs( + WebDriverBy::cssSelector('.foo'), + 'this is a new text' + ); + + $this->assertTrue($this->wait->until($condition)); + } + + public function testShouldDetectElementTextMatchesCondition(): void + { + // Set-up the consecutive calls to apply() as follows: + // Call #1: throws NoSuchElementException + // Call #2: return Element, but getText will throw StaleElementReferenceException + // Call #3: return Element, getText will return not-matching text + // Call #4: return Element, getText will return matching text + + $element = $this->createMock(RemoteWebElement::class); + + $element->expects($this->exactly(3)) + ->method('getText') + ->willReturnOnConsecutiveCalls( + $this->throwException(new StaleElementReferenceException('')), + 'non-matching', + 'matching-123' + ); + + $this->setupDriverToReturnElementAfterAnException($element, 3); + + $condition = WebDriverExpectedCondition::elementTextMatches( + WebDriverBy::cssSelector('.foo'), + '/matching-\d{3}/' + ); + + $this->assertTrue($this->wait->until($condition)); + } + + public function testShouldDetectElementValueContainsCondition(): void + { + // Set-up the consecutive calls to apply() as follows: + // Call #1: throws NoSuchElementException + // Call #2: return Element, but getAttribute will throw StaleElementReferenceException + // Call #3: return Element, getAttribute('value') will return not-matching text + // Call #4: return Element, getAttribute('value') will return matching text + + $element = $this->createMock(RemoteWebElement::class); + + $element->expects($this->exactly(3)) + ->method('getAttribute') + ->with('value') + ->willReturnOnConsecutiveCalls( + $this->throwException(new StaleElementReferenceException('')), + 'wrong text', + 'matching text' + ); + + $this->setupDriverToReturnElementAfterAnException($element, 3); + + $condition = WebDriverExpectedCondition::elementValueContains( + WebDriverBy::cssSelector('.foo'), + 'matching' + ); + + $this->assertTrue($this->wait->until($condition)); + } + + public function testShouldDetectNumberOfWindowsToBeCondition(): void + { + $this->driverMock->expects($this->any()) + ->method('getWindowHandles') + ->willReturnOnConsecutiveCalls(['one'], ['one', 'two', 'three'], ['one', 'two']); + + $condition = WebDriverExpectedCondition::numberOfWindowsToBe(2); + + $this->assertFalse(call_user_func($condition->getApply(), $this->driverMock)); + $this->assertFalse(call_user_func($condition->getApply(), $this->driverMock)); + $this->assertTrue(call_user_func($condition->getApply(), $this->driverMock)); + } + + private function setupDriverToReturnElementAfterAnException( + RemoteWebElement $element, + int $expectedNumberOfFindElementCalls + ): void { + $consecutiveReturn = [ + $this->throwException(new NoSuchElementException('')), + ]; + + for ($i = 0; $i < $expectedNumberOfFindElementCalls; $i++) { + $consecutiveReturn[] = $element; + } + + $this->driverMock->expects($this->exactly(count($consecutiveReturn))) + ->method('findElement') + ->with($this->isInstanceOf(WebDriverBy::class)) + ->willReturnOnConsecutiveCalls(...$consecutiveReturn); + } +} diff --git a/tests/unit/WebDriverKeysTest.php b/tests/unit/WebDriverKeysTest.php new file mode 100644 index 000000000..cfa07308f --- /dev/null +++ b/tests/unit/WebDriverKeysTest.php @@ -0,0 +1,59 @@ +assertSame($expectedOssOutput, WebDriverKeys::encode($keys)); + $this->assertSame($expectedW3cOutput, WebDriverKeys::encode($keys, true)); + } + + /** + * @return array[] + */ + public function provideKeys(): array + { + return [ + 'empty string' => ['', [''], ''], + 'simple string' => ['foo', ['foo'], 'foo'], + 'string as an array' => [['foo'], ['foo'], 'foo'], + 'string with modifier as an array' => [ + [WebDriverKeys::SHIFT, 'foo'], + [WebDriverKeys::SHIFT, 'foo'], + WebDriverKeys::SHIFT . 'foo', + ], + 'string with concatenated modifier' => [ + [WebDriverKeys::SHIFT . 'foo'], + [WebDriverKeys::SHIFT . 'foo'], + WebDriverKeys::SHIFT . 'foo', + ], + 'simple numeric value' => [3, ['3'], '3'], + 'multiple numeric values' => [[1, 3.33], ['1', '3.33'], '13.33'], + 'multiple mixed values ' => [ + ['foo', WebDriverKeys::END, '1.234'], + ['foo', WebDriverKeys::END, '1.234'], + 'foo' . WebDriverKeys::END . '1.234', + ], + 'array of strings with modifiers should separate them with NULL character' => [ + [[WebDriverKeys::SHIFT, 'foo'], [WebDriverKeys::META, 'bar']], + [WebDriverKeys::SHIFT . 'foo' . WebDriverKeys::NULL, WebDriverKeys::META . 'bar' . WebDriverKeys::NULL], + WebDriverKeys::SHIFT . 'foo' . WebDriverKeys::NULL . WebDriverKeys::META . 'bar' . WebDriverKeys::NULL, + ], + 'null' => [null, [], ''], + ]; + } +} diff --git a/tests/unit/WebDriverOptionsTest.php b/tests/unit/WebDriverOptionsTest.php new file mode 100644 index 000000000..4f51d12b6 --- /dev/null +++ b/tests/unit/WebDriverOptionsTest.php @@ -0,0 +1,198 @@ +executor = $this->getMockBuilder(ExecuteMethod::class) + ->disableOriginalConstructor() + ->getMock(); + } + + public function testShouldAddCookieFromArray(): void + { + $cookieInArray = [ + 'name' => 'cookieName', + 'value' => 'someValue', + 'path' => '/bar', + 'domain' => 'foo', + 'expiry' => 1485388333, + 'secure' => false, + 'httpOnly' => false, + ]; + + $this->executor->expects($this->once()) + ->method('execute') + ->with(DriverCommand::ADD_COOKIE, ['cookie' => $cookieInArray]); + + $options = new WebDriverOptions($this->executor); + + $options->addCookie($cookieInArray); + } + + public function testShouldAddCookieFromCookieObject(): void + { + $cookieObject = new Cookie('cookieName', 'someValue'); + $cookieObject->setPath('/bar'); + $cookieObject->setDomain('foo'); + $cookieObject->setExpiry(1485388333); + $cookieObject->setSecure(false); + $cookieObject->setHttpOnly(false); + + $expectedCookieData = [ + 'name' => 'cookieName', + 'value' => 'someValue', + 'path' => '/bar', + 'domain' => 'foo', + 'expiry' => 1485388333, + 'secure' => false, + 'httpOnly' => false, + ]; + + $this->executor->expects($this->once()) + ->method('execute') + ->with(DriverCommand::ADD_COOKIE, ['cookie' => $expectedCookieData]); + + $options = new WebDriverOptions($this->executor); + + $options->addCookie($cookieObject); + } + + public function testShouldNotAllowToCreateCookieFromDifferentObjectThanCookie(): void + { + $notCookie = new \stdClass(); + + $options = new WebDriverOptions($this->executor); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Cookie must be set from instance of Cookie class or from array.'); + $options->addCookie($notCookie); + } + + public function testShouldGetAllCookies(): void + { + $this->executor->expects($this->once()) + ->method('execute') + ->with(DriverCommand::GET_ALL_COOKIES) + ->willReturn( + [ + [ + 'path' => '/', + 'domain' => '*.seleniumhq.org', + 'name' => 'firstCookie', + 'httpOnly' => false, + 'secure' => true, + 'value' => 'value', + ], + [ + 'path' => '/', + 'domain' => 'docs.seleniumhq.org', + 'name' => 'secondCookie', + 'httpOnly' => false, + 'secure' => false, + 'value' => 'foo', + ], + ] + ); + + $options = new WebDriverOptions($this->executor); + + $cookies = $options->getCookies(); + + $this->assertCount(2, $cookies); + $this->assertContainsOnlyInstancesOf(Cookie::class, $cookies); + $this->assertSame('firstCookie', $cookies[0]->getName()); + $this->assertSame('secondCookie', $cookies[1]->getName()); + } + + public function testShouldGetCookieByName(): void + { + $this->executor->expects($this->once()) + ->method('execute') + ->with(DriverCommand::GET_ALL_COOKIES) + ->willReturn( + [ + [ + 'path' => '/', + 'domain' => '*.seleniumhq.org', + 'name' => 'cookieToFind', + 'httpOnly' => false, + 'secure' => true, + 'value' => 'value', + ], + [ + 'path' => '/', + 'domain' => 'docs.seleniumhq.org', + 'name' => 'otherCookie', + 'httpOnly' => false, + 'secure' => false, + 'value' => 'foo', + ], + ] + ); + + $options = new WebDriverOptions($this->executor); + + $cookie = $options->getCookieNamed('cookieToFind'); + + $this->assertInstanceOf(Cookie::class, $cookie); + $this->assertSame('cookieToFind', $cookie->getName()); + $this->assertSame('value', $cookie->getValue()); + $this->assertSame('/', $cookie->getPath()); + $this->assertSame('*.seleniumhq.org', $cookie->getDomain()); + $this->assertFalse($cookie->isHttpOnly()); + $this->assertTrue($cookie->isSecure()); + } + + public function testShouldReturnNullIfCookieWithNameNotFound(): void + { + $this->executor->expects($this->once()) + ->method('execute') + ->with(DriverCommand::GET_ALL_COOKIES) + ->willReturn( + [ + [ + 'path' => '/', + 'domain' => '*.seleniumhq.org', + 'name' => 'cookieToNotFind', + 'httpOnly' => false, + 'secure' => true, + 'value' => 'value', + ], + ] + ); + + $options = new WebDriverOptions($this->executor); + + $this->assertNull($options->getCookieNamed('notExistingCookie')); + } + + public function testShouldReturnTimeoutsInstance(): void + { + $options = new WebDriverOptions($this->executor); + + $timeouts = $options->timeouts(); + $this->assertInstanceOf(WebDriverTimeouts::class, $timeouts); + } + + public function testShouldReturnWindowInstance(): void + { + $options = new WebDriverOptions($this->executor); + + $window = $options->window(); + $this->assertInstanceOf(WebDriverWindow::class, $window); + } +} diff --git a/tools/php-cs-fixer/composer.json b/tools/php-cs-fixer/composer.json new file mode 100644 index 000000000..029b32c27 --- /dev/null +++ b/tools/php-cs-fixer/composer.json @@ -0,0 +1,5 @@ +{ + "require": { + "friendsofphp/php-cs-fixer": "^3.0" + } +} diff --git a/tools/phpstan/composer.json b/tools/phpstan/composer.json new file mode 100644 index 000000000..0ac8efe45 --- /dev/null +++ b/tools/phpstan/composer.json @@ -0,0 +1,5 @@ +{ + "require": { + "phpstan/phpstan": "^1.8" + } +}