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 new file mode 100644 index 000000000..2fdc4ff88 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,15 @@ +* text=auto + +/scripts export-ignore +/tests export-ignore +/tools export-ignore +/.gitattributes export-ignore +/.gitignore 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 7e4c563c5..a9384110d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,18 @@ 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 *.DS_Store *~ *.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/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..a468c26f1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,266 @@ +# Changelog +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. + +## 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/). + +## 1.1.2 - 2016-06-04 +- 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. + +## 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`. 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 f279f1f18..f3e8d3801 100644 --- a/README.md +++ b/README.md @@ -1,85 +1,228 @@ -php-webdriver -- WebDriver bindings for PHP -=========================================== +# php-webdriver – Selenium WebDriver bindings for PHP -## DESCRIPTION +[![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) -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. +## Description +Php-webdriver library is PHP language binding for Selenium WebDriver, which allows you to control web browsers from PHP. -Looking for documentation about Selenium WebDriver? See http://docs.seleniumhq.org/docs/ and https://code.google.com/p/selenium/wiki +This library is compatible with Selenium server version 2.x, 3.x and 4.x. -The PHP client was rewritten from scratch. 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/). -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/). -## GETTING THE CODE +## Installation -### Github - git clone git@github.com:facebook/php-webdriver.git +Installation is possible using [Composer](https://getcomposer.org/). -### Packagist -Add the dependency. https://packagist.org/packages/facebook/webdriver - - { - "require": { - "facebook/webdriver": "dev-master" - } - } - -Download the composer.phar +If you don't already use Composer, you can download the `composer.phar` binary: curl -sS https://getcomposer.org/installer | php -Install the library. +Then install the library: + + 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 + +### 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/). + +📙 You can find [further Selenium server information](https://github.com/php-webdriver/php-webdriver/wiki/Selenium-server) +in our wiki. + +#### d) Docker + +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 +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()); +``` + +### 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(); + +// Disable accepting SSL certificates +$desiredCapabilities->setCapability('acceptSslCerts', false); + +// Add arguments via FirefoxOptions to start headless firefox +$firefoxOptions = new FirefoxOptions(); +$firefoxOptions->addArguments(['-headless']); +$desiredCapabilities->setCapability(FirefoxOptions::CAPABILITY, $firefoxOptions); + +$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). + +* 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 +// Go to URL +$driver->get('https://en.wikipedia.org/wiki/Selenium_(software)'); - php composer.phar install - - +// 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 -## GETTING STARTED +// 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(); -* All you need as the server for this client is the selenium-server-standalone-#.jar file provided here: http://code.google.com/p/selenium/downloads/list +// Click the element to navigate to revision history page +$historyButton->click(); -* Download and run that file, replacing # with the current server version. +// Make sure to always call quit() at the end to terminate the browser session +$driver->quit(); +``` - java -jar selenium-server-standalone-#.jar +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. -* Then when you create a session, be sure to pass the url to where your server is running. +**NOTE:** Above snippets are not intended to be a working example by simply copy-pasting. See [example.php](example.php) for a working example. - // This would be the url of the host running the server-standalone.jar - $host = 'http://localhost:4444/wd/hub'; // this is the default - $capabilities = array(WebDriverCapabilityType::BROWSER_NAME => 'firefox'); - $driver = RemoteWebDriver::create($host, $capabilities); +## Changelog +For latest changes see [CHANGELOG.md](CHANGELOG.md) file. -* The $capabilities array lets you specify (among other things) which browser to use. See https://code.google.com/p/selenium/wiki/DesiredCapabilities for more details. +## More information -## MORE INFORMATION +Some basic usage example is provided in [example.php](example.php) file. -Check out the Selenium docs and wiki at http://docs.seleniumhq.org/docs/ and https://code.google.com/p/selenium/wiki +How-tos are provided right here in [📙 our GitHub wiki](https://github.com/php-webdriver/php-webdriver/wiki). -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) +If you don't use IDE, you may use [API documentation of php-webdriver](https://php-webdriver.github.io/php-webdriver/latest/). -## SUPPORT +You may also want to check out the Selenium project [docs](https://selenium.dev/documentation/en/) and [wiki](https://github.com/SeleniumHQ/selenium/wiki). -We have a great community willing to try and help you! +## Testing framework integration -Currently we offer support in two manners: +To take advantage of automatized testing you may want to integrate php-webdriver to your testing framework. +There are some projects already providing this: -### Via our Facebook Group +- [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 -If you have questions or are an active contributor consider joining our facebook group and contributing to the communal discussion and support +## Support -https://www.facebook.com/groups/phpwebdriver/ +We have a great community willing to help you! -### Via Github +❓ 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)). -If you're reading this you've already found our Github repository. If you have a question, feel free to submit it as an issue and our staff will do their best to help you as soon as possible. +🐛 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. -## CONTRIBUTING +📙 Looking for a **how-to** or **reference documentation**? See [our wiki](https://github.com/php-webdriver/php-webdriver/wiki). -We love to have your help to make php-webdriver better. Feel free to +## Contributing ❤️ -* open an [issue](https://github.com/facebook/php-webdriver/issues) if you run into any problem. -* fork the project and submit [pull request](https://github.com/facebook/php-webdriver/pulls). Before the pull requests can be accepted, a [Contributors Licensing Agreement](http://developers.facebook.com/opensource/cla) must be signed. +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. -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://selenium.googlecode.com/svn/trunk/docs/api/java/index.html?overview-summary.html) +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 108f2898f..f44663100 100644 --- a/composer.json +++ b/composer.json @@ -1,20 +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.3.19", - "phpunit/phpunit": "3.7.*" + "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": { + "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": { + "ext-simplexml": "For Firefox profile creation" }, + "minimum-stability": "dev", + "prefer-stable": true, "autoload": { - "classmap": ["lib/"] + "psr-4": { + "Facebook\\WebDriver\\": "lib/" + }, + "files": [ + "lib/Exception/TimeoutException.php" + ] + }, + "autoload-dev": { + "psr-4": { + "Facebook\\WebDriver\\": [ + "tests/unit", + "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 39ffb5a96..92cd431d6 100644 --- a/example.php +++ b/example.php @@ -1,49 +1,74 @@ 'firefox'); -$driver = RemoteWebDriver::create($host, $capabilities, 5000); +use Facebook\WebDriver\Remote\DesiredCapabilities; +use Facebook\WebDriver\Remote\RemoteWebDriver; -// navigate to 'http://docs.seleniumhq.org/' -$driver->get('http://docs.seleniumhq.org/'); +require_once('vendor/autoload.php'); -// adding cookie -$driver->manage()->deleteAllCookies(); -$driver->manage()->addCookie(array( - 'name' => 'cookie_name', - 'value' => 'cookie_value', -)); -$cookies = $driver->manage()->getCookies(); -print_r($cookies); +// This is where Selenium, Chromedriver and Geckodriver 4 listens by default. For Selenium 2/3, use http://localhost:4444/wd/hub +$host = 'http://localhost:4444/'; + +$capabilities = DesiredCapabilities::chrome(); + +$driver = RemoteWebDriver::create($host, $capabilities); -// click the link 'About' -$link = $driver->findElement( - WebDriverBy::id('menu_about') +// 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') ); -$link->click(); -// print the title of the current page -echo "The title is " . $driver->getTitle() . "'\n"; +// print title of the current page to output +echo "The title is '" . $driver->getTitle() . "'\n"; -// print the title of the current page -echo "The current URI is " . $driver->getCurrentURL() . "'\n"; +// print URL of current page to output +echo "The current URL is '" . $driver->getCurrentURL() . "'\n"; -// Search 'php' in the search box -$input = $driver->findElement( - WebDriverBy::id('q') +// find element of 'History' item in menu +$historyButton = $driver->findElement( + WebDriverBy::cssSelector('#ca-history a') ); -$input->sendKeys('php')->submit(); -// wait at most 10 seconds until at least one result is shown -$driver->wait(10)->until( - WebDriverExpectedCondition::presenceOfAllElementsLocatedBy( - WebDriverBy::className('gsc-result') - ) +// 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') ); -// close the Firefox +// 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"; + +// delete all cookies +$driver->manage()->deleteAllCookies(); + +// 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); + +// 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 new file mode 100644 index 000000000..e947a498e --- /dev/null +++ b/lib/Chrome/ChromeDriver.php @@ -0,0 +1,107 @@ + [ + 'firstMatch' => [(object) $capabilities->toW3cCompatibleArray()], + ], + 'desiredCapabilities' => (object) $capabilities->toArray(), + ] + ); + + $response = $executor->execute($newSessionCommand); + + /* + * 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 = WebDriverCommand::newSession( + [ + 'capabilities' => [ + 'firstMatch' => [(object) $desired_capabilities->toW3cCompatibleArray()], + ], + 'desiredCapabilities' => (object) $desired_capabilities->toArray(), + ] + ); + $response = $this->executor->execute($command); + $value = $response->getValue(); + + if (!$this->isW3cCompliant = isset($value['capabilities'])) { + $this->executor->disableW3cCompliance(); + } + + $this->sessionID = $response->getSessionID(); + } + + /** + * @return ChromeDevToolsDriver + */ + 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 new file mode 100644 index 000000000..902a48ded --- /dev/null +++ b/lib/Chrome/ChromeDriverService.php @@ -0,0 +1,37 @@ +toArray(); + } + + /** + * Sets the path of the Chrome executable. The path should be either absolute + * or relative to the location running ChromeDriver server. + * + * @param string $path + * @return ChromeOptions + */ + public function setBinary($path) + { + $this->binary = $path; + + return $this; + } + + /** + * @return ChromeOptions + */ + public function addArguments(array $arguments) + { + $this->arguments = array_merge($this->arguments, $arguments); + + return $this; + } + + /** + * Add a Chrome extension to install on browser startup. Each path should be + * a packed Chrome extension. + * + * @return ChromeOptions + */ + public function addExtensions(array $paths) + { + foreach ($paths as $path) { + $this->addExtension($path); + } + + return $this; + } + + /** + * @param array $encoded_extensions An array of base64 encoded of the extensions. + * @return ChromeOptions + */ + public function addEncodedExtensions(array $encoded_extensions) + { + foreach ($encoded_extensions as $encoded_extension) { + $this->addEncodedExtension($encoded_extension); + } + + return $this; + } + + /** + * 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 + */ + public function setExperimentalOption($name, $value) + { + $this->experimentalOptions[$name] = $value; + + return $this; + } + + /** + * @return DesiredCapabilities The DesiredCapabilities for Chrome with this options. + */ + public function toCapabilities() + { + $capabilities = DesiredCapabilities::chrome(); + $capabilities->setCapability(self::CAPABILITY, $this); + + return $capabilities; + } + + /** + * @return \ArrayObject|array + */ + public function toArray() + { + // 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 work + // with `ArrayObject` + $options = new \ArrayObject($this->experimentalOptions); + + if (!empty($this->binary)) { + $options['binary'] = $this->binary; + } + + if (!empty($this->arguments)) { + $options['args'] = $this->arguments; + } + + if (!empty($this->extensions)) { + $options['extensions'] = $this->extensions; + } + + return $options; + } + + /** + * Add a Chrome extension to install on browser startup. Each path should be a + * packed Chrome extension. + * + * @param string $path + * @return ChromeOptions + */ + private function addExtension($path) + { + $this->addEncodedExtension(base64_encode(file_get_contents($path))); + + return $this; + } + + /** + * @param string $encoded_extension Base64 encoded of the extension. + * @return ChromeOptions + */ + private function addEncodedExtension($encoded_extension) + { + $this->extensions[] = $encoded_extension; + + return $this; + } +} 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 @@ +results = $results; + } + + /** + * @return mixed + */ + public function getResults() + { + return $this->results; + } + + /** + * Throw WebDriverExceptions based on WebDriver 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 + * @throws NoScriptResultException + * @throws NoStringException + * @throws NoStringLengthException + * @throws NoStringWrapperException + * @throws NoSuchAlertException + * @throws NoSuchCollectionException + * @throws NoSuchCookieException + * @throws NoSuchDocumentException + * @throws NoSuchDriverException + * @throws NoSuchElementException + * @throws NoSuchFrameException + * @throws NoSuchWindowException + * @throws NullPointerException + * @throws ScriptTimeoutException + * @throws SessionNotCreatedException + * @throws StaleElementReferenceException + * @throws TimeoutException + * @throws UnableToCaptureScreenException + * @throws UnableToSetCookieException + * @throws UnexpectedAlertOpenException + * @throws UnexpectedJavascriptException + * @throws UnknownCommandException + * @throws UnknownErrorException + * @throws UnknownMethodException + * @throws UnknownServerException + * @throws UnrecognizedExceptionException + * @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 IndexOutOfBoundsException($message, $results); + case 2: + throw new NoCollectionException($message, $results); + case 3: + throw new NoStringException($message, $results); + case 4: + throw new NoStringLengthException($message, $results); + case 5: + throw new NoStringWrapperException($message, $results); + case 6: + throw new NoSuchDriverException($message, $results); + case 7: + throw new NoSuchElementException($message, $results); + case 8: + throw new NoSuchFrameException($message, $results); + case 9: + throw new UnknownCommandException($message, $results); + case 10: + throw new StaleElementReferenceException($message, $results); + case 11: + throw new ElementNotVisibleException($message, $results); + case 12: + throw new InvalidElementStateException($message, $results); + case 13: + throw new UnknownServerException($message, $results); + case 14: + throw new ExpectedException($message, $results); + case 15: + throw new ElementNotSelectableException($message, $results); + case 16: + throw new NoSuchDocumentException($message, $results); + case 17: + throw new UnexpectedJavascriptException($message, $results); + case 18: + throw new NoScriptResultException($message, $results); + case 19: + throw new XPathLookupException($message, $results); + case 20: + throw new NoSuchCollectionException($message, $results); + case 21: + throw new TimeoutException($message, $results); + case 22: + throw new NullPointerException($message, $results); + case 23: + throw new NoSuchWindowException($message, $results); + case 24: + throw new InvalidCookieDomainException($message, $results); + case 25: + throw new UnableToSetCookieException($message, $results); + case 26: + throw new UnexpectedAlertOpenException($message, $results); + case 27: + throw new NoAlertOpenException($message, $results); + case 28: + throw new ScriptTimeoutException($message, $results); + case 29: + throw new InvalidCoordinatesException($message, $results); + case 30: + throw new IMENotAvailableException($message, $results); + case 31: + throw new IMEEngineActivationFailedException($message, $results); + case 32: + throw new InvalidSelectorException($message, $results); + case 33: + throw new SessionNotCreatedException($message, $results); + case 34: + throw new MoveTargetOutOfBoundsException($message, $results); + default: + throw new UnrecognizedExceptionException($message, $results); + } + } +} diff --git a/lib/Exception/XPathLookupException.php b/lib/Exception/XPathLookupException.php new file mode 100644 index 000000000..86513db58 --- /dev/null +++ b/lib/Exception/XPathLookupException.php @@ -0,0 +1,10 @@ +setProfile($profile->encode()); + * $capabilities = DesiredCapabilities::firefox(); + * $capabilities->setCapability(FirefoxOptions::CAPABILITY, $firefoxOptions); + */ + public const PROFILE = 'firefox_profile'; + + /** + * 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 new file mode 100644 index 000000000..2a33fb003 --- /dev/null +++ b/lib/Firefox/FirefoxPreferences.php @@ -0,0 +1,25 @@ +extensions[] = $extension; + + return $this; + } + + /** + * @param string $extension_datas The path to the folder containing the datas to add to the extension + * @return FirefoxProfile + */ + public function addExtensionDatas($extension_datas) + { + if (!is_dir($extension_datas)) { + return null; + } + + $this->extensions_datas[basename($extension_datas)] = $extension_datas; + + return $this; + } + + /** + * @param string $rdf_file The path to the rdf file + * @return FirefoxProfile + */ + public function setRdfFile($rdf_file) + { + if (!is_file($rdf_file)) { + return null; + } + + $this->rdf_file = $rdf_file; + + return $this; + } + + /** + * @param string $key + * @param string|bool|int $value + * @throws LogicException + * @return FirefoxProfile + */ + public function setPreference($key, $value) + { + if (is_string($value)) { + $value = sprintf('"%s"', $value); + } else { + if (is_int($value)) { + $value = sprintf('%d', $value); + } else { + if (is_bool($value)) { + $value = $value ? 'true' : 'false'; + } else { + throw LogicException::forError( + 'The value of the preference should be either a string, int or bool.' + ); + } + } + } + $this->preferences[$key] = $value; + + return $this; + } + + /** + * @param mixed $key + * @return mixed + */ + public function getPreference($key) + { + if (array_key_exists($key, $this->preferences)) { + return $this->preferences[$key]; + } + + return null; + } + + /** + * @return string + */ + public function encode() + { + $temp_dir = $this->createTempDirectory('WebDriverFirefoxProfile'); + + if (isset($this->rdf_file)) { + copy($this->rdf_file, $temp_dir . DIRECTORY_SEPARATOR . 'mimeTypes.rdf'); + } + + foreach ($this->extensions as $extension) { + $this->installExtension($extension, $temp_dir); + } + + foreach ($this->extensions_datas as $dirname => $extension_datas) { + mkdir($temp_dir . DIRECTORY_SEPARATOR . $dirname); + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($extension_datas, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST + ); + foreach ($iterator as $item) { + $target_dir = $temp_dir . DIRECTORY_SEPARATOR . $dirname . DIRECTORY_SEPARATOR + . $iterator->getSubPathName(); + + if ($item->isDir()) { + mkdir($target_dir); + } else { + copy($item, $target_dir); + } + } + } + + $content = ''; + foreach ($this->preferences as $key => $value) { + $content .= sprintf("user_pref(\"%s\", %s);\n", $key, $value); + } + file_put_contents($temp_dir . '/user.js', $content); + + // Intentionally do not use `tempnam()`, as it creates empty file which zip extension may not handle. + $temp_zip = sys_get_temp_dir() . '/' . uniqid('WebDriverFirefoxProfileZip', false); + + $zip = new ZipArchive(); + $zip->open($temp_zip, ZipArchive::CREATE); + + $dir = new RecursiveDirectoryIterator($temp_dir); + $files = new RecursiveIteratorIterator($dir); + + $dir_prefix = preg_replace( + '#\\\\#', + '\\\\\\\\', + $temp_dir . DIRECTORY_SEPARATOR + ); + + foreach ($files as $name => $object) { + if (is_dir($name)) { + continue; + } + + $path = preg_replace("#^{$dir_prefix}#", '', $name); + $zip->addFile($name, $path); + } + $zip->close(); + + $profile = base64_encode(file_get_contents($temp_zip)); + + // clean up + $this->deleteDirectory($temp_dir); + unlink($temp_zip); + + return $profile; + } + + /** + * @param string $extension The path to the extension. + * @param string $profileDir The path to the profile directory. + * @throws IOException + */ + private function installExtension($extension, $profileDir) + { + $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 + ); + } + + 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 IOException + * @return string The path to the temp directory created. + */ + private function createTempDirectory($prefix = '') + { + $temp_dir = tempnam(sys_get_temp_dir(), $prefix); + if (file_exists($temp_dir)) { + unlink($temp_dir); + mkdir($temp_dir); + if (!is_dir($temp_dir)) { + throw IOException::forFileError( + 'Cannot install Firefox extension - cannot create directory', + $temp_dir + ); + } + } + + return $temp_dir; + } + + /** + * @param string $directory The path to the directory. + */ + private function deleteDirectory($directory) + { + $dir = new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS); + $paths = new RecursiveIteratorIterator($dir, RecursiveIteratorIterator::CHILD_FIRST); + + foreach ($paths as $path) { + if ($path->isDir() && !$path->isLink()) { + rmdir($path->getPathname()); + } else { + unlink($path->getPathname()); + } + } + + rmdir($directory); + } + + /** + * @param string $xpi The path to the .xpi extension. + * @param string $target_dir The path to the unzip directory. + * + * @throws IOException + * @return FirefoxProfile + */ + private function extractTo($xpi, $target_dir) + { + $zip = new ZipArchive(); + if (file_exists($xpi)) { + if ($zip->open($xpi)) { + $zip->extractTo($target_dir); + $zip->close(); + } else { + throw IOException::forFileError('Failed to open the firefox extension.', $xpi); + } + } else { + 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 new file mode 100644 index 000000000..91cad24af --- /dev/null +++ b/lib/Interactions/Internal/WebDriverButtonReleaseAction.php @@ -0,0 +1,16 @@ +mouse->mouseUp($this->getActionLocation()); + } +} diff --git a/lib/Interactions/Internal/WebDriverClickAction.php b/lib/Interactions/Internal/WebDriverClickAction.php new file mode 100644 index 000000000..e21b88336 --- /dev/null +++ b/lib/Interactions/Internal/WebDriverClickAction.php @@ -0,0 +1,13 @@ +mouse->click($this->getActionLocation()); + } +} diff --git a/lib/Interactions/Internal/WebDriverClickAndHoldAction.php b/lib/Interactions/Internal/WebDriverClickAndHoldAction.php new file mode 100644 index 000000000..5f5042c0b --- /dev/null +++ b/lib/Interactions/Internal/WebDriverClickAndHoldAction.php @@ -0,0 +1,16 @@ +mouse->mouseDown($this->getActionLocation()); + } +} diff --git a/lib/Interactions/Internal/WebDriverContextClickAction.php b/lib/Interactions/Internal/WebDriverContextClickAction.php new file mode 100644 index 000000000..493978bba --- /dev/null +++ b/lib/Interactions/Internal/WebDriverContextClickAction.php @@ -0,0 +1,16 @@ +mouse->contextClick($this->getActionLocation()); + } +} diff --git a/lib/Interactions/Internal/WebDriverCoordinates.php b/lib/Interactions/Internal/WebDriverCoordinates.php new file mode 100644 index 000000000..3a92f0a64 --- /dev/null +++ b/lib/Interactions/Internal/WebDriverCoordinates.php @@ -0,0 +1,77 @@ +onScreen = $on_screen; + $this->inViewPort = $in_view_port; + $this->onPage = $on_page; + $this->auxiliary = $auxiliary; + } + + /** + * @throws UnsupportedOperationException + * @return WebDriverPoint + */ + public function onScreen() + { + throw new UnsupportedOperationException( + 'onScreen is planned but not yet supported by Selenium' + ); + } + + /** + * @return WebDriverPoint + */ + public function inViewPort() + { + return call_user_func($this->inViewPort); + } + + /** + * @return WebDriverPoint + */ + public function onPage() + { + return call_user_func($this->onPage); + } + + /** + * @return string The attached object id. + */ + public function getAuxiliary() + { + return $this->auxiliary; + } +} diff --git a/lib/Interactions/Internal/WebDriverDoubleClickAction.php b/lib/Interactions/Internal/WebDriverDoubleClickAction.php new file mode 100644 index 000000000..386c49639 --- /dev/null +++ b/lib/Interactions/Internal/WebDriverDoubleClickAction.php @@ -0,0 +1,13 @@ +mouse->doubleClick($this->getActionLocation()); + } +} diff --git a/lib/Interactions/Internal/WebDriverKeyDownAction.php b/lib/Interactions/Internal/WebDriverKeyDownAction.php new file mode 100644 index 000000000..415ebe7f1 --- /dev/null +++ b/lib/Interactions/Internal/WebDriverKeyDownAction.php @@ -0,0 +1,12 @@ +focusOnElement(); + $this->keyboard->pressKey($this->key); + } +} diff --git a/lib/Interactions/Internal/WebDriverKeyUpAction.php b/lib/Interactions/Internal/WebDriverKeyUpAction.php new file mode 100644 index 000000000..0cdb3a84f --- /dev/null +++ b/lib/Interactions/Internal/WebDriverKeyUpAction.php @@ -0,0 +1,12 @@ +focusOnElement(); + $this->keyboard->releaseKey($this->key); + } +} diff --git a/lib/Interactions/Internal/WebDriverKeysRelatedAction.php b/lib/Interactions/Internal/WebDriverKeysRelatedAction.php new file mode 100644 index 000000000..a5ba0875f --- /dev/null +++ b/lib/Interactions/Internal/WebDriverKeysRelatedAction.php @@ -0,0 +1,43 @@ +keyboard = $keyboard; + $this->mouse = $mouse; + $this->locationProvider = $location_provider; + } + + protected function focusOnElement() + { + if ($this->locationProvider) { + $this->mouse->click($this->locationProvider->getCoordinates()); + } + } +} diff --git a/lib/Interactions/Internal/WebDriverMouseAction.php b/lib/Interactions/Internal/WebDriverMouseAction.php new file mode 100644 index 000000000..ecb1127ee --- /dev/null +++ b/lib/Interactions/Internal/WebDriverMouseAction.php @@ -0,0 +1,44 @@ +mouse = $mouse; + $this->locationProvider = $location_provider; + } + + /** + * @return null|WebDriverCoordinates + */ + protected function getActionLocation() + { + if ($this->locationProvider !== null) { + return $this->locationProvider->getCoordinates(); + } + + return null; + } + + protected function moveToLocation() + { + $this->mouse->mouseMove($this->locationProvider); + } +} diff --git a/lib/Interactions/Internal/WebDriverMouseMoveAction.php b/lib/Interactions/Internal/WebDriverMouseMoveAction.php new file mode 100644 index 000000000..1969f01ba --- /dev/null +++ b/lib/Interactions/Internal/WebDriverMouseMoveAction.php @@ -0,0 +1,13 @@ +mouse->mouseMove($this->getActionLocation()); + } +} diff --git a/lib/Interactions/Internal/WebDriverMoveToOffsetAction.php b/lib/Interactions/Internal/WebDriverMoveToOffsetAction.php new file mode 100644 index 000000000..c865da46a --- /dev/null +++ b/lib/Interactions/Internal/WebDriverMoveToOffsetAction.php @@ -0,0 +1,43 @@ +xOffset = $x_offset; + $this->yOffset = $y_offset; + } + + public function perform() + { + $this->mouse->mouseMove( + $this->getActionLocation(), + $this->xOffset, + $this->yOffset + ); + } +} diff --git a/lib/Interactions/Internal/WebDriverSendKeysAction.php b/lib/Interactions/Internal/WebDriverSendKeysAction.php new file mode 100644 index 000000000..2ed3cfd06 --- /dev/null +++ b/lib/Interactions/Internal/WebDriverSendKeysAction.php @@ -0,0 +1,35 @@ +keys = $keys; + } + + public function perform() + { + $this->focusOnElement(); + $this->keyboard->sendKeys($this->keys); + } +} diff --git a/lib/Interactions/Internal/WebDriverSingleKeyAction.php b/lib/Interactions/Internal/WebDriverSingleKeyAction.php new file mode 100644 index 000000000..6efe9384a --- /dev/null +++ b/lib/Interactions/Internal/WebDriverSingleKeyAction.php @@ -0,0 +1,54 @@ +key = $key; + } +} diff --git a/lib/Interactions/Touch/WebDriverDoubleTapAction.php b/lib/Interactions/Touch/WebDriverDoubleTapAction.php new file mode 100644 index 000000000..25a1761b3 --- /dev/null +++ b/lib/Interactions/Touch/WebDriverDoubleTapAction.php @@ -0,0 +1,13 @@ +touchScreen->doubleTap($this->locationProvider); + } +} diff --git a/lib/Interactions/Touch/WebDriverDownAction.php b/lib/Interactions/Touch/WebDriverDownAction.php new file mode 100644 index 000000000..225726fcf --- /dev/null +++ b/lib/Interactions/Touch/WebDriverDownAction.php @@ -0,0 +1,33 @@ +x = $x; + $this->y = $y; + parent::__construct($touch_screen); + } + + public function perform() + { + $this->touchScreen->down($this->x, $this->y); + } +} diff --git a/lib/Interactions/Touch/WebDriverFlickAction.php b/lib/Interactions/Touch/WebDriverFlickAction.php new file mode 100644 index 000000000..acdb7fc3e --- /dev/null +++ b/lib/Interactions/Touch/WebDriverFlickAction.php @@ -0,0 +1,33 @@ +x = $x; + $this->y = $y; + parent::__construct($touch_screen); + } + + public function perform() + { + $this->touchScreen->flick($this->x, $this->y); + } +} diff --git a/lib/Interactions/Touch/WebDriverFlickFromElementAction.php b/lib/Interactions/Touch/WebDriverFlickFromElementAction.php new file mode 100644 index 000000000..28d359718 --- /dev/null +++ b/lib/Interactions/Touch/WebDriverFlickFromElementAction.php @@ -0,0 +1,50 @@ +x = $x; + $this->y = $y; + $this->speed = $speed; + parent::__construct($touch_screen, $element); + } + + public function perform() + { + $this->touchScreen->flickFromElement( + $this->locationProvider, + $this->x, + $this->y, + $this->speed + ); + } +} diff --git a/lib/Interactions/Touch/WebDriverLongPressAction.php b/lib/Interactions/Touch/WebDriverLongPressAction.php new file mode 100644 index 000000000..7c1a165c6 --- /dev/null +++ b/lib/Interactions/Touch/WebDriverLongPressAction.php @@ -0,0 +1,13 @@ +touchScreen->longPress($this->locationProvider); + } +} diff --git a/lib/Interactions/Touch/WebDriverMoveAction.php b/lib/Interactions/Touch/WebDriverMoveAction.php new file mode 100644 index 000000000..d0a5f85f9 --- /dev/null +++ b/lib/Interactions/Touch/WebDriverMoveAction.php @@ -0,0 +1,27 @@ +x = $x; + $this->y = $y; + parent::__construct($touch_screen); + } + + public function perform() + { + $this->touchScreen->move($this->x, $this->y); + } +} diff --git a/lib/Interactions/Touch/WebDriverScrollAction.php b/lib/Interactions/Touch/WebDriverScrollAction.php new file mode 100644 index 000000000..952d57e34 --- /dev/null +++ b/lib/Interactions/Touch/WebDriverScrollAction.php @@ -0,0 +1,27 @@ +x = $x; + $this->y = $y; + parent::__construct($touch_screen); + } + + public function perform() + { + $this->touchScreen->scroll($this->x, $this->y); + } +} diff --git a/lib/Interactions/Touch/WebDriverScrollFromElementAction.php b/lib/Interactions/Touch/WebDriverScrollFromElementAction.php new file mode 100644 index 000000000..217564dc7 --- /dev/null +++ b/lib/Interactions/Touch/WebDriverScrollFromElementAction.php @@ -0,0 +1,36 @@ +x = $x; + $this->y = $y; + parent::__construct($touch_screen, $element); + } + + public function perform() + { + $this->touchScreen->scrollFromElement( + $this->locationProvider, + $this->x, + $this->y + ); + } +} diff --git a/lib/Interactions/Touch/WebDriverTapAction.php b/lib/Interactions/Touch/WebDriverTapAction.php new file mode 100644 index 000000000..63527e810 --- /dev/null +++ b/lib/Interactions/Touch/WebDriverTapAction.php @@ -0,0 +1,13 @@ +touchScreen->tap($this->locationProvider); + } +} diff --git a/lib/Interactions/Touch/WebDriverTouchAction.php b/lib/Interactions/Touch/WebDriverTouchAction.php new file mode 100644 index 000000000..10100ea21 --- /dev/null +++ b/lib/Interactions/Touch/WebDriverTouchAction.php @@ -0,0 +1,38 @@ +touchScreen = $touch_screen; + $this->locationProvider = $location_provider; + } + + /** + * @return null|WebDriverCoordinates + */ + protected function getActionLocation() + { + return $this->locationProvider !== null + ? $this->locationProvider->getCoordinates() : null; + } +} diff --git a/lib/Interactions/Touch/WebDriverTouchScreen.php b/lib/Interactions/Touch/WebDriverTouchScreen.php new file mode 100644 index 000000000..ff9f9c4d1 --- /dev/null +++ b/lib/Interactions/Touch/WebDriverTouchScreen.php @@ -0,0 +1,109 @@ +driver = $driver; + $this->keyboard = $driver->getKeyboard(); + $this->mouse = $driver->getMouse(); + $this->action = new WebDriverCompositeAction(); + } + + /** + * A convenience method for performing the actions without calling build(). + */ + public function perform() + { + $this->action->perform(); + } + + /** + * Mouse click. + * If $element is provided, move to the middle of the element first. + * + * @return WebDriverActions + */ + public function click(?WebDriverElement $element = null) + { + $this->action->addAction( + new WebDriverClickAction($this->mouse, $element) + ); + + return $this; + } + + /** + * Mouse click and hold. + * If $element is provided, move to the middle of the element first. + * + * @return WebDriverActions + */ + public function clickAndHold(?WebDriverElement $element = null) + { + $this->action->addAction( + new WebDriverClickAndHoldAction($this->mouse, $element) + ); + + return $this; + } + + /** + * Context-click (right click). + * If $element is provided, move to the middle of the element first. + * + * @return WebDriverActions + */ + public function contextClick(?WebDriverElement $element = null) + { + $this->action->addAction( + new WebDriverContextClickAction($this->mouse, $element) + ); + + return $this; + } + + /** + * Double click. + * If $element is provided, move to the middle of the element first. + * + * @return WebDriverActions + */ + public function doubleClick(?WebDriverElement $element = null) + { + $this->action->addAction( + new WebDriverDoubleClickAction($this->mouse, $element) + ); + + return $this; + } + + /** + * Drag and drop from $source to $target. + * + * @return WebDriverActions + */ + public function dragAndDrop(WebDriverElement $source, WebDriverElement $target) + { + $this->action->addAction( + new WebDriverClickAndHoldAction($this->mouse, $source) + ); + $this->action->addAction( + new WebDriverMouseMoveAction($this->mouse, $target) + ); + $this->action->addAction( + new WebDriverButtonReleaseAction($this->mouse, $target) + ); + + return $this; + } + + /** + * Drag $source and drop by offset ($x_offset, $y_offset). + * + * @param int $x_offset + * @param int $y_offset + * @return WebDriverActions + */ + public function dragAndDropBy(WebDriverElement $source, $x_offset, $y_offset) + { + $this->action->addAction( + new WebDriverClickAndHoldAction($this->mouse, $source) + ); + $this->action->addAction( + new WebDriverMoveToOffsetAction($this->mouse, null, $x_offset, $y_offset) + ); + $this->action->addAction( + new WebDriverButtonReleaseAction($this->mouse, null) + ); + + return $this; + } + + /** + * Mouse move by offset. + * + * @param int $x_offset + * @param int $y_offset + * @return WebDriverActions + */ + public function moveByOffset($x_offset, $y_offset) + { + $this->action->addAction( + new WebDriverMoveToOffsetAction($this->mouse, null, $x_offset, $y_offset) + ); + + return $this; + } + + /** + * Move to the middle of the given WebDriverElement. + * Extra shift, calculated from the top-left corner of the element, can be set by passing $x_offset and $y_offset + * parameters. + * + * @param int $x_offset + * @param int $y_offset + * @return WebDriverActions + */ + public function moveToElement(WebDriverElement $element, $x_offset = null, $y_offset = null) + { + $this->action->addAction(new WebDriverMoveToOffsetAction( + $this->mouse, + $element, + $x_offset, + $y_offset + )); + + return $this; + } + + /** + * Release the mouse button. + * If $element is provided, move to the middle of the element first. + * + * @return WebDriverActions + */ + public function release(?WebDriverElement $element = null) + { + $this->action->addAction( + new WebDriverButtonReleaseAction($this->mouse, $element) + ); + + return $this; + } + + /** + * Press a key on keyboard. + * If $element is provided, focus on that element first. + * + * @see WebDriverKeys for special keys like CONTROL, ALT, etc. + * @param string $key + * @return WebDriverActions + */ + public function keyDown(?WebDriverElement $element = null, $key = null) + { + $this->action->addAction( + new WebDriverKeyDownAction($this->keyboard, $this->mouse, $element, $key) + ); + + return $this; + } + + /** + * Release a key on keyboard. + * If $element is provided, focus on that element first. + * + * @see WebDriverKeys for special keys like CONTROL, ALT, etc. + * @param string $key + * @return WebDriverActions + */ + public function keyUp(?WebDriverElement $element = null, $key = null) + { + $this->action->addAction( + new WebDriverKeyUpAction($this->keyboard, $this->mouse, $element, $key) + ); + + return $this; + } + + /** + * Send keys by keyboard. + * If $element is provided, focus on that element first (using single mouse click). + * + * @see WebDriverKeys for special keys like CONTROL, ALT, etc. + * @param string $keys + * @return WebDriverActions + */ + public function sendKeys(?WebDriverElement $element = null, $keys = null) + { + $this->action->addAction( + new WebDriverSendKeysAction( + $this->keyboard, + $this->mouse, + $element, + $keys + ) + ); + + return $this; + } +} diff --git a/lib/Interactions/WebDriverCompositeAction.php b/lib/Interactions/WebDriverCompositeAction.php new file mode 100644 index 000000000..955d61986 --- /dev/null +++ b/lib/Interactions/WebDriverCompositeAction.php @@ -0,0 +1,48 @@ +actions[] = $action; + + return $this; + } + + /** + * Get the number of actions in the sequence. + * + * @return int The number of actions. + */ + public function getNumberOfActions() + { + return count($this->actions); + } + + /** + * Perform the sequence of actions. + */ + public function perform() + { + foreach ($this->actions as $action) { + $action->perform(); + } + } +} diff --git a/lib/Interactions/WebDriverTouchActions.php b/lib/Interactions/WebDriverTouchActions.php new file mode 100644 index 000000000..fd3298410 --- /dev/null +++ b/lib/Interactions/WebDriverTouchActions.php @@ -0,0 +1,175 @@ +touchScreen = $driver->getTouch(); + } + + /** + * @return WebDriverTouchActions + */ + public function tap(WebDriverElement $element) + { + $this->action->addAction( + new WebDriverTapAction($this->touchScreen, $element) + ); + + return $this; + } + + /** + * @param int $x + * @param int $y + * @return WebDriverTouchActions + */ + public function down($x, $y) + { + $this->action->addAction( + new WebDriverDownAction($this->touchScreen, $x, $y) + ); + + return $this; + } + + /** + * @param int $x + * @param int $y + * @return WebDriverTouchActions + */ + public function up($x, $y) + { + $this->action->addAction( + new WebDriverUpAction($this->touchScreen, $x, $y) + ); + + return $this; + } + + /** + * @param int $x + * @param int $y + * @return WebDriverTouchActions + */ + public function move($x, $y) + { + $this->action->addAction( + new WebDriverMoveAction($this->touchScreen, $x, $y) + ); + + return $this; + } + + /** + * @param int $x + * @param int $y + * @return WebDriverTouchActions + */ + public function scroll($x, $y) + { + $this->action->addAction( + new WebDriverScrollAction($this->touchScreen, $x, $y) + ); + + return $this; + } + + /** + * @param int $x + * @param int $y + * @return WebDriverTouchActions + */ + public function scrollFromElement(WebDriverElement $element, $x, $y) + { + $this->action->addAction( + new WebDriverScrollFromElementAction($this->touchScreen, $element, $x, $y) + ); + + return $this; + } + + /** + * @return WebDriverTouchActions + */ + public function doubleTap(WebDriverElement $element) + { + $this->action->addAction( + new WebDriverDoubleTapAction($this->touchScreen, $element) + ); + + return $this; + } + + /** + * @return WebDriverTouchActions + */ + public function longPress(WebDriverElement $element) + { + $this->action->addAction( + new WebDriverLongPressAction($this->touchScreen, $element) + ); + + return $this; + } + + /** + * @param int $x + * @param int $y + * @return WebDriverTouchActions + */ + public function flick($x, $y) + { + $this->action->addAction( + new WebDriverFlickAction($this->touchScreen, $x, $y) + ); + + return $this; + } + + /** + * @param int $x + * @param int $y + * @param int $speed + * @return WebDriverTouchActions + */ + public function flickFromElement(WebDriverElement $element, $x, $y, $speed) + { + $this->action->addAction( + new WebDriverFlickFromElementAction( + $this->touchScreen, + $element, + $x, + $y, + $speed + ) + ); + + return $this; + } +} diff --git a/lib/Internal/WebDriverLocatable.php b/lib/Internal/WebDriverLocatable.php new file mode 100644 index 000000000..225a10d66 --- /dev/null +++ b/lib/Internal/WebDriverLocatable.php @@ -0,0 +1,16 @@ + microtime(true)) { + if ($this->getHTTPResponseCode($url) === 200) { + return $this; + } + usleep(self::POLL_INTERVAL_MS); + } + + throw new TimeoutException(sprintf( + 'Timed out waiting for %s to become available after %d ms.', + $url, + $timeout_in_ms + )); + } + + public function waitUntilUnavailable($timeout_in_ms, $url) + { + $end = microtime(true) + $timeout_in_ms / 1000; + + while ($end > microtime(true)) { + if ($this->getHTTPResponseCode($url) !== 200) { + return $this; + } + usleep(self::POLL_INTERVAL_MS); + } + + throw new TimeoutException(sprintf( + 'Timed out waiting for %s to become unavailable after %d ms.', + $url, + $timeout_in_ms + )); + } + + private function getHTTPResponseCode($url) + { + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); + // The PHP doc indicates that CURLOPT_CONNECTTIMEOUT_MS constant is added in cURL 7.16.2 + // available since PHP 5.2.3. + if (!defined('CURLOPT_CONNECTTIMEOUT_MS')) { + define('CURLOPT_CONNECTTIMEOUT_MS', 156); // default value for CURLOPT_CONNECTTIMEOUT_MS + } + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT_MS, self::CONNECT_TIMEOUT_MS); + + $code = null; + + try { + curl_exec($ch); + $info = curl_getinfo($ch); + $code = $info['http_code']; + } catch (Exception $e) { + } + curl_close($ch); + + return $code; + } +} diff --git a/lib/Remote/CustomWebDriverCommand.php b/lib/Remote/CustomWebDriverCommand.php new file mode 100644 index 000000000..cef2c95b2 --- /dev/null +++ b/lib/Remote/CustomWebDriverCommand.php @@ -0,0 +1,82 @@ +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 new file mode 100644 index 000000000..88aa6b141 --- /dev/null +++ b/lib/Remote/DesiredCapabilities.php @@ -0,0 +1,428 @@ + '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. + */ + public function getBrowserName() + { + return $this->get(WebDriverCapabilityType::BROWSER_NAME, ''); + } + + /** + * @param string $browser_name + * @return DesiredCapabilities + */ + public function setBrowserName($browser_name) + { + $this->set(WebDriverCapabilityType::BROWSER_NAME, $browser_name); + + return $this; + } + + /** + * @return string The version of the browser. + */ + public function getVersion() + { + return $this->get(WebDriverCapabilityType::VERSION, ''); + } + + /** + * @param string $version + * @return DesiredCapabilities + */ + public function setVersion($version) + { + $this->set(WebDriverCapabilityType::VERSION, $version); + + return $this; + } + + /** + * @param string $name + * @return mixed The value of a capability. + */ + public function getCapability($name) + { + return $this->get($name); + } + + /** + * @param string $name + * @param mixed $value + * @return DesiredCapabilities + */ + 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; + } + + /** + * @return string The name of the platform. + */ + public function getPlatform() + { + return $this->get(WebDriverCapabilityType::PLATFORM, ''); + } + + /** + * @param string $platform + * @return DesiredCapabilities + */ + public function setPlatform($platform) + { + $this->set(WebDriverCapabilityType::PLATFORM, $platform); + + return $this; + } + + /** + * @param string $capability_name + * @return bool Whether the value is not null and not false. + */ + public function is($capability_name) + { + return (bool) $this->get($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() + { + return $this->get(WebDriverCapabilityType::JAVASCRIPT_ENABLED, false); + } + + /** + * This is a htmlUnit-only option. + * + * @param bool $enabled + * @throws UnsupportedOperationException + * @return DesiredCapabilities + * @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 UnsupportedOperationException( + 'isJavascriptEnabled() is a htmlunit-only option. ' . + 'See https://github.com/SeleniumHQ/selenium/wiki/DesiredCapabilities#read-write-capabilities.' + ); + } + + $this->set(WebDriverCapabilityType::JAVASCRIPT_ENABLED, $enabled); + + return $this; + } + + /** + * @todo Remove side-effects - not change eg. ChromeOptions::CAPABILITY from instance of ChromeOptions to an array + * @return array + */ + public function toArray() + { + if (isset($this->capabilities[ChromeOptions::CAPABILITY]) && + $this->capabilities[ChromeOptions::CAPABILITY] instanceof ChromeOptions + ) { + $this->capabilities[ChromeOptions::CAPABILITY] = + $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 + ) { + $this->capabilities[FirefoxDriver::PROFILE] = + $this->capabilities[FirefoxDriver::PROFILE]->encode(); + } + + return $this->capabilities; + } + + /** + * @return array + */ + public function toW3cCompatibleArray() + { + $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; + } + } + + // Convert ChromeOptions + if (array_key_exists(ChromeOptions::CAPABILITY, $ossCapabilities)) { + $w3cCapabilities[ChromeOptions::CAPABILITY] = $ossCapabilities[ChromeOptions::CAPABILITY]; + } + + // 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 static + */ + public static function android() + { + return new static([ + WebDriverCapabilityType::BROWSER_NAME => WebDriverBrowserType::ANDROID, + WebDriverCapabilityType::PLATFORM => WebDriverPlatform::ANDROID, + ]); + } + + /** + * @return static + */ + public static function chrome() + { + return new static([ + WebDriverCapabilityType::BROWSER_NAME => WebDriverBrowserType::CHROME, + WebDriverCapabilityType::PLATFORM => WebDriverPlatform::ANY, + ]); + } + + /** + * @return static + */ + public static function firefox() + { + $caps = new static([ + WebDriverCapabilityType::BROWSER_NAME => WebDriverBrowserType::FIREFOX, + WebDriverCapabilityType::PLATFORM => WebDriverPlatform::ANY, + ]); + + $caps->setCapability(FirefoxOptions::CAPABILITY, new FirefoxOptions()); // to add default options + + return $caps; + } + + /** + * @return static + */ + public static function htmlUnit() + { + return new static([ + WebDriverCapabilityType::BROWSER_NAME => WebDriverBrowserType::HTMLUNIT, + WebDriverCapabilityType::PLATFORM => WebDriverPlatform::ANY, + ]); + } + + /** + * @return static + */ + public static function htmlUnitWithJS() + { + $caps = new static([ + WebDriverCapabilityType::BROWSER_NAME => WebDriverBrowserType::HTMLUNIT, + WebDriverCapabilityType::PLATFORM => WebDriverPlatform::ANY, + ]); + + return $caps->setJavascriptEnabled(true); + } + + /** + * @return static + */ + public static function internetExplorer() + { + return new static([ + WebDriverCapabilityType::BROWSER_NAME => WebDriverBrowserType::IE, + WebDriverCapabilityType::PLATFORM => WebDriverPlatform::WINDOWS, + ]); + } + + /** + * @return static + */ + public static function microsoftEdge() + { + return new static([ + WebDriverCapabilityType::BROWSER_NAME => WebDriverBrowserType::MICROSOFT_EDGE, + WebDriverCapabilityType::PLATFORM => WebDriverPlatform::WINDOWS, + ]); + } + + /** + * @return static + */ + public static function iphone() + { + return new static([ + WebDriverCapabilityType::BROWSER_NAME => WebDriverBrowserType::IPHONE, + WebDriverCapabilityType::PLATFORM => WebDriverPlatform::MAC, + ]); + } + + /** + * @return static + */ + public static function ipad() + { + return new static([ + WebDriverCapabilityType::BROWSER_NAME => WebDriverBrowserType::IPAD, + WebDriverCapabilityType::PLATFORM => WebDriverPlatform::MAC, + ]); + } + + /** + * @return static + */ + public static function opera() + { + return new static([ + WebDriverCapabilityType::BROWSER_NAME => WebDriverBrowserType::OPERA, + WebDriverCapabilityType::PLATFORM => WebDriverPlatform::ANY, + ]); + } + + /** + * @return static + */ + public static function safari() + { + return new static([ + WebDriverCapabilityType::BROWSER_NAME => WebDriverBrowserType::SAFARI, + WebDriverCapabilityType::PLATFORM => WebDriverPlatform::ANY, + ]); + } + + /** + * @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() + { + return new static([ + WebDriverCapabilityType::BROWSER_NAME => WebDriverBrowserType::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 new file mode 100644 index 000000000..a3a230b7c --- /dev/null +++ b/lib/Remote/DriverCommand.php @@ -0,0 +1,153 @@ + ['method' => 'POST', 'url' => '/session/:sessionId/accept_alert'], + DriverCommand::ADD_COOKIE => ['method' => 'POST', 'url' => '/session/:sessionId/cookie'], + DriverCommand::CLEAR_ELEMENT => ['method' => 'POST', 'url' => '/session/:sessionId/element/:id/clear'], + DriverCommand::CLICK_ELEMENT => ['method' => 'POST', 'url' => '/session/:sessionId/element/:id/click'], + DriverCommand::CLOSE => ['method' => 'DELETE', 'url' => '/session/:sessionId/window'], + DriverCommand::DELETE_ALL_COOKIES => ['method' => 'DELETE', 'url' => '/session/:sessionId/cookie'], + DriverCommand::DELETE_COOKIE => ['method' => 'DELETE', 'url' => '/session/:sessionId/cookie/:name'], + DriverCommand::DISMISS_ALERT => ['method' => 'POST', 'url' => '/session/:sessionId/dismiss_alert'], + DriverCommand::ELEMENT_EQUALS => ['method' => 'GET', 'url' => '/session/:sessionId/element/:id/equals/:other'], + DriverCommand::FIND_CHILD_ELEMENT => ['method' => 'POST', 'url' => '/session/:sessionId/element/:id/element'], + DriverCommand::FIND_CHILD_ELEMENTS => ['method' => 'POST', 'url' => '/session/:sessionId/element/:id/elements'], + DriverCommand::EXECUTE_SCRIPT => ['method' => 'POST', 'url' => '/session/:sessionId/execute'], + DriverCommand::EXECUTE_ASYNC_SCRIPT => ['method' => 'POST', 'url' => '/session/:sessionId/execute_async'], + 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'], + DriverCommand::GET_CURRENT_WINDOW_HANDLE => ['method' => 'GET', 'url' => '/session/:sessionId/window_handle'], + DriverCommand::GET_ELEMENT_ATTRIBUTE => [ + 'method' => 'GET', + 'url' => '/session/:sessionId/element/:id/attribute/:name', + ], + DriverCommand::GET_ELEMENT_VALUE_OF_CSS_PROPERTY => [ + 'method' => 'GET', + 'url' => '/session/:sessionId/element/:id/css/:propertyName', + ], + DriverCommand::GET_ELEMENT_LOCATION => [ + 'method' => 'GET', + 'url' => '/session/:sessionId/element/:id/location', + ], + DriverCommand::GET_ELEMENT_LOCATION_ONCE_SCROLLED_INTO_VIEW => [ + 'method' => 'GET', + 'url' => '/session/:sessionId/element/:id/location_in_view', + ], + DriverCommand::GET_ELEMENT_SIZE => ['method' => 'GET', 'url' => '/session/:sessionId/element/:id/size'], + DriverCommand::GET_ELEMENT_TAG_NAME => ['method' => 'GET', 'url' => '/session/:sessionId/element/:id/name'], + DriverCommand::GET_ELEMENT_TEXT => ['method' => 'GET', 'url' => '/session/:sessionId/element/:id/text'], + DriverCommand::GET_LOG => ['method' => 'POST', 'url' => '/session/:sessionId/log'], + DriverCommand::GET_PAGE_SOURCE => ['method' => 'GET', 'url' => '/session/:sessionId/source'], + DriverCommand::GET_SCREEN_ORIENTATION => ['method' => 'GET', 'url' => '/session/:sessionId/orientation'], + DriverCommand::GET_CAPABILITIES => ['method' => 'GET', 'url' => '/session/:sessionId'], + DriverCommand::GET_TITLE => ['method' => 'GET', 'url' => '/session/:sessionId/title'], + DriverCommand::GET_WINDOW_HANDLES => ['method' => 'GET', 'url' => '/session/:sessionId/window_handles'], + DriverCommand::GET_WINDOW_POSITION => [ + 'method' => 'GET', + 'url' => '/session/:sessionId/window/:windowHandle/position', + ], + DriverCommand::GET_WINDOW_SIZE => ['method' => 'GET', 'url' => '/session/:sessionId/window/:windowHandle/size'], + DriverCommand::GO_BACK => ['method' => 'POST', 'url' => '/session/:sessionId/back'], + DriverCommand::GO_FORWARD => ['method' => 'POST', 'url' => '/session/:sessionId/forward'], + DriverCommand::IS_ELEMENT_DISPLAYED => [ + 'method' => 'GET', + 'url' => '/session/:sessionId/element/:id/displayed', + ], + DriverCommand::IS_ELEMENT_ENABLED => ['method' => 'GET', 'url' => '/session/:sessionId/element/:id/enabled'], + DriverCommand::IS_ELEMENT_SELECTED => ['method' => 'GET', 'url' => '/session/:sessionId/element/:id/selected'], + DriverCommand::MAXIMIZE_WINDOW => [ + 'method' => 'POST', + 'url' => '/session/:sessionId/window/:windowHandle/maximize', + ], + DriverCommand::MOUSE_DOWN => ['method' => 'POST', 'url' => '/session/:sessionId/buttondown'], + DriverCommand::MOUSE_UP => ['method' => 'POST', 'url' => '/session/:sessionId/buttonup'], + DriverCommand::CLICK => ['method' => 'POST', 'url' => '/session/:sessionId/click'], + DriverCommand::DOUBLE_CLICK => ['method' => 'POST', 'url' => '/session/:sessionId/doubleclick'], + DriverCommand::MOVE_TO => ['method' => 'POST', 'url' => '/session/:sessionId/moveto'], + DriverCommand::NEW_SESSION => ['method' => 'POST', 'url' => '/session'], + DriverCommand::QUIT => ['method' => 'DELETE', 'url' => '/session/:sessionId'], + DriverCommand::REFRESH => ['method' => 'POST', 'url' => '/session/:sessionId/refresh'], + DriverCommand::UPLOAD_FILE => ['method' => 'POST', 'url' => '/session/:sessionId/file'], // undocumented + DriverCommand::SEND_KEYS_TO_ACTIVE_ELEMENT => ['method' => 'POST', 'url' => '/session/:sessionId/keys'], + DriverCommand::SET_ALERT_VALUE => ['method' => 'POST', 'url' => '/session/:sessionId/alert_text'], + DriverCommand::SEND_KEYS_TO_ELEMENT => ['method' => 'POST', 'url' => '/session/:sessionId/element/:id/value'], + DriverCommand::IMPLICITLY_WAIT => ['method' => 'POST', 'url' => '/session/:sessionId/timeouts/implicit_wait'], + DriverCommand::SET_SCREEN_ORIENTATION => ['method' => 'POST', 'url' => '/session/:sessionId/orientation'], + DriverCommand::SET_TIMEOUT => ['method' => 'POST', 'url' => '/session/:sessionId/timeouts'], + DriverCommand::SET_SCRIPT_TIMEOUT => ['method' => 'POST', 'url' => '/session/:sessionId/timeouts/async_script'], + DriverCommand::SET_WINDOW_POSITION => [ + 'method' => 'POST', + 'url' => '/session/:sessionId/window/:windowHandle/position', + ], + DriverCommand::SET_WINDOW_SIZE => [ + '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'], + DriverCommand::TOUCH_FLICK => ['method' => 'POST', 'url' => '/session/:sessionId/touch/flick'], + DriverCommand::TOUCH_LONG_PRESS => ['method' => 'POST', 'url' => '/session/:sessionId/touch/longclick'], + 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 + */ + protected $url; + /** + * @var resource + */ + protected $curl; + /** + * @var bool + */ + protected $isW3cCompliant = true; + + /** + * @param string $url + * @param string|null $http_proxy + * @param int|null $http_proxy_port + */ + 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 ($http_proxy_port !== null) { + curl_setopt($this->curl, CURLOPT_PROXYPORT, $http_proxy_port); + } + } + + // Get credentials from $url (if any) + $matches = null; + if (preg_match("/^(https?:\/\/)(.*):(.*)@(.*?)/U", $url, $matches)) { + $this->url = $matches[1] . $matches[4]; + $auth_creds = $matches[2] . ':' . $matches[3]; + curl_setopt($this->curl, CURLOPT_HTTPAUTH, CURLAUTH_ANY); + curl_setopt($this->curl, CURLOPT_USERPWD, $auth_creds); + } + + curl_setopt($this->curl, CURLOPT_RETURNTRANSFER, true); + curl_setopt($this->curl, CURLOPT_FOLLOWLOCATION, true); + 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; + } + + /** + * Set timeout for the connect phase + * + * @param int $timeout_in_ms Timeout in milliseconds + * @return HttpCommandExecutor + */ + public function setConnectionTimeout($timeout_in_ms) + { + // There is a PHP bug in some versions which didn't define the constant. + curl_setopt( + $this->curl, + /* CURLOPT_CONNECTTIMEOUT_MS */ + 156, + $timeout_in_ms + ); + + return $this; + } + + /** + * Set the maximum time of a request + * + * @param int $timeout_in_ms Timeout in milliseconds + * @return HttpCommandExecutor + */ + public function setRequestTimeout($timeout_in_ms) + { + // There is a PHP bug in some versions (at least for PHP 5.3.3) which + // didn't define the constant. + curl_setopt( + $this->curl, + /* CURLOPT_TIMEOUT_MS */ + 155, + $timeout_in_ms + ); + + return $this; + } + + /** + * @return WebDriverResponse + */ + public function execute(WebDriverCommand $command) + { + $http_options = $this->getCommandHttpOptions($command); + $http_method = $http_options['method']; + $url = $http_options['url']; + + $sessionID = $command->getSessionID(); + $url = str_replace(':sessionId', $sessionID ?? '', $url); + $params = $command->getParameters(); + foreach ($params as $name => $value) { + if ($name[0] === ':') { + $url = str_replace($name, $value, $url); + unset($params[$name]); + } + } + + 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); + + // https://github.com/facebook/php-webdriver/issues/173 + if ($command->getName() === DriverCommand::NEW_SESSION) { + curl_setopt($this->curl, CURLOPT_POST, 1); + } else { + 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') { + 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); + + $raw_results = trim(curl_exec($this->curl)); + + if ($error = curl_error($this->curl)) { + 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 UnexpectedResponseException::forJsonDecodingError(json_last_error(), $raw_results); + } + + $value = null; + if (is_array($results) && array_key_exists('value', $results)) { + $value = $results['value']; + } + + $message = null; + if (is_array($value) && array_key_exists('message', $value)) { + $message = $value['message']; + } + + $sessionId = null; + 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']; + } + + // @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); + + return $response + ->setStatus($status) + ->setValue($value); + } + + /** + * @return string + */ + 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 new file mode 100644 index 000000000..ea7e85e01 --- /dev/null +++ b/lib/Remote/LocalFileDetector.php @@ -0,0 +1,20 @@ +driver = $driver; + } + + /** + * @param string $command_name + * @return mixed + */ + public function execute($command_name, array $parameters = []) + { + return $this->driver->execute($command_name, $parameters); + } +} diff --git a/lib/Remote/RemoteKeyboard.php b/lib/Remote/RemoteKeyboard.php new file mode 100644 index 000000000..095b0c573 --- /dev/null +++ b/lib/Remote/RemoteKeyboard.php @@ -0,0 +1,105 @@ +executor = $executor; + $this->driver = $driver; + $this->isW3cCompliant = $isW3cCompliant; + } + + /** + * Send keys to active element + * @param string|array $keys + * @return $this + */ + public function sendKeys($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; + } + + /** + * Press a modifier key + * + * @see WebDriverKeys + * @param string $key + * @return $this + */ + public function pressKey($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; + } + + /** + * Release a modifier key + * + * @see WebDriverKeys + * @param string $key + * @return $this + */ + public function releaseKey($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 new file mode 100644 index 000000000..fee209f94 --- /dev/null +++ b/lib/Remote/RemoteMouse.php @@ -0,0 +1,290 @@ +executor = $executor; + $this->isW3cCompliant = $isW3cCompliant; + } + + /** + * @return RemoteMouse + */ + 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' => self::BUTTON_LEFT, + ]); + + return $this; + } + + /** + * @return RemoteMouse + */ + 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' => self::BUTTON_RIGHT, + ]); + + return $this; + } + + /** + * @return RemoteMouse + */ + 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); + + return $this; + } + + /** + * @return RemoteMouse + */ + 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); + + return $this; + } + + /** + * @param int|null $x_offset + * @param int|null $y_offset + * + * @return RemoteMouse + */ + public function mouseMove( + ?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(); + } + if ($x_offset !== null) { + $params['xoffset'] = $x_offset; + } + if ($y_offset !== null) { + $params['yoffset'] = $y_offset; + } + + $this->executor->execute(DriverCommand::MOVE_TO, $params); + + return $this; + } + + /** + * @return RemoteMouse + */ + 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; + } + + 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 new file mode 100644 index 000000000..979b9c4f6 --- /dev/null +++ b/lib/Remote/RemoteTargetLocator.php @@ -0,0 +1,149 @@ +executor = $executor; + $this->driver = $driver; + $this->isW3cCompliant = $isW3cCompliant; + } + + /** + * @return RemoteWebDriver + */ + public function defaultContent() + { + $params = ['id' => null]; + $this->executor->execute(DriverCommand::SWITCH_TO_FRAME, $params); + + return $this->driver; + } + + /** + * @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 ($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 { + 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]; + $this->executor->execute(DriverCommand::SWITCH_TO_FRAME, $params); + + return $this->driver; + } + + /** + * 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 RemoteWebDriver + */ + public function window($handle) + { + if ($this->isW3cCompliant) { + $params = ['handle' => (string) $handle]; + } else { + $params = ['name' => (string) $handle]; + } + + $this->executor->execute(DriverCommand::SWITCH_TO_WINDOW, $params); + + return $this->driver; + } + + /** + * Creates a new browser window and switches the focus for future commands of this driver to the new window. + * + * @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); + } + + /** + * @return RemoteWebElement + */ + public function activeElement() + { + $response = $this->driver->execute(DriverCommand::GET_ACTIVE_ELEMENT, []); + $method = new RemoteExecuteMethod($this->driver); + + return new RemoteWebElement($method, JsonWireCompat::getElement($response), $this->isW3cCompliant); + } +} diff --git a/lib/Remote/RemoteTouchScreen.php b/lib/Remote/RemoteTouchScreen.php new file mode 100644 index 000000000..951c8619a --- /dev/null +++ b/lib/Remote/RemoteTouchScreen.php @@ -0,0 +1,177 @@ +executor = $executor; + } + + /** + * @return RemoteTouchScreen The instance. + */ + public function tap(WebDriverElement $element) + { + $this->executor->execute( + DriverCommand::TOUCH_SINGLE_TAP, + ['element' => $element->getID()] + ); + + return $this; + } + + /** + * @return RemoteTouchScreen The instance. + */ + public function doubleTap(WebDriverElement $element) + { + $this->executor->execute( + DriverCommand::TOUCH_DOUBLE_TAP, + ['element' => $element->getID()] + ); + + return $this; + } + + /** + * @param int $x + * @param int $y + * + * @return RemoteTouchScreen The instance. + */ + public function down($x, $y) + { + $this->executor->execute(DriverCommand::TOUCH_DOWN, [ + 'x' => $x, + 'y' => $y, + ]); + + return $this; + } + + /** + * @param int $xspeed + * @param int $yspeed + * + * @return RemoteTouchScreen The instance. + */ + public function flick($xspeed, $yspeed) + { + $this->executor->execute(DriverCommand::TOUCH_FLICK, [ + 'xspeed' => $xspeed, + 'yspeed' => $yspeed, + ]); + + return $this; + } + + /** + * @param int $xoffset + * @param int $yoffset + * @param int $speed + * + * @return RemoteTouchScreen The instance. + */ + public function flickFromElement(WebDriverElement $element, $xoffset, $yoffset, $speed) + { + $this->executor->execute(DriverCommand::TOUCH_FLICK, [ + 'xoffset' => $xoffset, + 'yoffset' => $yoffset, + 'element' => $element->getID(), + 'speed' => $speed, + ]); + + return $this; + } + + /** + * @return RemoteTouchScreen The instance. + */ + public function longPress(WebDriverElement $element) + { + $this->executor->execute( + DriverCommand::TOUCH_LONG_PRESS, + ['element' => $element->getID()] + ); + + return $this; + } + + /** + * @param int $x + * @param int $y + * + * @return RemoteTouchScreen The instance. + */ + public function move($x, $y) + { + $this->executor->execute(DriverCommand::TOUCH_MOVE, [ + 'x' => $x, + 'y' => $y, + ]); + + return $this; + } + + /** + * @param int $xoffset + * @param int $yoffset + * + * @return RemoteTouchScreen The instance. + */ + public function scroll($xoffset, $yoffset) + { + $this->executor->execute(DriverCommand::TOUCH_SCROLL, [ + 'xoffset' => $xoffset, + 'yoffset' => $yoffset, + ]); + + return $this; + } + + /** + * @param int $xoffset + * @param int $yoffset + * + * @return RemoteTouchScreen The instance. + */ + public function scrollFromElement(WebDriverElement $element, $xoffset, $yoffset) + { + $this->executor->execute(DriverCommand::TOUCH_SCROLL, [ + 'element' => $element->getID(), + 'xoffset' => $xoffset, + 'yoffset' => $yoffset, + ]); + + return $this; + } + + /** + * @param int $x + * @param int $y + * + * @return RemoteTouchScreen The instance. + */ + public function up($x, $y) + { + $this->executor->execute(DriverCommand::TOUCH_UP, [ + 'x' => $x, + 'y' => $y, + ]); + + return $this; + } +} diff --git a/lib/Remote/RemoteWebDriver.php b/lib/Remote/RemoteWebDriver.php new file mode 100644 index 000000000..3d65aaf0b --- /dev/null +++ b/lib/Remote/RemoteWebDriver.php @@ -0,0 +1,760 @@ +executor = $commandExecutor; + $this->sessionID = $sessionId; + $this->isW3cCompliant = $isW3cCompliant; + $this->capabilities = $capabilities; + } + + /** + * Construct the RemoteWebDriver by a desired capabilities. + * + * @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 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', + $desired_capabilities = null, + $connection_timeout_in_ms = null, + $request_timeout_in_ms = null, + $http_proxy = null, + $http_proxy_port = null, + ?DesiredCapabilities $required_capabilities = null + ) { + $selenium_server_url = preg_replace('#/+$#', '', $selenium_server_url); + + $desired_capabilities = self::castToDesiredCapabilitiesObject($desired_capabilities); + + $executor = new HttpCommandExecutor($selenium_server_url, $http_proxy, $http_proxy_port); + if ($connection_timeout_in_ms !== null) { + $executor->setConnectionTimeout($connection_timeout_in_ms); + } + if ($request_timeout_in_ms !== null) { + $executor->setRequestTimeout($request_timeout_in_ms); + } + + // W3C + $parameters = [ + 'capabilities' => [ + 'firstMatch' => [(object) $desired_capabilities->toW3cCompatibleArray()], + ], + ]; + + 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()); + } + + $parameters['desiredCapabilities'] = (object) $desired_capabilities->toArray(); + + $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 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 $session_id The existing session id + * @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); + } + + 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); + } + + /** + * Close the current window. + * + * @return RemoteWebDriver The current instance. + */ + public function close() + { + $this->execute(DriverCommand::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. + * + * @return RemoteWebElement NoSuchElementException is thrown in HttpCommandExecutor if no element is found. + * @see WebDriverBy + */ + public function findElement(WebDriverBy $by) + { + $raw_element = $this->execute( + DriverCommand::FIND_ELEMENT, + JsonWireCompat::getUsing($by, $this->isW3cCompliant) + ); + + return $this->newElement(JsonWireCompat::getElement($raw_element)); + } + + /** + * Find all WebDriverElements within the current page using the given mechanism. + * + * @return RemoteWebElement[] A list of all WebDriverElements, or an empty array if nothing matches + * @see WebDriverBy + */ + public function findElements(WebDriverBy $by) + { + $raw_elements = $this->execute( + DriverCommand::FIND_ELEMENTS, + 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(JsonWireCompat::getElement($raw_element)); + } + + return $elements; + } + + /** + * Load a new web page in the current browser window. + * + * @param string $url + * + * @return RemoteWebDriver The current instance. + */ + public function get($url) + { + $params = ['url' => (string) $url]; + $this->execute(DriverCommand::GET, $params); + + return $this; + } + + /** + * Get a string representing the current URL that the browser is looking at. + * + * @return string The current URL. + */ + public function getCurrentURL() + { + return $this->execute(DriverCommand::GET_CURRENT_URL); + } + + /** + * Get the source of the last loaded page. + * + * @return string The current page source. + */ + public function getPageSource() + { + return $this->execute(DriverCommand::GET_PAGE_SOURCE); + } + + /** + * Get the title of the current page. + * + * @return string The title of the current page. + */ + public function getTitle() + { + return $this->execute(DriverCommand::GET_TITLE); + } + + /** + * Return an opaque handle to this window that uniquely identifies it within this driver instance. + * + * @return string The current window handle. + */ + public function getWindowHandle() + { + return $this->execute( + DriverCommand::GET_CURRENT_WINDOW_HANDLE, + [] + ); + } + + /** + * 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() + { + return $this->execute(DriverCommand::GET_WINDOW_HANDLES, []); + } + + /** + * Quits this driver, closing every associated window. + */ + public function quit() + { + $this->execute(DriverCommand::QUIT); + $this->executor = null; + } + + /** + * 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. + * @return mixed The return value of the script. + */ + public function executeScript($script, array $arguments = []) + { + $params = [ + 'script' => $script, + 'args' => $this->prepareScriptArguments($arguments), + ]; + + return $this->execute(DriverCommand::EXECUTE_SCRIPT, $params); + } + + /** + * 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. + * + * 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. + * @return mixed The value passed by the script to the callback. + */ + public function executeAsyncScript($script, array $arguments = []) + { + $params = [ + 'script' => $script, + 'args' => $this->prepareScriptArguments($arguments), + ]; + + return $this->execute( + DriverCommand::EXECUTE_ASYNC_SCRIPT, + $params + ); + } + + /** + * Take a screenshot of the current page. + * + * @param string $save_as The path of the screenshot to be saved. + * @return string The screenshot in PNG format. + */ + public function takeScreenshot($save_as = null) + { + return (new ScreenshotHelper($this->getExecuteMethod()))->takePageScreenshot($save_as); + } + + /** + * 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 + * + * @return WebDriverWait + */ + public function wait($timeout_in_second = 30, $interval_in_millisecond = 250) + { + return new WebDriverWait( + $this, + $timeout_in_second, + $interval_in_millisecond + ); + } + + /** + * An abstraction for managing stuff you would do in a browser menu. For example, adding and deleting cookies. + * + * @return WebDriverOptions + */ + public function manage() + { + return new WebDriverOptions($this->getExecuteMethod(), $this->isW3cCompliant); + } + + /** + * An abstraction allowing the driver to access the browser's history and to navigate to a given URL. + * + * @return WebDriverNavigation + * @see WebDriverNavigation + */ + public function navigate() + { + return new WebDriverNavigation($this->getExecuteMethod()); + } + + /** + * Switch to a different window or frame. + * + * @return RemoteTargetLocator + * @see RemoteTargetLocator + */ + public function switchTo() + { + return new RemoteTargetLocator($this->getExecuteMethod(), $this, $this->isW3cCompliant); + } + + /** + * @return RemoteMouse + */ + public function getMouse() + { + if (!$this->mouse) { + $this->mouse = new RemoteMouse($this->getExecuteMethod(), $this->isW3cCompliant); + } + + return $this->mouse; + } + + /** + * @return RemoteKeyboard + */ + public function getKeyboard() + { + if (!$this->keyboard) { + $this->keyboard = new RemoteKeyboard($this->getExecuteMethod(), $this, $this->isW3cCompliant); + } + + return $this->keyboard; + } + + /** + * @return RemoteTouchScreen + */ + public function getTouch() + { + if (!$this->touch) { + $this->touch = new RemoteTouchScreen($this->getExecuteMethod()); + } + + return $this->touch; + } + + /** + * Construct a new action builder. + * + * @return WebDriverActions + */ + public function action() + { + return new WebDriverActions($this); + } + + /** + * Set the command executor of this RemoteWebdriver + * + * @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) + { + $this->executor = $executor; + + return $this; + } + + /** + * Get the command executor of this RemoteWebdriver + * + * @return HttpCommandExecutor + */ + public function getCommandExecutor() + { + return $this->executor; + } + + /** + * 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 + */ + public function setSessionID($session_id) + { + $this->sessionID = $session_id; + + return $this; + } + + /** + * Get current selenium sessionID + * + * @return string + */ + public function getSessionID() + { + return $this->sessionID; + } + + /** + * 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, null, null); + $executor->setConnectionTimeout($timeout_in_ms); + + $command = new WebDriverCommand( + null, + DriverCommand::GET_ALL_SESSIONS, + [] + ); + + return $executor->execute($command)->getValue(); + } + + 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, + $params + ); + + if ($this->executor) { + $response = $this->executor->execute($command); + + return $response->getValue(); + } + + 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 new file mode 100644 index 000000000..e0ce43b55 --- /dev/null +++ b/lib/Remote/RemoteWebElement.php @@ -0,0 +1,650 @@ +executor = $executor; + $this->id = $id; + $this->fileDetector = new UselessFileDetector(); + $this->isW3cCompliant = $isW3cCompliant; + } + + /** + * Clear content editable or resettable element + * + * @return $this The current instance. + */ + public function clear() + { + $this->executor->execute( + DriverCommand::CLEAR_ELEMENT, + [':id' => $this->id] + ); + + return $this; + } + + /** + * Click this element. + * + * @return $this The current instance. + */ + public function click() + { + 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; + } + + /** + * Find the first WebDriverElement within this element using the given mechanism. + * + * 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 = JsonWireCompat::getUsing($by, $this->isW3cCompliant); + $params[':id'] = $this->id; + + $raw_element = $this->executor->execute( + DriverCommand::FIND_CHILD_ELEMENT, + $params + ); + + return $this->newElement(JsonWireCompat::getElement($raw_element)); + } + + /** + * Find all WebDriverElements within this element using the given mechanism. + * + * 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 = 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(JsonWireCompat::getElement($raw_element)); + } + + return $elements; + } + + /** + * 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|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) + { + $params = [ + ':name' => $attribute_name, + ':id' => $this->id, + ]; + + 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); + } + + /** + * Get the value of a given CSS property. + * + * @param string $css_property_name The name of the CSS property. + * @return string The value of the CSS property. + */ + public function getCSSValue($css_property_name) + { + $params = [ + ':propertyName' => $css_property_name, + ':id' => $this->id, + ]; + + return $this->executor->execute( + DriverCommand::GET_ELEMENT_VALUE_OF_CSS_PROPERTY, + $params + ); + } + + /** + * Get the location of element relative to the top-left corner of the page. + * + * @return WebDriverPoint The location of the element. + */ + public function getLocation() + { + $location = $this->executor->execute( + DriverCommand::GET_ELEMENT_LOCATION, + [':id' => $this->id] + ); + + return new WebDriverPoint($location['x'], $location['y']); + } + + /** + * Try scrolling the element into the view port and return the location of + * element relative to the top-left corner of the page afterwards. + * + * @return WebDriverPoint The location of the element. + */ + public function getLocationOnScreenOnceScrolledIntoView() + { + 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']); + } + + /** + * @return WebDriverCoordinates + */ + public function getCoordinates() + { + $element = $this; + + $on_screen = null; // planned but not yet implemented + $in_view_port = static function () use ($element) { + return $element->getLocationOnScreenOnceScrolledIntoView(); + }; + $on_page = static function () use ($element) { + return $element->getLocation(); + }; + $auxiliary = $this->getID(); + + return new WebDriverCoordinates( + $on_screen, + $in_view_port, + $on_page, + $auxiliary + ); + } + + /** + * Get the size of element. + * + * @return WebDriverDimension The dimension of the element. + */ + public function getSize() + { + $size = $this->executor->execute( + DriverCommand::GET_ELEMENT_SIZE, + [':id' => $this->id] + ); + + return new WebDriverDimension($size['width'], $size['height']); + } + + /** + * 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 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 mb_strtolower($this->executor->execute( + DriverCommand::GET_ELEMENT_TAG_NAME, + [':id' => $this->id] + )); + } + + /** + * Get the visible (i.e. not hidden by CSS) innerText of this element, + * including sub-elements, without any leading or trailing whitespace. + * + * @return string The visible innerText of this element. + */ + public function getText() + { + return $this->executor->execute( + DriverCommand::GET_ELEMENT_TEXT, + [':id' => $this->id] + ); + } + + /** + * Is this element displayed or not? This method avoids the problem of having + * to parse an element's "style" attribute. + * + * @return bool + */ + public function isDisplayed() + { + return $this->executor->execute( + DriverCommand::IS_ELEMENT_DISPLAYED, + [':id' => $this->id] + ); + } + + /** + * Is the element currently enabled or not? This will generally return true + * for everything but disabled input elements. + * + * @return bool + */ + public function isEnabled() + { + return $this->executor->execute( + DriverCommand::IS_ELEMENT_ENABLED, + [':id' => $this->id] + ); + } + + /** + * Determine whether this element is selected or not. + * + * @return bool + */ + public function isSelected() + { + return $this->executor->execute( + DriverCommand::IS_ELEMENT_SELECTED, + [':id' => $this->id] + ); + } + + /** + * Simulate typing into an element, which may set its value. + * + * @param mixed $value The data to be typed. + * @return static The current instance. + */ + public function sendKeys($value) + { + $local_file = $this->fileDetector->getLocalFile($value); + + $params = []; + if ($local_file === null) { + 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 { + 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, + ]; + } + } + + foreach ($params as $param) { + $this->executor->execute(DriverCommand::SEND_KEYS_TO_ELEMENT, $param); + } + + return $this; + } + + /** + * 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);` + * + * @return $this + * @see FileDetector + * @see LocalFileDetector + * @see UselessFileDetector + */ + public function setFileDetector(FileDetector $detector) + { + $this->fileDetector = $detector; + + return $this; + } + + /** + * If this current element is a form, or an element within a form, then this will be submitted to the remote server. + * + * @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] + ); + + return $this; + } + + /** + * Get the opaque ID of the element. + * + * @return string The opaque ID. + */ + public function getID() + { + return $this->id; + } + + /** + * 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. + * + * @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 static + */ + protected function newElement($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 new file mode 100644 index 000000000..5e3ef8399 --- /dev/null +++ b/lib/Remote/Service/DriverCommandExecutor.php @@ -0,0 +1,53 @@ +getURL()); + $this->service = $service; + } + + /** + * @throws \Exception + * @throws WebDriverException + * @return WebDriverResponse + */ + public function execute(WebDriverCommand $command) + { + if ($command->getName() === DriverCommand::NEW_SESSION) { + $this->service->start(); + } + + try { + $value = parent::execute($command); + if ($command->getName() === DriverCommand::QUIT) { + $this->service->stop(); + } + + return $value; + } catch (\Exception $e) { + if (!$this->service->isRunning()) { + throw new DriverServerDiedException($e); + } + throw $e; + } + } +} diff --git a/lib/Remote/Service/DriverService.php b/lib/Remote/Service/DriverService.php new file mode 100644 index 000000000..028b8cd83 --- /dev/null +++ b/lib/Remote/Service/DriverService.php @@ -0,0 +1,183 @@ +setExecutable($executable); + $this->url = sprintf('http://localhost:%d', $port); + $this->args = $args; + $this->environment = $environment ?: $_ENV; + } + + /** + * @return string + */ + public function getURL() + { + return $this->url; + } + + /** + * @return DriverService + */ + public function start() + { + if ($this->process !== null) { + return $this; + } + + $this->process = $this->createProcess(); + $this->process->start(); + + $this->checkWasStarted($this->process); + + $checker = new URLChecker(); + $checker->waitUntilAvailable(20 * 1000, $this->url . '/status'); + + return $this; + } + + /** + * @return DriverService + */ + public function stop() + { + if ($this->process === null) { + return $this; + } + + $this->process->stop(); + $this->process = null; + + $checker = new URLChecker(); + $checker->waitUntilUnavailable(3 * 1000, $this->url . '/shutdown'); + + return $this; + } + + /** + * @return bool + */ + public function isRunning() + { + if ($this->process === null) { + return false; + } + + return $this->process->isRunning(); + } + + /** + * @deprecated Has no effect. Will be removed in next major version. Executable is now checked + * when calling setExecutable(). + * @param string $executable + * @return string + */ + protected static function checkExecutable($executable) + { + return $executable; + } + + /** + * @param string $executable + * @throws IOException + */ + protected function setExecutable($executable) + { + if ($this->isExecutable($executable)) { + $this->executable = $executable; + + return; + } + + 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); + } + } + + 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 new file mode 100644 index 000000000..6bce0e004 --- /dev/null +++ b/lib/Remote/UselessFileDetector.php @@ -0,0 +1,11 @@ +sessionID = $session_id; + $this->name = $name; + $this->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 + */ + public function getName() + { + return $this->name; + } + + /** + * @return string|null Could be null for newSession command + */ + public function getSessionID() + { + return $this->sessionID; + } + + /** + * @return array + */ + public function getParameters() + { + return $this->parameters; + } +} diff --git a/lib/Remote/WebDriverResponse.php b/lib/Remote/WebDriverResponse.php new file mode 100644 index 000000000..39b19bf09 --- /dev/null +++ b/lib/Remote/WebDriverResponse.php @@ -0,0 +1,84 @@ +sessionID = $session_id; + } + + /** + * @return null|int + */ + public function getStatus() + { + return $this->status; + } + + /** + * @param int $status + * @return WebDriverResponse + */ + public function setStatus($status) + { + $this->status = $status; + + return $this; + } + + /** + * @return mixed + */ + public function getValue() + { + return $this->value; + } + + /** + * @param mixed $value + * @return WebDriverResponse + */ + public function setValue($value) + { + $this->value = $value; + + return $this; + } + + /** + * @return null|string + */ + public function getSessionID() + { + return $this->sessionID; + } + + /** + * @param mixed $session_id + * @return WebDriverResponse + */ + public function setSessionID($session_id) + { + $this->sessionID = $session_id; + + return $this; + } +} diff --git a/lib/Support/Events/EventFiringWebDriver.php b/lib/Support/Events/EventFiringWebDriver.php new file mode 100644 index 000000000..21e5a6887 --- /dev/null +++ b/lib/Support/Events/EventFiringWebDriver.php @@ -0,0 +1,394 @@ +dispatcher = $dispatcher ?: new WebDriverDispatcher(); + if (!$this->dispatcher->getDefaultDriver()) { + $this->dispatcher->setDefaultDriver($this); + } + $this->driver = $driver; + } + + /** + * @return WebDriverDispatcher + */ + public function getDispatcher() + { + return $this->dispatcher; + } + + /** + * @return WebDriver + */ + public function getWebDriver() + { + return $this->driver; + } + + /** + * @param mixed $url + * @throws WebDriverException + * @return $this + */ + 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); + + return $this; + } + + /** + * @throws WebDriverException + * @return array + */ + public function findElements(WebDriverBy $by) + { + $this->dispatch('beforeFindBy', $by, null, $this); + $elements = []; + + try { + foreach ($this->driver->findElements($by) as $element) { + $elements[] = $this->newElement($element); + } + } catch (WebDriverException $exception) { + $this->dispatchOnException($exception); + throw $exception; + } + + $this->dispatch('afterFindBy', $by, null, $this); + + return $elements; + } + + /** + * @throws WebDriverException + * @return EventFiringWebElement + */ + public function findElement(WebDriverBy $by) + { + $this->dispatch('beforeFindBy', $by, null, $this); + + try { + $element = $this->newElement($this->driver->findElement($by)); + } catch (WebDriverException $exception) { + $this->dispatchOnException($exception); + throw $exception; + } + + $this->dispatch('afterFindBy', $by, null, $this); + + return $element; + } + + /** + * @param string $script + * @throws WebDriverException + * @return mixed + */ + public function executeScript($script, array $arguments = []) + { + if (!$this->driver instanceof JavaScriptExecutor) { + throw new UnsupportedOperationException( + 'driver does not implement JavaScriptExecutor' + ); + } + + $this->dispatch('beforeScript', $script, $this); + + try { + $result = $this->driver->executeScript($script, $arguments); + } catch (WebDriverException $exception) { + $this->dispatchOnException($exception); + throw $exception; + } + + $this->dispatch('afterScript', $script, $this); + + return $result; + } + + /** + * @param string $script + * @throws WebDriverException + * @return mixed + */ + public function executeAsyncScript($script, array $arguments = []) + { + if (!$this->driver instanceof JavaScriptExecutor) { + throw new UnsupportedOperationException( + 'driver does not implement JavaScriptExecutor' + ); + } + + $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); + + return $result; + } + + /** + * @throws WebDriverException + * @return $this + */ + public function close() + { + try { + $this->driver->close(); + + return $this; + } catch (WebDriverException $exception) { + $this->dispatchOnException($exception); + throw $exception; + } + } + + /** + * @throws WebDriverException + * @return string + */ + public function getCurrentURL() + { + try { + return $this->driver->getCurrentURL(); + } catch (WebDriverException $exception) { + $this->dispatchOnException($exception); + throw $exception; + } + } + + /** + * @throws WebDriverException + * @return string + */ + public function getPageSource() + { + try { + return $this->driver->getPageSource(); + } catch (WebDriverException $exception) { + $this->dispatchOnException($exception); + throw $exception; + } + } + + /** + * @throws WebDriverException + * @return string + */ + public function getTitle() + { + try { + return $this->driver->getTitle(); + } catch (WebDriverException $exception) { + $this->dispatchOnException($exception); + throw $exception; + } + } + + /** + * @throws WebDriverException + * @return string + */ + public function getWindowHandle() + { + try { + return $this->driver->getWindowHandle(); + } catch (WebDriverException $exception) { + $this->dispatchOnException($exception); + throw $exception; + } + } + + /** + * @throws WebDriverException + * @return array + */ + public function getWindowHandles() + { + try { + return $this->driver->getWindowHandles(); + } catch (WebDriverException $exception) { + $this->dispatchOnException($exception); + throw $exception; + } + } + + /** + * @throws WebDriverException + */ + public function quit() + { + try { + $this->driver->quit(); + } catch (WebDriverException $exception) { + $this->dispatchOnException($exception); + throw $exception; + } + } + + /** + * @param null|string $save_as + * @throws WebDriverException + * @return string + */ + public function takeScreenshot($save_as = null) + { + try { + return $this->driver->takeScreenshot($save_as); + } catch (WebDriverException $exception) { + $this->dispatchOnException($exception); + throw $exception; + } + } + + /** + * @param int $timeout_in_second + * @param int $interval_in_millisecond + * @throws WebDriverException + * @return WebDriverWait + */ + public function wait($timeout_in_second = 30, $interval_in_millisecond = 250) + { + try { + return $this->driver->wait($timeout_in_second, $interval_in_millisecond); + } catch (WebDriverException $exception) { + $this->dispatchOnException($exception); + throw $exception; + } + } + + /** + * @throws WebDriverException + * @return WebDriverOptions + */ + public function manage() + { + try { + return $this->driver->manage(); + } catch (WebDriverException $exception) { + $this->dispatchOnException($exception); + throw $exception; + } + } + + /** + * @throws WebDriverException + * @return EventFiringWebDriverNavigation + */ + public function navigate() + { + try { + return new EventFiringWebDriverNavigation( + $this->driver->navigate(), + $this->getDispatcher() + ); + } catch (WebDriverException $exception) { + $this->dispatchOnException($exception); + throw $exception; + } + } + + /** + * @throws WebDriverException + * @return WebDriverTargetLocator + */ + public function switchTo() + { + try { + return $this->driver->switchTo(); + } catch (WebDriverException $exception) { + $this->dispatchOnException($exception); + throw $exception; + } + } + + /** + * @throws WebDriverException + * @return WebDriverTouchScreen + */ + public function getTouch() + { + try { + return $this->driver->getTouch(); + } catch (WebDriverException $exception) { + $this->dispatchOnException($exception); + 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 new file mode 100644 index 000000000..27ea34205 --- /dev/null +++ b/lib/Support/Events/EventFiringWebDriverNavigation.php @@ -0,0 +1,135 @@ +navigator = $navigator; + $this->dispatcher = $dispatcher; + } + + /** + * @return WebDriverDispatcher + */ + public function getDispatcher() + { + return $this->dispatcher; + } + + /** + * @return WebDriverNavigationInterface + */ + public function getNavigator() + { + return $this->navigator; + } + + public function back() + { + $this->dispatch( + 'beforeNavigateBack', + $this->getDispatcher()->getDefaultDriver() + ); + + try { + $this->navigator->back(); + } catch (WebDriverException $exception) { + $this->dispatchOnException($exception); + } + $this->dispatch( + 'afterNavigateBack', + $this->getDispatcher()->getDefaultDriver() + ); + + return $this; + } + + public function forward() + { + $this->dispatch( + 'beforeNavigateForward', + $this->getDispatcher()->getDefaultDriver() + ); + + try { + $this->navigator->forward(); + } catch (WebDriverException $exception) { + $this->dispatchOnException($exception); + } + $this->dispatch( + 'afterNavigateForward', + $this->getDispatcher()->getDefaultDriver() + ); + + return $this; + } + + public function refresh() + { + try { + $this->navigator->refresh(); + + return $this; + } catch (WebDriverException $exception) { + $this->dispatchOnException($exception); + throw $exception; + } + } + + public function to($url) + { + $this->dispatch( + 'beforeNavigateTo', + $url, + $this->getDispatcher()->getDefaultDriver() + ); + + try { + $this->navigator->to($url); + } catch (WebDriverException $exception) { + $this->dispatchOnException($exception); + throw $exception; + } + + $this->dispatch( + 'afterNavigateTo', + $url, + $this->getDispatcher()->getDefaultDriver() + ); + + return $this; + } + + /** + * @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); + } +} diff --git a/lib/Support/Events/EventFiringWebElement.php b/lib/Support/Events/EventFiringWebElement.php new file mode 100644 index 000000000..6caa08684 --- /dev/null +++ b/lib/Support/Events/EventFiringWebElement.php @@ -0,0 +1,413 @@ +element = $element; + $this->dispatcher = $dispatcher; + } + + /** + * @return WebDriverDispatcher + */ + public function getDispatcher() + { + return $this->dispatcher; + } + + /** + * @return WebDriverElement + */ + public function getElement() + { + return $this->element; + } + + /** + * @param mixed $value + * @throws WebDriverException + * @return $this + */ + 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); + + return $this; + } + + /** + * @throws WebDriverException + * @return $this + */ + public function click() + { + $this->dispatch('beforeClickOn', $this); + + try { + $this->element->click(); + } catch (WebDriverException $exception) { + $this->dispatchOnException($exception); + throw $exception; + } + $this->dispatch('afterClickOn', $this); + + return $this; + } + + /** + * @throws WebDriverException + * @return EventFiringWebElement + */ + public function findElement(WebDriverBy $by) + { + $this->dispatch( + 'beforeFindBy', + $by, + $this, + $this->dispatcher->getDefaultDriver() + ); + + try { + $element = $this->newElement($this->element->findElement($by)); + } catch (WebDriverException $exception) { + $this->dispatchOnException($exception); + throw $exception; + } + + $this->dispatch( + 'afterFindBy', + $by, + $this, + $this->dispatcher->getDefaultDriver() + ); + + return $element; + } + + /** + * @throws WebDriverException + * @return array + */ + public function findElements(WebDriverBy $by) + { + $this->dispatch( + 'beforeFindBy', + $by, + $this, + $this->dispatcher->getDefaultDriver() + ); + + try { + $elements = []; + foreach ($this->element->findElements($by) as $element) { + $elements[] = $this->newElement($element); + } + } catch (WebDriverException $exception) { + $this->dispatchOnException($exception); + throw $exception; + } + $this->dispatch( + 'afterFindBy', + $by, + $this, + $this->dispatcher->getDefaultDriver() + ); + + return $elements; + } + + /** + * @throws WebDriverException + * @return $this + */ + public function clear() + { + try { + $this->element->clear(); + + return $this; + } catch (WebDriverException $exception) { + $this->dispatchOnException($exception); + throw $exception; + } + } + + /** + * @param string $attribute_name + * @throws WebDriverException + * @return string + */ + public function getAttribute($attribute_name) + { + try { + return $this->element->getAttribute($attribute_name); + } catch (WebDriverException $exception) { + $this->dispatchOnException($exception); + throw $exception; + } + } + + /** + * @param string $css_property_name + * @throws WebDriverException + * @return string + */ + public function getCSSValue($css_property_name) + { + try { + return $this->element->getCSSValue($css_property_name); + } catch (WebDriverException $exception) { + $this->dispatchOnException($exception); + throw $exception; + } + } + + /** + * @throws WebDriverException + * @return WebDriverPoint + */ + public function getLocation() + { + try { + return $this->element->getLocation(); + } catch (WebDriverException $exception) { + $this->dispatchOnException($exception); + throw $exception; + } + } + + /** + * @throws WebDriverException + * @return WebDriverPoint + */ + public function getLocationOnScreenOnceScrolledIntoView() + { + try { + return $this->element->getLocationOnScreenOnceScrolledIntoView(); + } catch (WebDriverException $exception) { + $this->dispatchOnException($exception); + throw $exception; + } + } + + /** + * @return WebDriverCoordinates + */ + public function getCoordinates() + { + try { + return $this->element->getCoordinates(); + } catch (WebDriverException $exception) { + $this->dispatchOnException($exception); + throw $exception; + } + } + + /** + * @throws WebDriverException + * @return WebDriverDimension + */ + public function getSize() + { + try { + return $this->element->getSize(); + } catch (WebDriverException $exception) { + $this->dispatchOnException($exception); + throw $exception; + } + } + + /** + * @throws WebDriverException + * @return string + */ + public function getTagName() + { + try { + return $this->element->getTagName(); + } catch (WebDriverException $exception) { + $this->dispatchOnException($exception); + throw $exception; + } + } + + /** + * @throws WebDriverException + * @return string + */ + public function getText() + { + try { + return $this->element->getText(); + } catch (WebDriverException $exception) { + $this->dispatchOnException($exception); + throw $exception; + } + } + + /** + * @throws WebDriverException + * @return bool + */ + public function isDisplayed() + { + try { + return $this->element->isDisplayed(); + } catch (WebDriverException $exception) { + $this->dispatchOnException($exception); + throw $exception; + } + } + + /** + * @throws WebDriverException + * @return bool + */ + public function isEnabled() + { + try { + return $this->element->isEnabled(); + } catch (WebDriverException $exception) { + $this->dispatchOnException($exception); + throw $exception; + } + } + + /** + * @throws WebDriverException + * @return bool + */ + public function isSelected() + { + try { + return $this->element->isSelected(); + } catch (WebDriverException $exception) { + $this->dispatchOnException($exception); + throw $exception; + } + } + + /** + * @throws WebDriverException + * @return $this + */ + public function submit() + { + try { + $this->element->submit(); + + return $this; + } catch (WebDriverException $exception) { + $this->dispatchOnException($exception); + throw $exception; + } + } + + /** + * @throws WebDriverException + * @return string + */ + public function getID() + { + try { + return $this->element->getID(); + } catch (WebDriverException $exception) { + $this->dispatchOnException($exception); + throw $exception; + } + } + + /** + * Test if two element IDs refer to the same DOM element. + * + * @return bool + */ + public function equals(WebDriverElement $other) + { + try { + return $this->element->equals($other); + } catch (WebDriverException $exception) { + $this->dispatchOnException($exception); + throw $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() + ); + } + + /** + * @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 d82911fc8..52120a7d7 100755 --- a/lib/WebDriver.php +++ b/lib/WebDriver.php @@ -1,156 +1,143 @@ A list of all WebDriverElements, - * or an empty array if nothing matches - * @see WebDriverBy - */ - public function findElements(WebDriverBy $locator); - - /** - * Load a new web page in the current browser window. - * - * @return WebDriver The current instance. - */ - public function get($url); - - /** - * Get a string representing the current URL that the browser is looking at. - * - * @return string The current URL. - */ - public function getCurrentURL(); - - /** - * Get the source of the last loaded page. - * - * @return string The current page source. - */ - public function getPageSource(); - - /** - * Get the title of the current page. - * - * @return string The title of the current page. - */ - public function getTitle(); - - /** - * Return an opaque handle to this window that uniquely identifies it within - * this driver instance. - * - * @return string The current window handle. - */ - public function getWindowHandle(); - - /** - * Get all window handles available to the current session. - * - * @return array An array of string containing all available window handles. - */ - public function getWindowHandles(); - - /** - * Quits this driver, closing every associated window. - * - * @return void - */ - public function quit(); - - /** - * 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. - * @return mixed The return value of the script. - */ - public function executeScript($script, array $arguments = array()); - - /** - * Take a screenshot of the current page. - * - * @param string $save_as The path of the screenshot to be saved. - * @return string The screenshot in PNG format. - */ - public function takeScreenshot($save_as = null); - - /** - * Construct a new WebDriverWait by the current WebDriver instance. - * Sample usage: - * - * $driver->wait(20, 1000)->until( - * WebDriverExpectedCondition::titleIs('WebDriver Page') - * ); - * - * @return WebDriverWait - */ - public function wait( - $timeout_in_second = 30, - $interval_in_millisecond = 250); - - /** - * An abstraction for managing stuff you would do in a browser menu. For - * example, adding and deleting cookies. - * - * @return WebDriverOptions - */ - public function manage(); - - /** - * An abstraction allowing the driver to access the browser's history and to - * navigate to a given URL. - * - * @return WebDriverNavigation - * @see WebDriverNavigation - */ - public function navigate(); - - /** - * Switch to a different window or frame. - * - * @return WebDriverTargetLocator - * @see WebDriverTargetLocator - */ - public function switchTo(); +interface WebDriver extends WebDriverSearchContext +{ + /** + * Close the current window. + * + * @return WebDriver The current instance. + */ + public function close(); + + /** + * Load a new web page in the current browser window. + * + * @param string $url + * @return WebDriver The current instance. + */ + public function get($url); + + /** + * Get a string representing the current URL that the browser is looking at. + * + * @return string The current URL. + */ + public function getCurrentURL(); + + /** + * Get the source of the last loaded page. + * + * @return string The current page source. + */ + public function getPageSource(); + + /** + * Get the title of the current page. + * + * @return string The title of the current page. + */ + public function getTitle(); + + /** + * Return an opaque handle to this window that uniquely identifies it within + * this driver instance. + * + * @return string The current window handle. + */ + public function getWindowHandle(); + + /** + * Get all window handles available to the current session. + * + * @return array An array of string containing all available window handles. + */ + public function getWindowHandles(); + + /** + * Quits this driver, closing every associated window. + */ + public function quit(); + + /** + * Take a screenshot of the current page. + * + * @param string $save_as The path of the screenshot to be saved. + * @return string The screenshot in PNG format. + */ + public function takeScreenshot($save_as = null); + + /** + * 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 + * @return WebDriverWait + */ + public function wait( + $timeout_in_second = 30, + $interval_in_millisecond = 250 + ); + + /** + * An abstraction for managing stuff you would do in a browser menu. For + * example, adding and deleting cookies. + * + * @return WebDriverOptions + */ + public function manage(); + + /** + * An abstraction allowing the driver to access the browser's history and to + * navigate to a given URL. + * + * @return WebDriverNavigationInterface + * @see WebDriverNavigation + */ + public function navigate(); + + /** + * Switch to a different window or frame. + * + * @return WebDriverTargetLocator + * @see WebDriverTargetLocator + */ + public function switchTo(); + + // TODO: Add in next major release (BC) + ///** + // * @return WebDriverTouchScreen + // */ + //public function getTouch(); + + /** + * @param string $name + * @param array $params + * @return mixed + */ + public function execute($name, $params); + + // TODO: Add in next major release (BC) + ///** + // * 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 = []); } diff --git a/lib/WebDriverAction.php b/lib/WebDriverAction.php index 80a310a64..3b3a78418 100644 --- a/lib/WebDriverAction.php +++ b/lib/WebDriverAction.php @@ -1,25 +1,11 @@ executor = $executor; + } - protected $executor; + /** + * Accept alert + * + * @return WebDriverAlert The instance. + */ + public function accept() + { + $this->executor->execute(DriverCommand::ACCEPT_ALERT); - public function __construct($executor) { - $this->executor = $executor; - } + return $this; + } - /** - * Accept alert - * - * @return WebDriverAlert The instance. - */ - public function accept() { - $this->executor->execute('acceptAlert'); - return $this; - } + /** + * Dismiss alert + * + * @return WebDriverAlert The instance. + */ + public function dismiss() + { + $this->executor->execute(DriverCommand::DISMISS_ALERT); - /** - * Dismiss alert - * - * @return WebDriverAlert The instance. - */ - public function dismiss() { - $this->executor->execute('dismissAlert'); - return $this; - } + return $this; + } - /** - * Get alert text - * - * @return string - */ - public function getText() { - return $this->executor->execute('getAlertText'); - } + /** + * Get alert text + * + * @return string + */ + public function getText() + { + return $this->executor->execute(DriverCommand::GET_ALERT_TEXT); + } - /** - * Send keystrokes to javascript prompt() dialog - * - * @return WebDriverAlert - */ - public function sendKeys($value) { - $this->executor->execute('sendKeysToAlert', array('text' => $value)); - return $this; - } + /** + * Send keystrokes to javascript prompt() dialog + * + * @param string $value + * @return WebDriverAlert + */ + public function sendKeys($value) + { + $this->executor->execute( + DriverCommand::SET_ALERT_VALUE, + ['text' => $value] + ); + return $this; + } } diff --git a/lib/WebDriverBy.php b/lib/WebDriverBy.php index 7e0e9cce0..4bead6743 100644 --- a/lib/WebDriverBy.php +++ b/lib/WebDriverBy.php @@ -1,17 +1,6 @@ mechanism = $mechanism; - $this->value = $value; - } + protected function __construct($mechanism, $value) + { + $this->mechanism = $mechanism; + $this->value = $value; + } - /** - * @return string - */ - public function getMechanism() { - return $this->mechanism; - } + /** + * @return string + */ + public function getMechanism() + { + return $this->mechanism; + } - /** - * @return string - */ - public function getValue() { - return $this->value; - } + /** + * @return string + */ + public function getValue() + { + return $this->value; + } - /** - * Locates elements whose class name contains the search value; compound class - * names are not permitted. - * - * @return WebDriverBy - */ - public static function className($class_name) { - return new WebDriverBy('class name', $class_name); - } + /** + * Locates elements whose class name contains the search value; compound class + * names are not permitted. + * + * @param string $class_name + * @return static + */ + public static function className($class_name) + { + return new static('class name', $class_name); + } - /** - * Locates elements matching a CSS selector. - * - * @return WebDriverBy - */ - public static function cssSelector($css_selector) { - return new WebDriverBy('css selector', $css_selector); - } + /** + * Locates elements matching a CSS selector. + * + * @param string $css_selector + * @return static + */ + public static function cssSelector($css_selector) + { + return new static('css selector', $css_selector); + } - /** - * Locates elements whose ID attribute matches the search value. - * - * @return WebDriverBy - */ - public static function id($id) { - return new WebDriverBy('id', $id); - } + /** + * Locates elements whose ID attribute matches the search value. + * + * @param string $id + * @return static + */ + public static function id($id) + { + return new static('id', $id); + } - /** - * Locates elements whose NAME attribute matches the search value. - * @return WebDriverBy - */ - public static function name($name) { - return new WebDriverBy('name', $name); - } + /** + * Locates elements whose NAME attribute matches the search value. + * + * @param string $name + * @return static + */ + public static function name($name) + { + return new static('name', $name); + } - /** - * Locates anchor elements whose visible text matches the search value. - * - * @return WebDriverBy - */ - public static function linkText($link_text) { - return new WebDriverBy('link text', $link_text); - } + /** + * Locates anchor elements whose visible text matches the search value. + * + * @param string $link_text + * @return static + */ + public static function linkText($link_text) + { + return new static('link text', $link_text); + } - /** - * Locates anchor elements whose visible text partially matches the search - * value. - * - * @return WebDriverBy - */ - public static function partialLinkText($partial_link_text) { - return new WebDriverBy('partial link text', $partial_link_text); - } + /** + * Locates anchor elements whose visible text partially matches the search + * value. + * + * @param string $partial_link_text + * @return static + */ + public static function partialLinkText($partial_link_text) + { + return new static('partial link text', $partial_link_text); + } - /** - * Locates elements whose tag name matches the search value. - * - * @return WebDriverBy - */ - public static function tagName($tag_name) { - return new WebDriverBy('tag name', $tag_name); - } + /** + * Locates elements whose tag name matches the search value. + * + * @param string $tag_name + * @return static + */ + public static function tagName($tag_name) + { + return new static('tag name', $tag_name); + } - /** - * Locates elements matching an XPath expression. - * - * @return WebDriverBy - */ - public static function xpath($xpath) { - return new WebDriverBy('xpath', $xpath); - } + /** + * Locates elements matching an XPath expression. + * + * @param string $xpath + * @return static + */ + public static function xpath($xpath) + { + return new static('xpath', $xpath); + } } diff --git a/lib/WebDriverCapabilities.php b/lib/WebDriverCapabilities.php new file mode 100644 index 000000000..75cb99de2 --- /dev/null +++ b/lib/WebDriverCapabilities.php @@ -0,0 +1,46 @@ +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 b74bc4f67..7f6bb3ece 100644 --- a/lib/WebDriverCommandExecutor.php +++ b/lib/WebDriverCommandExecutor.php @@ -1,26 +1,17 @@ width = $width; - $this->height = $height; - } + /** + * @param int|float $width + * @param int|float $height + */ + public function __construct($width, $height) + { + $this->width = $width; + $this->height = $height; + } - /** - * Get the height. - * - * @return int The height. - */ - public function getHeight() { - return $this->height; - } + /** + * Get the height. + * + * @return int The height. + */ + public function getHeight() + { + return (int) $this->height; + } - /** - * Get the width. - * - * @return int The width. - */ - public function getWidth() { - return $this->width; - } + /** + * Get the width. + * + * @return int The width. + */ + public function getWidth() + { + return (int) $this->width; + } - /** - * Check whether the given dimension is the same as the instance. - * - * @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) { - return $this->height === $dimension->getHeight() && - $this->width === $dimension->getWidth(); - } + /** + * Check whether the given dimension is the same as the instance. + * + * @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(self $dimension) + { + return $this->height === $dimension->getHeight() && $this->width === $dimension->getWidth(); + } } diff --git a/lib/WebDriverDispatcher.php b/lib/WebDriverDispatcher.php index c5c4f191f..fe1ecb0f4 100644 --- a/lib/WebDriverDispatcher.php +++ b/lib/WebDriverDispatcher.php @@ -1,80 +1,75 @@ driver = $driver; - return $this; - } + /** + * this is needed so that EventFiringWebElement can pass the driver to the + * exception handling + * + * @return $this + */ + public function setDefaultDriver(EventFiringWebDriver $driver) + { + $this->driver = $driver; - /** - * @return null|EventFiringWebDriver - */ - public function getDefaultDriver() { - return $this->driver; - } + return $this; + } + + /** + * @return null|EventFiringWebDriver + */ + public function getDefaultDriver() + { + return $this->driver; + } - /** - * @param WebDriverEventListener $listener - * @return $this - */ - public function register(WebDriverEventListener $listener) { - $this->listeners[] = $listener; - return $this; - } + /** + * @return $this + */ + public function register(WebDriverEventListener $listener) + { + $this->listeners[] = $listener; - /** - * @param WebDriverEventListener $listener - * @return $this - */ - public function unregister(WebDriverEventListener $listener) { - $key = array_search($listener, $this->listeners, true); - if ($key !== false) { - unset($this->listeners[$key]); + return $this; } - return $this; - } - /** - * @param mixed $method - * @param mixed $arguments - * @return $this - */ - public function dispatch($method, $arguments) { - foreach ($this->listeners as $listener) { - call_user_func_array(array($listener, $method), $arguments); + /** + * @return $this + */ + public function unregister(WebDriverEventListener $listener) + { + $key = array_search($listener, $this->listeners, true); + if ($key !== false) { + unset($this->listeners[$key]); + } + + return $this; } - return $this; - } + /** + * @param mixed $method + * @param mixed $arguments + * @return $this + */ + public function dispatch($method, $arguments) + { + foreach ($this->listeners as $listener) { + call_user_func_array([$listener, $method], $arguments); + } + + return $this; + } } diff --git a/lib/WebDriverElement.php b/lib/WebDriverElement.php index c530123f8..8ffa18383 100644 --- a/lib/WebDriverElement.php +++ b/lib/WebDriverElement.php @@ -1,155 +1,154 @@ results = $results; - } - - public function getResults() { - return $this->results; - } - - /** - * Throw WebDriverExceptions. - * For $status_code >= 0, they are errors defined in the json wired protocol. - * For $status_code < 0, they are errors defined in php-webdriver. - */ - public static function throwException($status_code, $message, $results) { - switch ($status_code) { - case -1: - throw new WebDriverCurlException($message); - case 0: - // Success - break; - case 1: - throw new IndexOutOfBoundsWebDriverError($message, $results); - case 2: - throw new NoCollectionWebDriverError($message, $results); - case 3: - throw new NoStringWebDriverError($message, $results); - case 4: - throw new NoStringLengthWebDriverError($message, $results); - case 5: - throw new NoStringWrapperWebDriverError($message, $results); - case 6: - throw new NoSuchDriverWebDriverError($message, $results); - case 7: - throw new NoSuchElementWebDriverError($message, $results); - case 8: - throw new NoSuchFrameWebDriverError($message, $results); - case 9: - throw new UnknownCommandWebDriverError($message, $results); - case 10: - throw new ObsoleteElementWebDriverError($message, $results); - case 11: - throw new ElementNotDisplayedWebDriverError($message, $results); - case 12: - throw new InvalidElementStateWebDriverError($message, $results); - case 13: - throw new UnhandledWebDriverError($message, $results); - case 14: - throw new ExpectedWebDriverError($message, $results); - case 15: - throw new ElementNotSelectableWebDriverError($message, $results); - case 16: - throw new NoSuchDocumentWebDriverError($message, $results); - case 17: - throw new UnexpectedJavascriptWebDriverError($message, $results); - case 18: - throw new NoScriptResultWebDriverError($message, $results); - case 19: - throw new XPathLookupWebDriverError($message, $results); - case 20: - throw new NoSuchCollectionWebDriverError($message, $results); - case 21: - throw new TimeOutWebDriverError($message, $results); - case 22: - throw new NullPointerWebDriverError($message, $results); - case 23: - throw new NoSuchWindowWebDriverError($message, $results); - case 24: - throw new InvalidCookieDomainWebDriverError($message, $results); - case 25: - throw new UnableToSetCookieWebDriverError($message, $results); - case 26: - throw new UnexpectedAlertOpenWebDriverError($message, $results); - case 27: - throw new NoAlertOpenWebDriverError($message, $results); - case 28: - throw new ScriptTimeoutWebDriverError($message, $results); - case 29: - throw new InvalidElementCoordinatesWebDriverError($message, $results); - case 30: - throw new IMENotAvailableWebDriverError($message, $results); - case 31: - throw new IMEEngineActivationFailedWebDriverError($message, $results); - case 32: - throw new InvalidSelectorWebDriverError($message, $results); - case 33: - throw new SessionNotCreatedWebDriverError($message, $results); - case 34: - throw new MoveTargetOutOfBoundsWebDriverError($message, $results); - default: - throw new UnrecognizedWebDriverErrorWebDriverError($message, $results); - } - } -} - -class IndexOutOfBoundsWebDriverError extends WebDriverException {} // 1 -class NoCollectionWebDriverError extends WebDriverException {} // 2 -class NoStringWebDriverError extends WebDriverException {} // 3 -class NoStringLengthWebDriverError extends WebDriverException {} // 4 -class NoStringWrapperWebDriverError extends WebDriverException {} // 5 -class NoSuchDriverWebDriverError extends WebDriverException {} // 6 -class NoSuchElementWebDriverError extends WebDriverException {} // 7 -class NoSuchFrameWebDriverError extends WebDriverException {} // 8 -class UnknownCommandWebDriverError extends WebDriverException {} // 9 -class ObsoleteElementWebDriverError extends WebDriverException {} // 10 -class ElementNotDisplayedWebDriverError extends WebDriverException {} // 11 -class InvalidElementStateWebDriverError extends WebDriverException {} // 12 -class UnhandledWebDriverError extends WebDriverException {} // 13 -class ExpectedWebDriverError extends WebDriverException {} // 14 -class ElementNotSelectableWebDriverError extends WebDriverException {} // 15 -class NoSuchDocumentWebDriverError extends WebDriverException {} // 16 -class UnexpectedJavascriptWebDriverError extends WebDriverException {} // 17 -class NoScriptResultWebDriverError extends WebDriverException {} // 18 -class XPathLookupWebDriverError extends WebDriverException {} // 19 -class NoSuchCollectionWebDriverError extends WebDriverException {} // 20 -class TimeOutWebDriverError extends WebDriverException {} // 21 -class NullPointerWebDriverError extends WebDriverException {} // 22 -class NoSuchWindowWebDriverError extends WebDriverException {} // 23 -class InvalidCookieDomainWebDriverError extends WebDriverException {} // 24 -class UnableToSetCookieWebDriverError extends WebDriverException {} // 25 -class UnexpectedAlertOpenWebDriverError extends WebDriverException {} // 26 -class NoAlertOpenWebDriverError extends WebDriverException {} // 27 -class ScriptTimeoutWebDriverError extends WebDriverException {} // 28 -class InvalidElementCoordinatesWebDriverError extends WebDriverException {}// 29 -class IMENotAvailableWebDriverError extends WebDriverException {} // 30 -class IMEEngineActivationFailedWebDriverError extends WebDriverException {}// 31 -class InvalidSelectorWebDriverError extends WebDriverException {} // 32 -class SessionNotCreatedWebDriverError extends WebDriverException {} // 33 -class MoveTargetOutOfBoundsWebDriverError extends WebDriverException {} // 34 - -// Fallback -class UnrecognizedWebDriverErrorWebDriverError extends WebDriverException {} - -class UnexpectedTagNameException extends WebDriverException { - - public function __construct( - string $expected_tag_name, - string $actual_tag_name) { - parent::__construct( - sprintf( - "Element should have been \"%s\" but was \"%s\"", - $expected_tag_name, $actual_tag_name - ) - ); - } -} - -class UnsupportedOperationException extends WebDriverException {} diff --git a/lib/WebDriverExpectedCondition.php b/lib/WebDriverExpectedCondition.php index e00f5382e..188f3c4d8 100644 --- a/lib/WebDriverExpectedCondition.php +++ b/lib/WebDriverExpectedCondition.php @@ -1,410 +1,584 @@ apply; - } - - protected function __construct($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 WebDriverExpectedCondition True when the title matches, - * false otherwise. - */ - public static function titleIs($title) { - return new WebDriverExpectedCondition( - function ($driver) use ($title) { - return $title === $driver->getTitle(); - } - ); - } - - /** - * An expectation for checking substring of a page Title. - * - * @param string title The expected substring of Title. - * @return WebDriverExpectedCondition True when in title, - * false otherwise. - */ - public static function titleContains($title) { - return new WebDriverExpectedCondition( - function ($driver) use ($title) { - return strpos($driver->getTitle(), $title) !== false; - } - ); - } - - /** - * 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. - */ - public static function presenceOfElementLocated(WebDriverBy $by) { - return new WebDriverExpectedCondition( - function ($driver) use ($by) { - return $driver->findElement($by); - } - ); - } - - /** - * 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. - */ - public static function visibilityOfElementLocated(WebDriverBy $by) { - return new WebDriverExpectedCondition( - function ($driver) use ($by) { - try { - $element = $driver->findElement($by); - return $element->isDisplayed() ? $element : null; - } catch (ObsoleteElementWebDriverError $e) { - return 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. - */ - public static function visibilityOf(WebDriverElement $element) { - return new WebDriverExpectedCondition( - function ($driver) use ($element) { - return $element->isDisplayed() ? $element : null; - } - ); - } - - /** - * 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 WebDriverExpectedCondition An array of WebDriverElements - * once they are located. - */ - public static function presenceOfAllElementsLocatedBy(WebDriverBy $by) { - return new WebDriverExpectedCondition( - function ($driver) use ($by) { - $elements = $driver->findElements($by); - return count($elements) > 0 ? $elements : null; - } - ); - } - - /** - * An expectation for checking if the given text is present in the specified - * element. - * - * @param WebDriverBy $by The locator used to find the element. - * @param string $text The text to be presented in the element. - * @return WebDriverExpectedCondition Whether the text is presented. - */ - public static function textToBePresentInElement( - WebDriverBy $by, $text) { - return new WebDriverExpectedCondition( - function ($driver) use ($by, $text) { - try { - $element_text = $driver->findElement($by)->getText(); - return strpos($element_text, $text) !== false; - } catch (ObsoleteElementWebDriverError $e) { - return null; - } - } - ); - } - - /** - * 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 WebDriverExpectedCondition Whether the text is presented. - */ - public static function textToBePresentInElementValue( - WebDriverBy $by, $text) { - return new WebDriverExpectedCondition( - function ($driver) use ($by, $text) { - try { - $element_text = $driver->findElement($by)->getAttribute('value'); - return strpos($element_text, $text) !== false; - } catch (ObsoleteElementWebDriverError $e) { - return null; - } - } - ); - } - - /** - * 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 - */ - public static function frameToBeAvailableAndSwitchToIt( - string $frame_locator) { - return new WebDriverExpectedCondition( - function ($driver) use ($frame_locator) { - try { - return $driver->switchTo()->frame($frame_locator); - } catch (NoSuchFrameWebDriverError $e) { - return false; - } - } - ); - } - - /** - * 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 WebDriverExpectedCondition Whether there is no element - * located. - */ - public static function invisibilityOfElementLocated(WebDriverBy $by) { - return new WebDriverExpectedCondition( - function ($driver) use ($by) { - try { - return !($driver->findElement($by)->isDisplayed()); - } catch (NoSuchElementWebDriverError $e) { - return true; - } catch (ObsoleteElementWebDriverError $e) { - return true; - } - } - ); - } - - /** - * 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 string $text The text of the element. - * @return WebDriverExpectedCondition Whether the text is found in the - * element located. - */ - public static function invisibilityOfElementWithText( - WebDriverBy $by, $text) { - return new WebDriverExpectedCondition( - function ($driver) use ($by, $text) { - try { - return !($driver->findElement($by)->getText() === $text); - } catch (NoSuchElementWebDriverError $e) { - return true; - } catch (ObsoleteElementWebDriverError $e) { - return true; - } - } - ); - } - - /** - * 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 - */ - public static function elementToBeClickable(WebDriverBy $by) { - $visibility_of_element_located = - WebDriverExpectedCondition::visibilityOfElementLocated($by); - return new WebDriverExpectedCondition( - function ($driver) use ($visibility_of_element_located) { - $element = call_user_func( - $visibility_of_element_located->getApply(), - $driver +class WebDriverExpectedCondition +{ + /** + * A callable function to be executed by WebDriverWait. It should return + * a truthy value, mostly boolean or a WebDriverElement, on success. + * @var callable + */ + private $apply; + + protected function __construct(callable $apply) + { + $this->apply = $apply; + } + + /** + * @return callable A callable function to be executed by WebDriverWait + */ + public function getApply() + { + return $this->apply; + } + + /** + * An expectation for checking the title of a page. + * + * @param string $title The expected title, which must be an exact match. + * @return static Condition returns whether current page title equals given string. + */ + public static function titleIs($title) + { + return new static( + function (WebDriver $driver) use ($title) { + return $title === $driver->getTitle(); + } ); - try { - if ($element !== null && $element->isEnabled()) { - return $element; - } else { - return null; - } - } catch (ObsoleteElementWebDriverError $e) { - return null; - } - } - ); - } - - /** - * Wait until an element is no longer attached to the DOM. - * - * @param WebDriverElement $element The element to wait for. - * @return WebDriverExpectedCondition false if the element is still - * attached to the DOM, true otherwise. - */ - public static function stalenessOf(WebDriverElement $element) { - return new WebDriverExpectedCondition( - function ($driver) use ($element) { - try { - $element->isEnabled(); - return false; - } catch (ObsoleteElementWebDriverError $e) { - return true; - } - } - ); - } - - /** - * 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 ObsoleteElementWebDriverError 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. - */ - public static function refreshed(WebDriverExpectedCondition $condition) { - return new WebDriverExpectedCondition( - function ($driver) use ($condition) { - try { - return call_user_func($condition->getApply(), $driver); - } catch (ObsoleteElementWebDriverError $e) { - return null; - } - } - ); - } - - /** - * An expectation for checking if the given element is selected. - * - * @param mixed element_or_by Either the element or the locator. - * @return WebDriverExpectedCondition whether the element is selected. - */ - public static function elementToBeSelected($element_or_by) { - return WebDriverExpectedCondition::elementSelectionStateToBe( - $element_or_by, - true - ); - } - - /** - * An expectation for checking if the given element is selected. - * - * @param mixed $element_or_by Either the element or the locator. - * @param bool $selected The required state. - * @return WebDriverExpectedCondition Whether the element is selected. - */ - public static function elementSelectionStateToBe( - $element_or_by, - $selected - ) { - if ($element_or_by instanceof WebDriverElement) { - return new WebDriverExpectedCondition( - function ($driver) use ($element_or_by, $selected) { - return $element_or_by->isSelected === $selected; + } + + /** + * An expectation for checking substring of a page Title. + * + * @param string $title The expected substring of Title. + * @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 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. + * + * @param WebDriverBy $by The locator used to find the element. + * @return static Condition returns the WebDriverElement which is located. + */ + public static function presenceOfElementLocated(WebDriverBy $by) + { + return new static( + function (WebDriver $driver) use ($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. + * + * @param WebDriverBy $by The locator used to find the element. + * @return static Condition returns the WebDriverElement which is located and visible. + */ + public static function visibilityOfElementLocated(WebDriverBy $by) + { + return new static( + function (WebDriver $driver) use ($by) { + try { + $element = $driver->findElement($by); + + return $element->isDisplayed() ? $element : null; + } catch (StaleElementReferenceException $e) { + return null; + } + } + ); + } + + /** + * 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 static Condition returns the same WebDriverElement once it is visible. + */ + public static function visibilityOf(WebDriverElement $element) + { + return new static( + function () use ($element) { + return $element->isDisplayed() ? $element : null; + } + ); + } + + /** + * 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. + * @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 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, $text) { + try { + $element_text = $driver->findElement($by)->getText(); + + return mb_strpos($element_text, $text) !== false; + } catch (StaleElementReferenceException $e) { + return null; + } + } + ); + } + + /** + * 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 expected text of the element. + * @return static Condition returns whether the element has text value equal to given one. + */ + public static function elementTextIs(WebDriverBy $by, $text) + { + return new static( + function (WebDriver $driver) use ($by, $text) { + try { + return $driver->findElement($by)->getText() == $text; + } catch (StaleElementReferenceException $e) { + return null; + } + } + ); + } + + /** + * 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; + } + } + ); + } + + /** + * 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 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 mb_strpos($element_text, $text) !== false; + } catch (StaleElementReferenceException $e) { + return null; + } + } + ); + } + + /** + * 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 static Condition returns object focused on new frame when frame is found, false otherwise. + */ + public static function frameToBeAvailableAndSwitchToIt($frame_locator) + { + return new static( + function (WebDriver $driver) use ($frame_locator) { + try { + return $driver->switchTo()->frame($frame_locator); + } catch (NoSuchFrameException $e) { + return false; + } + } + ); + } + + /** + * 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 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|StaleElementReferenceException $e) { + return true; + } + } + ); + } + + /** + * 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 string $text The text of the element. + * @return static Condition returns whether the text is found in the element located. + */ + public static function invisibilityOfElementWithText(WebDriverBy $by, $text) + { + return new static( + function (WebDriver $driver) use ($by, $text) { + try { + return !($driver->findElement($by)->getText() === $text); + } catch (NoSuchElementException|StaleElementReferenceException $e) { + return true; + } + } + ); + } + + /** + * 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 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); + + return new static( + function (WebDriver $driver) use ($visibility_of_element_located) { + $element = call_user_func( + $visibility_of_element_located->getApply(), + $driver + ); + + try { + if ($element !== null && $element->isEnabled()) { + return $element; + } + + return null; + } catch (StaleElementReferenceException $e) { + return null; + } + } + ); + } + + /** + * Wait until an element is no longer attached to the DOM. + * + * @param WebDriverElement $element The element to wait for. + * @return static Condition returns whether the element is still attached to the DOM. + */ + public static function stalenessOf(WebDriverElement $element) + { + return new static( + function () use ($element) { + try { + $element->isEnabled(); + + return false; + } catch (StaleElementReferenceException $e) { + return true; + } + } + ); + } + + /** + * 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. + * + * @param WebDriverExpectedCondition $condition The condition wrapped. + * @return static Condition returns the return value of the getApply() of the given condition. + */ + public static function refreshed(self $condition) + { + return new static( + function (WebDriver $driver) use ($condition) { + try { + return call_user_func($condition->getApply(), $driver); + } catch (StaleElementReferenceException $e) { + return null; + } + } + ); + } + + /** + * An expectation for checking if the given element is selected. + * + * @param mixed $element_or_by Either the element or the locator. + * @return static Condition returns whether the element is selected. + */ + public static function elementToBeSelected($element_or_by) + { + return self::elementSelectionStateToBe( + $element_or_by, + true + ); + } + + /** + * An expectation for checking if the given element is selected. + * + * @param mixed $element_or_by Either the element or the locator. + * @param bool $selected The required state. + * @return static Condition returns whether the element is selected. + */ + public static function elementSelectionStateToBe($element_or_by, $selected) + { + if ($element_or_by instanceof WebDriverElement) { + return new static( + function () use ($element_or_by, $selected) { + return $element_or_by->isSelected() === $selected; + } + ); } - ); - } else if ($element_or_by instanceof WebDriverBy) { - return new WebDriverExpectedCondition( - function ($driver) use ($element_or_by, $selected) { - try { - $element = $driver->findElement($element_or_by); - return $element->isSelected === $selected; - } catch (ObsoleteElementWebDriverError $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 static Condition returns WebDriverAlert if alert() is present, null otherwise. + */ + public static function alertIsPresent() + { + return new static( + function (WebDriver $driver) { + try { + // Unlike the Java code, we get a WebDriverAlert object regardless + // of whether there is an alert. Calling getText() will throw + // an exception if it is not really there. + $alert = $driver->switchTo()->alert(); + $alert->getText(); + + return $alert; + } 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(self $condition) + { + return new static( + function (WebDriver $driver) use ($condition) { + $result = call_user_func($condition->getApply(), $driver); + + return !$result; + } + ); } - } - - /** - * An expectation for whether an alert() box is present. - * - * @return WebDriverExpectedCondition if alert() is present, - * null otherwise. - */ - public static function alertIsPresent() { - return new WebDriverExpectedCondition( - function ($driver) { - try { - // Unlike the Java code, we get a WebDriverAlert object regardless - // of whether there is an alert. Calling getText() will throw - // an exception if it is not really there. - $alert = $driver->switchTo()->alert(); - $alert->getText(); - return $alert; - } catch (NoAlertOpenWebDriverError $e) { - return null; - } - } - ); - } - - /** - * An expectation with the logical opposite condition of the given condition. - * - * @param WebDriverExpectedCondition $condition The condition to be negated. - * @return mixed The nagation of the result of the given condition. - */ - public static function not(WebDriverExpectedCondition $condition) { - return new WebDriverExpectedCondition( - function ($driver) use ($condition) { - $result = call_user_func($condition->getApply(), $driver); - return !$result; - } - ); - } } diff --git a/lib/WebDriverHasInputDevices.php b/lib/WebDriverHasInputDevices.php index 949c26dc8..efe41ae42 100644 --- a/lib/WebDriverHasInputDevices.php +++ b/lib/WebDriverHasInputDevices.php @@ -1,30 +1,19 @@ executor = $executor; - } +class WebDriverNavigation implements WebDriverNavigationInterface +{ + protected $executor; - /** - * Move back a single entry in the browser's history, if possible. - * - * @return WebDriverNavigation The instance. - */ - public function back() { - $this->executor->execute('goBack'); - return $this; - } + public function __construct(ExecuteMethod $executor) + { + $this->executor = $executor; + } - /** - * Move forward a single entry in the browser's history, if possible. - * - * @return WebDriverNavigation The instance. - */ - public function forward() { - $this->executor->execute('goForward'); - return $this; - } + public function back() + { + $this->executor->execute(DriverCommand::GO_BACK); - /** - * Refresh the current page. - * - * @return WebDriverNavigation The instance. - */ - public function refresh() { - $this->executor->execute('refreshPage'); - return $this; - } + return $this; + } - /** - * Navigate to the given URL. - * - * @return WebDriverNavigation The instance. - */ - public function to($url) { - $params = array('url' => (string)$url); - $this->executor->execute('get', $params); - return $this; - } + public function forward() + { + $this->executor->execute(DriverCommand::GO_FORWARD); + + return $this; + } + + public function refresh() + { + $this->executor->execute(DriverCommand::REFRESH); + + return $this; + } + + public function to($url) + { + $params = ['url' => (string) $url]; + $this->executor->execute(DriverCommand::GET, $params); + + return $this; + } } 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; - } - - /** - * 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 The path the cookie is visible to. If left blank or set - * to null, will be set to "/". - * 'domain': string The domain the cookie is visible to. It should be null or - * the same as the domain of the current URL. - * 'secure': bool Whether this cookie requires a secure connection(https?). - * It should be null or equal to the security of the current - * URL. - * 'expiry': int The cookie's expiration date; may be null. - * - * @param array $cookie An array with key as the attributes mentioned above. - * @return WebDriverOptions The current instance. - */ - public function addCookie(array $cookie) { - $this->validate($cookie); - $this->executor->execute('addCookie', array('cookie' => $cookie)); - return $this; - } - - /** - * Delete all the cookies that are currently visible. - * - * @return WebDriverOptions The current instance. - */ - public function deleteAllCookies() { - $this->executor->execute('deleteAllCookies'); - return $this; - } - - /** - * Delete the cookie with the give name. - * - * @return WebDriverOptions The current instance. - */ - public function deleteCookieNamed($name) { - $this->executor->execute('deleteCookie', array(':name' => $name)); - return $this; - } - - /** - * Get the cookie with a given name. - * - * @return array The cookie, or null if no cookie with the given name is - * presented. - */ - public function getCookieNamed($name) { - $cookies = $this->getCookies(); - foreach ($cookies as $cookie) { - if ($cookie['name'] === $name) { - return $cookie; - } +class WebDriverOptions +{ + /** + * @var ExecuteMethod + */ + protected $executor; + /** + * @var bool + */ + protected $isW3cCompliant; + + public function __construct(ExecuteMethod $executor, $isW3cCompliant = false) + { + $this->executor = $executor; + $this->isW3cCompliant = $isW3cCompliant; + } + + /** + * Add a specific cookie. + * + * @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($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->toArray()] + ); + + return $this; } - return null; - } - - /** - * Get all the cookies for the current domain. - * - * @return array The array of cookies presented. - */ - public function getCookies() { - return $this->executor->execute('getAllCookies'); - } - - 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 ";"'); + + /** + * Delete all the cookies that are currently visible. + * + * @return WebDriverOptions The current instance. + */ + public function deleteAllCookies() + { + $this->executor->execute(DriverCommand::DELETE_ALL_COOKIES); + + return $this; + } + + /** + * Delete the cookie with the given name. + * + * @param string $name + * @return WebDriverOptions The current instance. + */ + public function deleteCookieNamed($name) + { + $this->executor->execute( + DriverCommand::DELETE_COOKIE, + [':name' => $name] + ); + + return $this; } - if (!isset($cookie['value'])) { - throw new InvalidArgumentException( - '"value" is required when setting a cookie.'); + /** + * Get the cookie with a given name. + * + * @param string $name + * @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) { + return $cookie; + } + } + + return null; } - if (isset($cookie['domain']) && strpos($cookie['domain'], ':') !== false) { - throw new InvalidArgumentException( - '"domain" should not contain a port:'.(string)$cookie['domain']); + /** + * Get all the cookies for the current domain. + * + * @return Cookie[] The array of cookies presented. + */ + public function getCookies() + { + $cookieArrays = $this->executor->execute(DriverCommand::GET_ALL_COOKIES); + if (!is_array($cookieArrays)) { // Microsoft Edge returns null if there are no cookies... + return []; + } + + $cookies = []; + foreach ($cookieArrays as $cookieArray) { + $cookies[] = Cookie::createFromArray($cookieArray); + } + + return $cookies; } - } - - /** - * Return the interface for managing driver timeouts. - * - * @return WebDriverTimeouts - */ - public function timeouts() { - return new WebDriverTimeouts($this->executor); - } - - /** - * An abstraction allowing the driver to manipulate the browser's window - * - * @return WebDriverWindow - * @see WebDriverWindow - */ - public function window() { - return new WebDriverWindow($this->executor); - } - - /** - * Get the log for a given log type. Log buffer is reset after each request. - * - * @param $logType The log type. - * @return array The list of log entries. - * @see https://code.google.com/p/selenium/wiki/JsonWireProtocol#Log_Type - */ - public function getLog($log_type) { - return $this->executor->execute( - 'getLog', - array('type' => $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 - */ - public function getAvailableLogTypes() { - return $this->executor->execute('getAvailableLogTypes'); - } + /** + * Return the interface for managing driver timeouts. + * + * @return WebDriverTimeouts + */ + public function timeouts() + { + return new WebDriverTimeouts($this->executor, $this->isW3cCompliant); + } + + /** + * An abstraction allowing the driver to manipulate the browser's window + * + * @return WebDriverWindow + * @see WebDriverWindow + */ + public function window() + { + return new WebDriverWindow($this->executor, $this->isW3cCompliant); + } + + /** + * Get the log for a given log type. Log buffer is reset after each request. + * + * @param string $log_type The log type. + * @return array The list of log entries. + * @see https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol#log-type + */ + public function getLog($log_type) + { + return $this->executor->execute( + DriverCommand::GET_LOG, + ['type' => $log_type] + ); + } + + /** + * Get available log types. + * + * @return array The list of available log types. + * @see https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol#log-type + */ + public function getAvailableLogTypes() + { + return $this->executor->execute(DriverCommand::GET_AVAILABLE_LOG_TYPES); + } } diff --git a/lib/WebDriverPlatform.php b/lib/WebDriverPlatform.php new file mode 100644 index 000000000..a589f3013 --- /dev/null +++ b/lib/WebDriverPlatform.php @@ -0,0 +1,25 @@ +x = $x; + $this->y = $y; + } - private $x, $y; + /** + * Get the x-coordinate. + * + * @return int The x-coordinate of the point. + */ + public function getX() + { + return (int) $this->x; + } - public function __construct($x, $y) { - $this->x = $x; - $this->y = $y; - } + /** + * Get the y-coordinate. + * + * @return int The y-coordinate of the point. + */ + public function getY() + { + return (int) $this->y; + } - /** - * Get the x-coordinate. - * - * @return int The x-coordinate of the point. - */ - public function getX() { - return $this->x; - } + /** + * Set the point to a new position. + * + * @param int $new_x + * @param int $new_y + * @return WebDriverPoint The same instance with updated coordinates. + */ + public function move($new_x, $new_y) + { + $this->x = $new_x; + $this->y = $new_y; - /** - * Get the y-coordinate. - * - * @return int The y-coordinate of the point. - */ - public function getY() { - return $this->y; - } + return $this; + } - /** - * Set the point to a new position. - * - * @return WebDriverPoint The same instance with updated coordinates. - */ - public function move($new_x, $new_y) { - $this->x = $new_x; - $this->y = $new_y; - return $this; - } + /** + * Move the current by offsets. + * + * @param int $x_offset + * @param int $y_offset + * @return WebDriverPoint The same instance with updated coordinates. + */ + public function moveBy($x_offset, $y_offset) + { + $this->x += $x_offset; + $this->y += $y_offset; - /** - * Move the current by offsets. - * - * @return WebDriverPoint The same instance with updated coordinates. - */ - public function moveBy($x_offset, $y_offset) { - $this->x += $x_offset; - $this->y += $y_offset; - return $this; - } + return $this; + } - /** - * Check whether the given point is the same as the instance. - * - * @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) { - return $this->x === $point->getX() && - $this->y === $point->getY(); - } + /** + * Check whether the given point is the same as the instance. + * + * @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(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 2bcd7660e..5fb1daaf9 100644 --- a/lib/WebDriverSearchContext.php +++ b/lib/WebDriverSearchContext.php @@ -1,42 +1,28 @@ getTagName(); +/** + * Models a default HTML ` is shown. + let enclosingSelectElement = enclosingNodeOrSelfMatchingPredicate(element, (e) => e.tagName.toUpperCase() === 'SELECT'); + return isElementDisplayed(enclosingSelectElement); + } + case 'INPUT': + // 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/lib/support/events/EventFiringWebDriver.php b/lib/support/events/EventFiringWebDriver.php deleted file mode 100644 index f86a6c4d2..000000000 --- a/lib/support/events/EventFiringWebDriver.php +++ /dev/null @@ -1,327 +0,0 @@ -dispatcher = $dispatcher ?: new WebDriverDispatcher(); - if (!$this->dispatcher->getDefaultDriver()) { - $this->dispatcher->setDefaultDriver($this); - } - $this->driver = $driver; - return $this; - } - - /** - * @return WebDriverDispatcher - */ - public function getDispatcher() { - return $this->dispatcher; - } - - /** - * @param mixed $method - * @return void - */ - protected function dispatch($method) { - if (!$this->dispatcher) { - return; - } - - $arguments = func_get_args(); - unset($arguments[0]); - $this->dispatcher->dispatch($method, $arguments); - } - - /** - * @return WebDriver - */ - public function getWebDriver() { - return $this->driver; - } - - /** - * @param WebDriverElement $element - * @return EventFiringWebElement - */ - private function newElement(WebDriverElement $element) { - return new EventFiringWebElement($element, $this->getDispatcher()); - } - - /** - * @param mixed $url - * @return $this - * @throws WebDriverException - */ - public function get($url) { - $this->dispatch('beforeNavigateTo', $url, $this); - try { - $this->driver->get($url); - } catch (WebDriverException $exception) { - $this->dispatchOnException($exception); - } - $this->dispatch('afterNavigateTo', $url, $this); - return $this; - } - - /** - * @param WebDriverBy $by - * @return array - * @throws WebDriverException - */ - public function findElements(WebDriverBy $by) { - $this->dispatch('beforeFindBy', $by, null, $this); - try { - $elements = array(); - foreach ($this->driver->findElements($by) as $element) { - $elements[] = $this->newElement($element); - } - } catch (WebDriverException $exception) { - $this->dispatchOnException($exception); - } - $this->dispatch('afterFindBy', $by, null, $this); - return $elements; - - } - - /** - * @param WebDriverBy $by - * @return EventFiringWebElement - * @throws WebDriverException - */ - public function findElement(WebDriverBy $by) { - $this->dispatch('beforeFindBy', $by, null, $this); - try { - $element = $this->newElement($this->driver->findElement($by)); - } catch (WebDriverException $exception) { - $this->dispatchOnException($exception); - } - $this->dispatch('afterFindBy', $by, null, $this); - return $element; - } - - /** - * @param $script - * @param array $arguments - * @return mixed - * @throws WebDriverException - */ - public function executeScript($script, array $arguments = array()) { - $this->dispatch('beforeScript', $script, $this); - try { - $result = $this->driver->executeScript($script, $arguments); - } catch (WebDriverException $exception) { - $this->dispatchOnException($exception); - } - $this->dispatch('afterScript', $script, $this); - return $result; - } - - /** - * @return $this - * @throws WebDriverException - */ - public function close() { - try { - $this->driver->close(); - return $this; - } catch (WebDriverException $exception) { - $this->dispatchOnException($exception); - } - } - - /** - * @return string - * @throws WebDriverException - */ - public function getCurrentURL() { - try { - return $this->driver->getCurrentURL(); - } catch (WebDriverException $exception) { - $this->dispatchOnException($exception); - } - } - - /** - * @return string - * @throws WebDriverException - */ - public function getPageSource() { - try { - return $this->driver->getPageSource(); - } catch (WebDriverException $exception) { - $this->dispatchOnException($exception); - } - } - - /** - * @return string - * @throws WebDriverException - */ - public function getTitle() { - try { - return $this->driver->getTitle(); - } catch (WebDriverException $exception) { - $this->dispatchOnException($exception); - } - } - - /** - * @return string - * @throws WebDriverException - */ - public function getWindowHandle() { - try { - return $this->driver->getWindowHandle(); - } catch (WebDriverException $exception) { - $this->dispatchOnException($exception); - } - } - - /** - * @return array - * @throws WebDriverException - */ - public function getWindowHandles() { - try { - return $this->driver->getWindowHandles(); - } catch (WebDriverException $exception) { - $this->dispatchOnException($exception); - } - } - - /** - * @throws WebDriverException - */ - public function quit() { - try { - $this->driver->quit(); - } catch (WebDriverException $exception) { - $this->dispatchOnException($exception); - } - } - - /** - * @param null|string $save_as - * @return string - * @throws WebDriverException - */ - public function takeScreenshot($save_as = null) { - try { - return $this->driver->takeScreenshot($save_as); - } catch (WebDriverException $exception) { - $this->dispatchOnException($exception); - } - } - - /** - * @param int $timeout_in_second - * @param int $interval_in_millisecond - * @return WebDriverWait - * @throws WebDriverException - */ - public function wait($timeout_in_second = 30, - $interval_in_millisecond = 250) { - try { - return $this->driver->wait($timeout_in_second, $interval_in_millisecond); - } catch (WebDriverException $exception) { - $this->dispatchOnException($exception); - } - } - - /** - * @return WebDriverOptions - * @throws WebDriverException - */ - public function manage() { - try { - return $this->driver->manage(); - } catch (WebDriverException $exception) { - $this->dispatchOnException($exception); - } - } - - /** - * @return EventFiringWebDriverNavigation - * @throws WebDriverException - */ - public function navigate() { - try { - return new EventFiringWebDriverNavigation( - $this->driver->navigate(), - $this->getDispatcher() - ); - } catch (WebDriverException $exception) { - $this->dispatchOnException($exception); - } - } - - /** - * @return WebDriverTargetLocator - * @throws WebDriverException - */ - public function switchTo() { - try { - return $this->driver->switchTo(); - } catch (WebDriverException $exception) { - $this->dispatchOnException($exception); - } - } - - /** - * @return WebDriverTouchScreen - * @throws WebDriverException - */ - public function getTouch() { - try { - return $this->driver->getTouch(); - } catch (WebDriverException $exception) { - $this->dispatchOnException($exception); - } - } - - /** - * Get the element on the page that currently has focus. - * - * @return WebDriverElement - */ - public function getActiveElement() { - try { - return $this->driver->getActiveElement(); - } catch (WebDriverException $exception) { - $this->dispatchOnException($exception); - } - } - - private function dispatchOnException($exception) { - $this->dispatch('onException', $exception, $this); - throw $exception; - } -} diff --git a/lib/support/events/EventFiringWebDriverNavigation.php b/lib/support/events/EventFiringWebDriverNavigation.php deleted file mode 100644 index 5fb96f929..000000000 --- a/lib/support/events/EventFiringWebDriverNavigation.php +++ /dev/null @@ -1,150 +0,0 @@ -navigator = $navigator; - $this->dispatcher = $dispatcher; - return $this; - } - - /** - * @return WebDriverDispatcher - */ - public function getDispatcher() { - return $this->dispatcher; - } - - /** - * @param mixed $method - * @return void - */ - protected function dispatch($method) { - if (!$this->dispatcher) { - return; - } - - $arguments = func_get_args(); - unset($arguments[0]); - $this->dispatcher->dispatch($method, $arguments); - } - - /** - * @return WebDriverNavigation - */ - public function getNavigator() { - return $this->navigator; - } - - /** - * @return $this - * @throws WebDriverException - */ - public function back() { - $this->dispatch( - 'beforeNavigateBack', - $this->getDispatcher()->getDefaultDriver() - ); - try { - $this->navigator->back(); - } catch (WebDriverException $exception) { - $this->dispatchOnException($exception); - } - $this->dispatch( - 'afterNavigateBack', - $this->getDispatcher()->getDefaultDriver() - ); - return $this; - } - - /** - * @return $this - * @throws WebDriverException - */ - public function forward() { - $this->dispatch( - 'beforeNavigateForward', - $this->getDispatcher()->getDefaultDriver() - ); - try { - $this->navigator->forward(); - } catch (WebDriverException $exception) { - $this->dispatchOnException($exception); - } - $this->dispatch( - 'afterNavigateForward', - $this->getDispatcher()->getDefaultDriver() - ); - return $this; - } - - /** - * @return $this - * @throws WebDriverException - */ - public function refresh() { - try { - $this->navigator->refresh(); - return $this; - } catch (WebDriverException $exception) { - $this->dispatchOnException($exception); - } - } - - /** - * @param mixed $url - * @return $this - * @throws WebDriverException - */ - public function to($url) { - $this->dispatch( - 'beforeNavigateTo', - $url, - $this->getDispatcher()->getDefaultDriver() - ); - try { - $this->navigator->to($url); - } catch (WebDriverException $exception) { - $this->dispatchOnException($exception); - } - $this->dispatch( - 'afterNavigateTo', - $url, - $this->getDispatcher()->getDefaultDriver() - ); - return $this; - } - - private function dispatchOnException($exception) { - $this->dispatch('onException', $exception); - throw $exception; - } -} diff --git a/lib/support/events/EventFiringWebElement.php b/lib/support/events/EventFiringWebElement.php deleted file mode 100644 index 162aeca9c..000000000 --- a/lib/support/events/EventFiringWebElement.php +++ /dev/null @@ -1,358 +0,0 @@ -element = $element; - $this->dispatcher = $dispatcher; - return $this; - } - - /** - * @return WebDriverDispatcher - */ - public function getDispatcher() { - return $this->dispatcher; - } - - /** - * @param mixed $method - * @return void - */ - protected function dispatch($method) { - if (!$this->dispatcher) { - return; - } - $arguments = func_get_args(); - unset($arguments[0]); - $this->dispatcher->dispatch($method, $arguments); - } - - /** - * @return WebDriverElement - */ - public function getElement() { - return $this->element; - } - - /** - * @param WebDriverElement $element - * @return EventFiringWebElement - */ - private function newElement(WebDriverElement $element) { - return new EventFiringWebElement($element, $this->getDispatcher()); - } - - /** - * @param mixed $value - * @return $this - * @throws WebDriverException - */ - public function sendKeys($value) { - - $this->dispatch('beforeChangeValueOf', $this); - try { - $this->element->sendKeys($value); - } catch (WebDriverException $exception) { - $this->dispatchOnException($exception); - } - $this->dispatch('afterChangeValueOf', $this); - return $this; - - } - - /** - * @return $this - * @throws WebDriverException - */ - public function click() { - $this->dispatch('beforeClickOn', $this); - try { - $this->element->click(); - } catch (WebDriverException $exception) { - $this->dispatchOnException($exception); - } - $this->dispatch('afterClickOn', $this); - return $this; - } - - /** - * @param WebDriverBy $by - * @return EventFiringWebElement - * @throws WebDriverException - */ - public function findElement(WebDriverBy $by) { - $this->dispatch( - 'beforeFindBy', - $by, - $this, - $this->dispatcher->getDefaultDriver()); - try { - $element = $this->newElement($this->element->findElement($by)); - } catch (WebDriverException $exception) { - $this->dispatchOnException($exception); - } - $this->dispatch( - 'afterFindBy', - $by, - $this, - $this->dispatcher->getDefaultDriver() - ); - return $element; - } - - /** - * @param WebDriverBy $by - * @return array - * @throws WebDriverException - */ - public function findElements(WebDriverBy $by) { - $this->dispatch( - 'beforeFindBy', - $by, - $this, - $this->dispatcher->getDefaultDriver() - ); - try { - $elements = array(); - foreach ($this->element->findElements($by) as $element) { - $elements[] = $this->newElement($element); - } - } catch (WebDriverException $exception) { - $this->dispatchOnException($exception); - } - $this->dispatch( - 'afterFindBy', - $by, - $this, - $this->dispatcher->getDefaultDriver() - ); - return $elements; - } - - /** - * @return $this - * @throws WebDriverException - */ - public function clear() { - try { - $this->element->clear(); - return $this; - } catch (WebDriverException $exception) { - $this->dispatchOnException($exception); - } - } - - /** - * @param string $attribute_name - * @return string - * @throws WebDriverException - */ - public function getAttribute($attribute_name) { - try { - return $this->element->getAttribute($attribute_name); - } catch (WebDriverException $exception) { - $this->dispatchOnException($exception); - } - } - - /** - * @param string $css_property_name - * @return string - * @throws WebDriverException - */ - public function getCSSValue($css_property_name) { - try { - return $this->element->getCSSValue($css_property_name); - } catch (WebDriverException $exception) { - $this->dispatchOnException($exception); - } - } - - /** - * @return WebDriverLocation - * @throws WebDriverException - */ - public function getLocation() { - try { - return $this->element->getLocation(); - } catch (WebDriverException $exception) { - $this->dispatchOnException($exception); - } - } - - /** - * @return WebDriverLocation - * @throws WebDriverException - */ - public function getLocationOnScreenOnceScrolledIntoView() { - try { - return $this->element->getLocationOnScreenOnceScrolledIntoView(); - } catch (WebDriverException $exception) { - $this->dispatchOnException($exception); - } - } - - /** - * @return WebDriverCoordinates - */ - public function getCoordinates() { - try { - return $this->element->getCoordinates(); - } catch (WebDriverException $exception) { - $this->dispatchOnException($exception); - } - } - - - /** - * @return WebDriverDimension - * @throws WebDriverException - */ - public function getSize() { - try { - return $this->element->getSize(); - } catch (WebDriverException $exception) { - $this->dispatchOnException($exception); - } - } - - /** - * @return string - * @throws WebDriverException - */ - public function getTagName() { - try { - return $this->element->getTagName(); - } catch (WebDriverException $exception) { - $this->dispatchOnException($exception); - } - } - - /** - * @return string - * @throws WebDriverException - */ - public function getText() { - try { - return $this->element->getText(); - } catch (WebDriverException $exception) { - $this->dispatchOnException($exception); - } - } - - /** - * @return bool - * @throws WebDriverException - */ - public function isDisplayed() { - try { - return $this->element->isDisplayed(); - } catch (WebDriverException $exception) { - $this->dispatchOnException($exception); - } - } - - /** - * @return bool - * @throws WebDriverException - */ - public function isEnabled() { - try { - return $this->element->isEnabled(); - } catch (WebDriverException $exception) { - $this->dispatchOnException($exception); - } - } - - /** - * @return bool - * @throws WebDriverException - */ - public function isSelected() { - try { - return $this->element->isSelected(); - } catch (WebDriverException $exception) { - $this->dispatchOnException($exception); - } - } - - /** - * @return $this - * @throws WebDriverException - */ - public function submit() { - try { - $this->element->submit(); - return $this; - } catch (WebDriverException $exception) { - $this->dispatchOnException($exception); - } - } - - /** - * @return string - * @throws WebDriverException - */ - public function getID() { - try { - return $this->element->getID(); - } catch (WebDriverException $exception) { - $this->dispatchOnException($exception); - } - } - - - /** - * Test if two element IDs refer to the same DOM element. - * - * @param WebDriverElement $other - * @return bool - */ - public function equals(WebDriverElement $other) { - try { - return $this->element->equals($other); - } catch (WebDriverException $exception) { - $this->dispatchOnException($exception); - } - } - - private function dispatchOnException($exception) { - $this->dispatch( - 'onException', - $exception, - $this->dispatcher->getDefaultDriver() - ); - throw $exception; - } - - -} 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 new file mode 100644 index 000000000..c58c72eaa --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,25 @@ + + + + + tests/unit + + + 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/BasePHPWebDriverTestCase.php b/tests/BasePHPWebDriverTestCase.php deleted file mode 100644 index 6b49db2bb..000000000 --- a/tests/BasePHPWebDriverTestCase.php +++ /dev/null @@ -1,43 +0,0 @@ -driver = RemoteWebDriver::create( - 'http://localhost:4444/wd/hub', - array( - WebDriverCapabilityType::BROWSER_NAME - => WebDriverBrowserType::HTMLUNIT, - ) - ); - } - - protected function tearDown() { - $this->driver->quit(); - } - - /** - * Get the URL of the test html. - */ - protected function getTestPath($path) { - return 'file:///'.dirname(__FILE__).'/html/'.$path; - } -} diff --git a/tests/ExampleTestCase.php b/tests/ExampleTestCase.php deleted file mode 100644 index 982e7e6ad..000000000 --- a/tests/ExampleTestCase.php +++ /dev/null @@ -1,41 +0,0 @@ -driver->get($this->getTestPath('index.html')); - self::assertEquals( - 'php-webdriver test page', - $this->driver->getTitle() - ); - } - - public function testTestPageWelcome() { - $this->driver->get($this->getTestPath('index.html')); - self::assertEquals( - 'Welcome to the facebook/php-webdriver testing page.', - $this->driver->findElement(WebDriverBy::id('welcome'))->getText() - ); - } -} \ No newline at end of file diff --git a/tests/__init__.php b/tests/__init__.php deleted file mode 100644 index d90f7b531..000000000 --- a/tests/__init__.php +++ /dev/null @@ -1,16 +0,0 @@ -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 new file mode 100644 index 000000000..e812353ea --- /dev/null +++ b/tests/functional/FileUploadTest.php @@ -0,0 +1,52 @@ +driver->get($this->getTestPageUrl(TestPage::UPLOAD)); + + $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 getTestFilePath(): string + { + 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/Fixtures/FileUploadTestFile.txt b/tests/functional/Fixtures/FileUploadTestFile.txt new file mode 100644 index 000000000..0ca232e37 --- /dev/null +++ b/tests/functional/Fixtures/FileUploadTestFile.txt @@ -0,0 +1 @@ +text file 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 new file mode 100644 index 000000000..db0fd8b0f --- /dev/null +++ b/tests/functional/WebDriverTestCase.php @@ -0,0 +1,248 @@ +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(): void + { + 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); + } + } + + /** + * Mark a test as skipped if the current browser is not in the list of browsers. + * + * @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+ + */ + public function runBare(): void + { + $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/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/html/index.html b/tests/html/index.html deleted file mode 100644 index c8739903a..000000000 --- a/tests/html/index.html +++ /dev/null @@ -1,8 +0,0 @@ - - - php-webdriver test page - - -

    Welcome to the facebook/php-webdriver testing page.

    - - \ No newline at end of file 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 new file mode 100644 index 000000000..f14bad0c2 --- /dev/null +++ b/tests/unit/Interactions/Internal/WebDriverButtonReleaseActionTest.php @@ -0,0 +1,36 @@ +webDriverMouse = $this->getMockBuilder(WebDriverMouse::class)->getMock(); + $this->locationProvider = $this->getMockBuilder(WebDriverLocatable::class)->getMock(); + $this->webDriverButtonReleaseAction = new WebDriverButtonReleaseAction( + $this->webDriverMouse, + $this->locationProvider + ); + } + + 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')->willReturn($coords); + $this->webDriverButtonReleaseAction->perform(); + } +} diff --git a/tests/unit/Interactions/Internal/WebDriverClickActionTest.php b/tests/unit/Interactions/Internal/WebDriverClickActionTest.php new file mode 100644 index 000000000..de12aab26 --- /dev/null +++ b/tests/unit/Interactions/Internal/WebDriverClickActionTest.php @@ -0,0 +1,36 @@ +webDriverMouse = $this->getMockBuilder(WebDriverMouse::class)->getMock(); + $this->locationProvider = $this->getMockBuilder(WebDriverLocatable::class)->getMock(); + $this->webDriverClickAction = new WebDriverClickAction( + $this->webDriverMouse, + $this->locationProvider + ); + } + + 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')->willReturn($coords); + $this->webDriverClickAction->perform(); + } +} diff --git a/tests/unit/Interactions/Internal/WebDriverClickAndHoldActionTest.php b/tests/unit/Interactions/Internal/WebDriverClickAndHoldActionTest.php new file mode 100644 index 000000000..da5a18dee --- /dev/null +++ b/tests/unit/Interactions/Internal/WebDriverClickAndHoldActionTest.php @@ -0,0 +1,36 @@ +webDriverMouse = $this->getMockBuilder(WebDriverMouse::class)->getMock(); + $this->locationProvider = $this->getMockBuilder(WebDriverLocatable::class)->getMock(); + $this->webDriverClickAndHoldAction = new WebDriverClickAndHoldAction( + $this->webDriverMouse, + $this->locationProvider + ); + } + + 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')->willReturn($coords); + $this->webDriverClickAndHoldAction->perform(); + } +} diff --git a/tests/unit/Interactions/Internal/WebDriverContextClickActionTest.php b/tests/unit/Interactions/Internal/WebDriverContextClickActionTest.php new file mode 100644 index 000000000..f3e66f6ee --- /dev/null +++ b/tests/unit/Interactions/Internal/WebDriverContextClickActionTest.php @@ -0,0 +1,36 @@ +webDriverMouse = $this->getMockBuilder(WebDriverMouse::class)->getMock(); + $this->locationProvider = $this->getMockBuilder(WebDriverLocatable::class)->getMock(); + $this->webDriverContextClickAction = new WebDriverContextClickAction( + $this->webDriverMouse, + $this->locationProvider + ); + } + + 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')->willReturn($coords); + $this->webDriverContextClickAction->perform(); + } +} diff --git a/tests/unit/Interactions/Internal/WebDriverCoordinatesTest.php b/tests/unit/Interactions/Internal/WebDriverCoordinatesTest.php new file mode 100644 index 000000000..f8faee8d8 --- /dev/null +++ b/tests/unit/Interactions/Internal/WebDriverCoordinatesTest.php @@ -0,0 +1,25 @@ +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 new file mode 100644 index 000000000..d4ae699f1 --- /dev/null +++ b/tests/unit/Interactions/Internal/WebDriverDoubleClickActionTest.php @@ -0,0 +1,36 @@ +webDriverMouse = $this->getMockBuilder(WebDriverMouse::class)->getMock(); + $this->locationProvider = $this->getMockBuilder(WebDriverLocatable::class)->getMock(); + $this->webDriverDoubleClickAction = new WebDriverDoubleClickAction( + $this->webDriverMouse, + $this->locationProvider + ); + } + + 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')->willReturn($coords); + $this->webDriverDoubleClickAction->perform(); + } +} diff --git a/tests/unit/Interactions/Internal/WebDriverKeyDownActionTest.php b/tests/unit/Interactions/Internal/WebDriverKeyDownActionTest.php new file mode 100644 index 000000000..0fd8aa9dd --- /dev/null +++ b/tests/unit/Interactions/Internal/WebDriverKeyDownActionTest.php @@ -0,0 +1,44 @@ +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, + WebDriverKeys::LEFT_SHIFT + ); + } + + public function testPerformFocusesOnElementAndSendPressKeyCommand(): void + { + $coords = $this->createMock(WebDriverCoordinates::class); + $this->webDriverMouse->expects($this->once())->method('click')->with($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 new file mode 100644 index 000000000..f10d53b09 --- /dev/null +++ b/tests/unit/Interactions/Internal/WebDriverKeyUpActionTest.php @@ -0,0 +1,44 @@ +webDriverKeyboard = $this->getMockBuilder(WebDriverKeyboard::class)->getMock(); + $this->webDriverMouse = $this->getMockBuilder(WebDriverMouse::class)->getMock(); + $this->locationProvider = $this->getMockBuilder(WebDriverLocatable::class)->getMock(); + + $this->webDriverKeyUpAction = new WebDriverKeyUpAction( + $this->webDriverKeyboard, + $this->webDriverMouse, + $this->locationProvider, + WebDriverKeys::LEFT_SHIFT + ); + } + + public function testPerformFocusesOnElementAndSendPressKeyCommand(): void + { + $coords = $this->createMock(WebDriverCoordinates::class); + $this->webDriverMouse->expects($this->once())->method('click')->with($coords); + $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 new file mode 100644 index 000000000..f23594852 --- /dev/null +++ b/tests/unit/Interactions/Internal/WebDriverMouseMoveActionTest.php @@ -0,0 +1,37 @@ +webDriverMouse = $this->getMockBuilder(WebDriverMouse::class)->getMock(); + $this->locationProvider = $this->getMockBuilder(WebDriverLocatable::class)->getMock(); + + $this->webDriverMouseMoveAction = new WebDriverMouseMoveAction( + $this->webDriverMouse, + $this->locationProvider + ); + } + + 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')->willReturn($coords); + $this->webDriverMouseMoveAction->perform(); + } +} diff --git a/tests/unit/Interactions/Internal/WebDriverMouseToOffsetActionTest.php b/tests/unit/Interactions/Internal/WebDriverMouseToOffsetActionTest.php new file mode 100644 index 000000000..966b88325 --- /dev/null +++ b/tests/unit/Interactions/Internal/WebDriverMouseToOffsetActionTest.php @@ -0,0 +1,41 @@ +webDriverMouse = $this->getMockBuilder(WebDriverMouse::class)->getMock(); + $this->locationProvider = $this->getMockBuilder(WebDriverLocatable::class)->getMock(); + + $this->webDriverMoveToOffsetAction = new WebDriverMoveToOffsetAction( + $this->webDriverMouse, + $this->locationProvider, + 150, + 200 + ); + } + + 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')->willReturn($coords); + $this->webDriverMoveToOffsetAction->perform(); + } +} diff --git a/tests/unit/Interactions/Internal/WebDriverSendKeysActionTest.php b/tests/unit/Interactions/Internal/WebDriverSendKeysActionTest.php new file mode 100644 index 000000000..b610d083f --- /dev/null +++ b/tests/unit/Interactions/Internal/WebDriverSendKeysActionTest.php @@ -0,0 +1,47 @@ +webDriverKeyboard = $this->getMockBuilder(WebDriverKeyboard::class)->getMock(); + $this->webDriverMouse = $this->getMockBuilder(WebDriverMouse::class)->getMock(); + $this->locationProvider = $this->getMockBuilder(WebDriverLocatable::class)->getMock(); + + $this->keys = ['t', 'e', 's', 't']; + $this->webDriverSendKeysAction = new WebDriverSendKeysAction( + $this->webDriverKeyboard, + $this->webDriverMouse, + $this->locationProvider, + $this->keys + ); + } + + 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')->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 new file mode 100644 index 000000000..e57b2a4d3 --- /dev/null +++ b/tests/unit/Remote/DesiredCapabilitiesTest.php @@ -0,0 +1,319 @@ + 'fooVal', WebDriverCapabilityType::PLATFORM => WebDriverPlatform::ANY] + ); + + $this->assertSame('fooVal', $capabilities->getCapability('fooKey')); + $this->assertSame('ANY', $capabilities->getPlatform()); + + $this->assertSame( + ['fooKey' => 'fooVal', WebDriverCapabilityType::PLATFORM => WebDriverPlatform::ANY], + $capabilities->toArray() + ); + } + + public function testShouldInstantiateEmptyInstance(): void + { + $capabilities = new DesiredCapabilities(); + + $this->assertNull($capabilities->getCapability('foo')); + $this->assertSame([], $capabilities->toArray()); + } + + public function testShouldProvideAccessToCapabilitiesUsingSettersAndGetters(): void + { + $capabilities = new DesiredCapabilities(); + // generic capability setter + $capabilities->setCapability('custom', 1337); + // specific setters + $capabilities->setBrowserName(WebDriverBrowserType::CHROME); + $capabilities->setPlatform(WebDriverPlatform::LINUX); + $capabilities->setVersion(333); + + $this->assertSame(1337, $capabilities->getCapability('custom')); + $this->assertSame(WebDriverBrowserType::CHROME, $capabilities->getBrowserName()); + $this->assertSame(WebDriverPlatform::LINUX, $capabilities->getPlatform()); + $this->assertSame(333, $capabilities->getVersion()); + } + + 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(): void + { + $capabilities = new DesiredCapabilities(); + $capabilities->setBrowserName(WebDriverBrowserType::HTMLUNIT); + $capabilities->setJavascriptEnabled(false); + + $this->assertFalse($capabilities->isJavascriptEnabled()); + } + + /** + * @dataProvider provideBrowserCapabilities + */ + public function testShouldProvideShortcutSetupForCapabilitiesOfEachBrowser( + string $setupMethod, + string $expectedBrowser, + string $expectedPlatform + ): void { + /** @var DesiredCapabilities $capabilities */ + $capabilities = call_user_func([DesiredCapabilities::class, $setupMethod]); + + $this->assertSame($expectedBrowser, $capabilities->getBrowserName()); + $this->assertSame($expectedPlatform, $capabilities->getPlatform()); + } + + /** + * @return array[] + */ + public function provideBrowserCapabilities(): array + { + return [ + ['android', WebDriverBrowserType::ANDROID, WebDriverPlatform::ANDROID], + ['chrome', WebDriverBrowserType::CHROME, WebDriverPlatform::ANY], + ['firefox', WebDriverBrowserType::FIREFOX, WebDriverPlatform::ANY], + ['htmlUnit', WebDriverBrowserType::HTMLUNIT, WebDriverPlatform::ANY], + ['htmlUnitWithJS', WebDriverBrowserType::HTMLUNIT, WebDriverPlatform::ANY], + ['MicrosoftEdge', WebDriverBrowserType::MICROSOFT_EDGE, WebDriverPlatform::WINDOWS], + ['internetExplorer', WebDriverBrowserType::IE, WebDriverPlatform::WINDOWS], + ['iphone', WebDriverBrowserType::IPHONE, WebDriverPlatform::MAC], + ['ipad', WebDriverBrowserType::IPAD, WebDriverPlatform::MAC], + ['opera', WebDriverBrowserType::OPERA, WebDriverPlatform::ANY], + ['safari', WebDriverBrowserType::SAFARI, WebDriverPlatform::ANY], + ['phantomjs', WebDriverBrowserType::PHANTOMJS, WebDriverPlatform::ANY], + ]; + } + + 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(); + + $this->assertSame('firefox', $capabilitiesArray['browserName']); + $this->assertSame( + [ + 'binary' => '/foo/bar/firefox', + 'args' => ['-headless'], + 'prefs' => FirefoxOptionsTest::EXPECTED_DEFAULT_PREFS, + ], + $capabilitiesArray['moz:firefoxOptions'] + ); + } + + 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 new file mode 100644 index 000000000..b29d788b1 --- /dev/null +++ b/tests/unit/Remote/HttpCommandExecutorTest.php @@ -0,0 +1,125 @@ +executor = new HttpCommandExecutor('http://localhost:4444/'); + } + + /** + * @dataProvider provideCommand + */ + public function testShouldSendRequestToAssembledUrl( + WebDriverCommand $command, + bool $shouldResetExpectHeader, + string $expectedUrl, + ?string $expectedPostData + ): void { + $expectedCurlSetOptCalls = [ + [$this->anything(), CURLOPT_URL, $expectedUrl], + [$this->anything()], + ]; + + 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'], + ]; + } + + $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()) + ->willReturn('{}'); + + $this->executor->execute($command); + } + + /** + * @return array[] + */ + public function provideCommand(): array + { + return [ + 'POST command having :id placeholder in url' => [ + 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' => [ + 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' => [ + 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' => [ + 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' => [ + 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 new file mode 100644 index 000000000..182b72d94 --- /dev/null +++ b/tests/unit/Remote/WebDriverCommandTest.php @@ -0,0 +1,26 @@ + 'bar']); + + $this->assertSame('session-id-123', $command->getSessionID()); + $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" + } +}