diff --git a/.coveralls.yml b/.coveralls.yml index 8115fc995..5ae55a93b 100644 --- a/.coveralls.yml +++ b/.coveralls.yml @@ -1,2 +1,2 @@ -coverage_clover: ./logs/coverage-clover.xml +coverage_clover: ./logs/clover.xml json_path: ./logs/coveralls-upload.json diff --git a/.gitattributes b/.gitattributes index c3b6383af..2fdc4ff88 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,8 +1,15 @@ * text=auto +/scripts export-ignore /tests export-ignore +/tools export-ignore /.gitattributes export-ignore /.gitignore export-ignore -/.travis.yml export-ignore /example.php export-ignore /phpunit.xml.dist export-ignore +/.github export-ignore +/.php-cs-fixer.dist.php export-ignore +/phpstan.neon export-ignore +/.coveralls.yml export-ignore +/logs export-ignore +/mlc_config.json export-ignore diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 000000000..a79b02b1f --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,94 @@ +# Contributing to php-webdriver + +We love to have your help to make php-webdriver better! + +Feel free to open an [issue](https://github.com/php-webdriver/php-webdriver/issues) if you run into any problem, or +send a pull request (see bellow) with your contribution. + +## Before you contribute + +Do not hesitate to ask for a guidance before you implement notable change, or a new feature - use the associated [issue](https://github.com/php-webdriver/php-webdriver/issues) or use [Discussions](https://github.com/php-webdriver/php-webdriver/discussions). +Because any new code means increased effort in library maintenance (which is being done by volunteers in their free time), +please understand not every pull request is automatically accepted. This is why we recommend using the mentioned channels to discuss bigger changes in the source code first. + +When you are going to contribute, also please keep in mind that this webdriver client aims to be similar to clients in languages Java/Ruby/Python/C#. +Here is the [official documentation](https://www.selenium.dev/documentation/en/) and overview of [the official Java API](http://seleniumhq.github.io/selenium/docs/api/java/) + +## Workflow when contributing a patch + +1. Fork the project on GitHub +2. Implement your code changes into separate branch +3. Make sure all PHPUnit tests passes and code-style matches PSR-2 (see below). We also have CI builds which will automatically run tests on your pull request. Make sure to fix any reported issues reported by these automated tests. +4. When implementing a notable change, fix or a new feature, add record to the Unreleased section of [CHANGELOG.md](../CHANGELOG.md) +5. Submit your [pull request](https://github.com/php-webdriver/php-webdriver/pulls) against `main` branch + +### Run automated code checks + +To make sure your code comply with [PSR-2](http://www.php-fig.org/psr/psr-2/) coding style, tests passes and to execute other automated checks, run locally: + +```sh +composer all +``` + +To run functional tests locally there is some additional setup needed - see below. Without this setup, functional tests will be skipped. + + +For easier development there are also few other prepared commands: +- `composer fix` - to auto-fix the codestyle and composer.json +- `composer analyze` - to run only code analysis (without tests) +- `composer test` - to run all tests + +### Unit tests + +There are two test-suites: one with **unit tests** only (`unit`), and second with **functional tests** (`functional`), +which requires running Selenium server and local PHP server. + +To execute **all tests** in both suites run: + +```sh +composer test +``` + +If you want to execute **just the unit tests**, run: + +```sh +composer test -- --testsuite unit +``` + +**Functional tests** are run against a real browser. It means they take a bit longer and also require an additional setup: +you must first [download](https://www.selenium.dev/downloads/) and start the Selenium standalone server, +then start the local PHP server which will serve the test pages and then run the `functional` test suite: + +```sh +export BROWSER_NAME="htmlunit" # see below for other browsers +java -jar selenium-server-X.XX.0.jar standalone --log selenium.log & +php -S localhost:8000 -t tests/functional/web/ & +# Use following to run both unit and functional tests +composer all +# Or this to run only functional tests: +composer test -- --testsuite functional +``` + +If you want to run tests in different browser then "htmlunit" (Chrome or Firefox), you need to set up the browser driver (Chromedriver/Geckodriver), as it is [explained in wiki](https://github.com/php-webdriver/php-webdriver/wiki/Chrome) +and then the `BROWSER_NAME` environment variable: + +```sh +... +export BROWSER_NAME="chrome" +composer all +``` + +To test with Firefox/Geckodriver, you must also set `GECKODRIVER` environment variable: + +```sh +export GECKODRIVER=1 +export BROWSER_NAME="firefox" +composer all +``` + +To see the tests as they are happening (in the browser window), you can disable headless mode. This is useful eg. when debugging the tests or writing a new one: + +```sh +export DISABLE_HEADLESS="1" +composer all +``` diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 000000000..bac315d1d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,124 @@ +name: 🐛 Bug report +description: Create a bug report to help us improve php-webdriver +labels: [ "bug" ] +body: + - type: markdown + attributes: + value: | + If you have a question, [ask in Discussions](https://github.com/php-webdriver/php-webdriver/discussions) instead of filling a bug. + + If you are reporting a bug, please **fill as much as possible information**, otherwise the community and maintainers cannot provide a prompt feedback and help solving the issue. + - type: textarea + id: bug-description + attributes: + label: Bug description + description: | + A clear description of what the bug is. + validations: + required: true + + - type: textarea + id: steps-to-reproduce + attributes: + label: How could the issue be reproduced + description: | + Provide steps to reproduce the behavior. Please include everything relevant - the PHP code you use to initialize driver instance, the PHP code causing the error, HTML snippet or URL of the page where you encounter the issue etc. + This will be automatically formatted into code, so no need for backticks ```. + placeholder: | + // For example you can provide how you create WebDriver instance: + $capabilities = DesiredCapabilities::chrome(); + $driver = RemoteWebDriver::create('http://localhost:4444/', $capabilities); + // And the code you use to execute the php-webdriver commands, for example: + $driver->get('http://site.localhost/foo.html'); + $button = $driver->findElement(WebDriverBy::cssSelector('#foo')); + $button->click(); + +
+ +
+ + render: shell + validations: + required: true + + - type: textarea + id: expected-behavior + attributes: + label: Expected behavior + description: | + A clear and concise description of what you expected to happen. + validations: + required: false + + - type: input + id: php-webdriver-version + attributes: + label: Php-webdriver version + description: You can run `composer show php-webdriver/webdriver` to find the version number + placeholder: | + For example: 1.13.0 + validations: + required: true + + - type: input + id: php-version + attributes: + label: PHP version + description: You can run `php -v` to find the version + placeholder: | + For example: 8.1.11 + validations: + required: true + + - type: input + id: how-start + attributes: + label: How do you start the browser driver or Selenium server + description: | + For example: Selenium server jar, Selenium in Docker, chromedriver command, Laravel Dusk, SauceLabs etc. + If relevant, provide the complete command you use to start the browser driver or Selenium server + validations: + required: true + + - type: input + id: selenium-version + attributes: + label: Selenium server / Selenium Docker image version + description: Relevant only if you use Selenium server / Selenium in Docker + validations: + required: false + + - type: input + id: browser-driver + attributes: + label: Browser driver (chromedriver/geckodriver...) version + description: You can run `chromedriver --version` or `geckodriver --version` to find the version + placeholder: | + For example: geckodriver 0.31.0 + validations: + required: false + + - type: input + id: browser + attributes: + label: Browser name and version + placeholder: | + For example: Firefox 105.0.2 + validations: + required: false + + - type: input + id: operating-system + attributes: + label: Operating system + validations: + required: false + + - type: textarea + id: additional-context + attributes: + label: Additional context + description: | + Add any other context or you notes about the problem here. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..e9a259980 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: ❓ Questions and Help + url: https://github.com/php-webdriver/php-webdriver/discussions + about: Please ask and answer questions here + - name: 💡 Ideas and feature requests + url: https://github.com/php-webdriver/php-webdriver/discussions + about: Suggest an idea for php-webdriver diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml new file mode 100644 index 000000000..50112e87a --- /dev/null +++ b/.github/dependabot.yaml @@ -0,0 +1,7 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: daily + time: "04:00" diff --git a/.github/workflows/coveralls-workaround.yaml b/.github/workflows/coveralls-workaround.yaml new file mode 100644 index 000000000..d6044861d --- /dev/null +++ b/.github/workflows/coveralls-workaround.yaml @@ -0,0 +1,48 @@ +name: Coveralls coverage +# Must be run in separate workflow to have access to repository secrets even for PR from forks. +# See https://securitylab.github.com/research/github-actions-preventing-pwn-requests/ + +permissions: + contents: read + +on: + workflow_run: + workflows: [ "Tests" ] + types: + - completed + +jobs: + coveralls: + name: Coveralls coverage workaround + runs-on: ubuntu-latest + if: > + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.conclusion == 'success' + steps: + # see https://securitylab.github.com/research/github-actions-preventing-pwn-requests/ + - name: 'Download artifact' + uses: actions/github-script@v8 + with: + script: | + var artifacts = await github.rest.actions.listWorkflowRunArtifacts({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: ${{github.event.workflow_run.id }}, + }); + var matchArtifact = artifacts.data.artifacts.filter((artifact) => { + return artifact.name == "data" + })[0]; + var download = await github.rest.actions.downloadArtifact({ + owner: context.repo.owner, + repo: context.repo.repo, + artifact_id: matchArtifact.id, + archive_format: 'zip', + }); + var fs = require('fs'); + fs.writeFileSync('${{github.workspace}}/data.zip', Buffer.from(download.data)); + - run: unzip data.zip + - name: Coveralls coverage workaround + # see https://github.com/lemurheavy/coveralls-public/issues/1653#issuecomment-1251587119 + run: | + BUILD_NUM=$(cat run_id) + curl --location --request GET "https://coveralls.io/rerun_build?repo_token=${{%20secrets.COVERALLS_REPO_TOKEN%20}}&build_num=$BUILD_NUM" diff --git a/.github/workflows/docs-lint.yml b/.github/workflows/docs-lint.yml new file mode 100644 index 000000000..42baa0000 --- /dev/null +++ b/.github/workflows/docs-lint.yml @@ -0,0 +1,26 @@ +name: Lint PHP documentation + +permissions: + contents: read + +on: + push: + pull_request: + branches: + - 'main' + +jobs: + lint-docs: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Lint PHP documentation + uses: sudo-bot/action-doctum@v5 + with: + config-file: scripts/doctum.php + method: 'parse' + cli-args: '--output-format=github --no-ansi --no-progress -v --ignore-parse-errors' diff --git a/.github/workflows/docs-publish.yml b/.github/workflows/docs-publish.yml new file mode 100644 index 000000000..d0da12f8b --- /dev/null +++ b/.github/workflows/docs-publish.yml @@ -0,0 +1,38 @@ +name: Publish API documentation + +permissions: + contents: read + +on: + repository_dispatch: + types: [ run-build-api-docs ] + workflow_dispatch: + schedule: + - cron: "00 12 * * *" + +jobs: + publish-pages: + environment: + name: API documentation + url: https://php-webdriver.github.io/php-webdriver/ + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ssh-key: ${{ secrets.SSH_KEY_DEPLOY }} + + - name: Build PHP documentation + uses: sudo-bot/action-doctum@v5 + with: + config-file: scripts/doctum.php + method: 'update' + cli-args: '--output-format=github --no-ansi --no-progress -v --ignore-parse-errors' + + - name: Set commit author + run: | + git config user.name "Automated" + git config user.email "actions@users.noreply.github.com" + - name: Push the changes + run: ./scripts/update-built-docs.sh diff --git a/.github/workflows/no-response.yaml b/.github/workflows/no-response.yaml new file mode 100644 index 000000000..7147e3792 --- /dev/null +++ b/.github/workflows/no-response.yaml @@ -0,0 +1,29 @@ +name: No Response + +permissions: + issues: write + +# Both `issue_comment` and `scheduled` event types are required for this Action to work properly. +on: + issue_comment: + types: [created] + schedule: + - cron: '* */8 * * *' # every hour at :33 + +jobs: + noResponse: + runs-on: ubuntu-latest + steps: + - uses: lee-dohm/no-response@v0.5.0 + with: + token: ${{ github.token }} + daysUntilClose: 14 + responseRequiredLabel: 'waiting for reaction' + closeComment: > + This issue has been automatically closed because there has been no response + to our request for more information from the original author. With only the + information that is currently in the issue, we don't have enough information + to take action. + + If the original issue author adds comment with more information, + this issue will be automatically reopened and we can investigate further. diff --git a/.github/workflows/sauce-labs.yaml b/.github/workflows/sauce-labs.yaml new file mode 100644 index 000000000..812fbfe25 --- /dev/null +++ b/.github/workflows/sauce-labs.yaml @@ -0,0 +1,74 @@ +name: Sauce Labs + +permissions: + contents: read + +on: + push: + schedule: + - cron: '0 3 * * *' + +jobs: + tests: + runs-on: ubuntu-latest + # Source: https://github.community/t/do-not-run-cron-workflows-in-forks/17636/2 + if: (github.event_name == 'schedule' && github.repository == 'php-webdriver/php-webdriver') || (github.event_name != 'schedule') + env: + SAUCELABS: 1 + SAUCE_USERNAME: ${{ secrets.SAUCE_USERNAME }} + SAUCE_ACCESS_KEY: ${{ secrets.SAUCE_ACCESS_KEY }} + strategy: + fail-fast: false + matrix: + include: + # Chrome 74 is the last version which doesn't use W3C WebDriver by default and rather use OSS protocol + - { name: "Chrome 74, OSS protocol", BROWSER_NAME: "chrome", VERSION: "74.0", PLATFORM: "Windows 11", w3c: false, tunnel-name: "gh-1-chrome-oss-legacy" } + - { name: "Chrome latest, W3C protocol", BROWSER_NAME: "chrome", VERSION: "latest", PLATFORM: "Windows 11", w3c: true, tunnel-name: "gh-2-chrome-w3c" } + - { name: "Edge latest, W3C protocol", BROWSER_NAME: "MicrosoftEdge", VERSION: "latest", PLATFORM: "Windows 11", w3c: true, tunnel-name: "gh-3-MicrosoftEdge" } + + name: ${{ matrix.name }} (${{ matrix.tunnel-name }}) + steps: + - uses: actions/checkout@v6 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: mbstring, intl, zip + coverage: none + + - name: Install PHP dependencies + run: composer update --no-interaction + + - name: Start local PHP server + run: | + php -S 127.0.0.1:8000 -t tests/functional/web/ &>>./logs/php-server.log & + + - name: Start Sauce Connect + uses: saucelabs/sauce-connect-action@v3 + with: + username: ${{ secrets.SAUCE_USERNAME }} + accessKey: ${{ secrets.SAUCE_ACCESS_KEY }} + tunnelName: ${{ matrix.tunnel-name }} + proxyLocalhost: allow + region: 'us-west-1' + + - name: Run tests + env: + BROWSER_NAME: ${{ matrix.BROWSER_NAME }} + VERSION: ${{ matrix.VERSION }} + PLATFORM: ${{ matrix.PLATFORM }} + DISABLE_W3C_PROTOCOL: "${{ matrix.w3c && '0' || '1' }}" + SAUCE_TUNNEL_NAME: ${{ matrix.tunnel-name }} + run: | + if [ -n "$SAUCELABS" ]; then EXCLUDE_GROUP+="exclude-saucelabs,"; fi + if [ "$BROWSER_NAME" = "MicrosoftEdge" ]; then EXCLUDE_GROUP+="exclude-edge,"; fi + if [ "$BROWSER_NAME" = "firefox" ]; then EXCLUDE_GROUP+="exclude-firefox,"; fi + if [ "$BROWSER_NAME" = "chrome" ]; then EXCLUDE_GROUP+="exclude-chrome,"; fi + if [ -n "$EXCLUDE_GROUP" ]; then EXTRA_PARAMS+=" --exclude-group $EXCLUDE_GROUP"; fi + ./vendor/bin/phpunit --testsuite functional $EXTRA_PARAMS + + - name: Print logs + if: ${{ always() }} + run: | + if [ -f ./logs/php-server.log ]; then cat ./logs/php-server.log; fi diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 000000000..d215e92ce --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,214 @@ +name: Tests + +permissions: + contents: read + +on: + push: + pull_request: + schedule: + - cron: '0 3 * * *' + +jobs: + analyze: + name: "Code style and static analysis" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: mbstring, intl, zip + + - name: Install PHP dependencies + run: composer update --no-interaction + + - name: Lint + run: composer lint + + - name: Run analysis + run: composer analyze + + markdown-link-check: + name: "Markdown link check" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: tcort/github-action-markdown-link-check@v1 + with: + use-verbose-mode: 'yes' + + unit-tests: + name: "Unit tests" + runs-on: ubuntu-latest + + strategy: + matrix: + php-version: ['7.3', '7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5'] + dependencies: [''] + include: + - { php-version: '7.3', dependencies: '--prefer-lowest' } + + steps: + - uses: actions/checkout@v6 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + extensions: mbstring, intl, zip + coverage: xdebug + ini-values: ${{ matrix.xdebug-ini-values }} + + - name: Install PHP dependencies + run: composer update --no-interaction ${{ matrix.dependencies }} + + - name: Run tests + run: vendor/bin/phpunit --testsuite unit --colors=always --coverage-clover ./logs/clover.xml + + - name: Submit coverage to Coveralls + # We use php-coveralls library for this, as the official Coveralls GitHub Action lacks support for clover reports: + # https://github.com/coverallsapp/github-action/issues/15 + env: + COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} + COVERALLS_PARALLEL: true + COVERALLS_FLAG_NAME: ${{ github.job }}-PHP-${{ matrix.php-version }} ${{ matrix.dependencies }} + run: | + composer global require php-coveralls/php-coveralls + ~/.composer/vendor/bin/php-coveralls -v + + functional-tests: + runs-on: ${{ matrix.os }} + env: + SELENIUM_SERVER_DOWNLOAD_URL: https://github.com/SeleniumHQ/selenium/releases/download/selenium-4.38.0/selenium-server-4.38.0.jar + + strategy: + fail-fast: false + matrix: + os: ['ubuntu-latest'] + browser: ['chrome', 'firefox'] + selenium-server: [true, false] # Whether to run via Selenium server or directly via browser driver + w3c: [true] # Although all builds negotiate protocol by default, it implies W3C protocol for both Chromedriver and Geckodriver + include: + - { browser: 'safari', os: 'macos-latest', selenium-server: false, w3c: true } + # Force OSS (JsonWire) protocol on ChromeDriver - to make sure we keep compatibility: + - { browser: 'chrome', os: 'ubuntu-latest', selenium-server: false, w3c: false } + + name: "Functional tests (${{ matrix.browser }}, Selenium server: ${{ matrix.selenium-server }}, W3C: ${{ matrix.w3c }})" + + steps: + - uses: actions/checkout@v6 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: mbstring, intl, zip + coverage: xdebug + + - name: Install PHP dependencies + run: composer update --no-interaction + + - name: Start Selenium standalone server + # If you want to run your Selenium WebDriver tests on GitHub actions, we recommend using service containers + # with eg. selenium/standalone-chrome image. See https://docs.github.com/en/actions/guides/about-service-containers + # But for the purpose of testing this library itself, we need more control, so we set everything up manually. + if: ${{ matrix.selenium-server }} + run: | + mkdir -p build logs + wget -q -t 3 -O build/selenium-server.jar $SELENIUM_SERVER_DOWNLOAD_URL + java -jar build/selenium-server.jar standalone --version + xvfb-run --server-args="-screen 0, 1280x720x24" --auto-servernum java -jar build/selenium-server.jar standalone --log logs/selenium-server.log & + + - name: Start ChromeDriver + if: ${{ !matrix.selenium-server && matrix.browser == 'chrome' }} + run: | + google-chrome --version + xvfb-run --server-args="-screen 0, 1280x720x24" --auto-servernum \ + chromedriver --port=4444 &> ./logs/chromedriver.log & + + - name: Start GeckoDriver + if: ${{ !matrix.selenium-server && matrix.browser == 'firefox' }} + run: | + firefox --version + geckodriver --version + xvfb-run --server-args="-screen 0, 1280x720x24" --auto-servernum \ + geckodriver &> ./logs/geckodriver.log & + + - name: Start SafariDriver + if: ${{ !matrix.selenium-server && matrix.browser == 'safari' }} + run: | + defaults read /Applications/Safari.app/Contents/Info CFBundleShortVersionString + /usr/bin/safaridriver -p 4444 --diagnose & + + - name: Start local PHP server + run: | + php -S 127.0.0.1:8000 -t tests/functional/web/ &> ./logs/php-server.log & + + - name: Wait for browser & PHP to start + timeout-minutes: 1 + run: | + while ! nc -z localhost 4444 ./data/run_id + - uses: actions/upload-artifact@v6 + with: + name: data + path: data/ diff --git a/.gitignore b/.gitignore index 42ef8b562..a9384110d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,13 @@ composer.phar composer.lock vendor +tools/php-cs-fixer/vendor .php_cs.cache +.php-cs-fixer.cache +.phpunit.result.cache phpunit.xml logs/ +build/ # generic files to ignore *.lock @@ -11,3 +15,4 @@ logs/ *~ *.swp .idea +.vscode diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 000000000..f754fb8b7 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,139 @@ +notPath('Firefox/FirefoxProfile.php') // need to use str_* instead of mb_str_* methods + ->in([__DIR__ . '/lib', __DIR__ . '/tests']); + +return (new PhpCsFixer\Config()) + ->setRules([ + '@PSR2' => true, + 'array_syntax' => ['syntax' => 'short'], + 'binary_operator_spaces' => true, + 'blank_line_before_statement' => ['statements' => ['return', 'try']], + 'braces' => ['allow_single_line_anonymous_class_with_empty_body' => true, 'allow_single_line_closure' => true], + 'cast_spaces' => true, + 'class_attributes_separation' => ['elements' => ['method' => 'one', 'trait_import' => 'none']], + 'clean_namespace' => true, + 'combine_nested_dirname' => true, + 'compact_nullable_typehint' => true, + 'concat_space' => ['spacing' => 'one'], + 'declare_equal_normalize' => true, + //'declare_strict_types' => true, // TODO: used only in tests, use in lib in next major version + 'fopen_flag_order' => true, + 'fopen_flags' => true, + 'full_opening_tag' => true, + 'function_typehint_space' => true, + 'heredoc_indentation' => ['indentation' => 'same_as_start'], + 'implode_call' => true, + 'is_null' => true, + 'lambda_not_used_import' => true, + 'list_syntax' => true, + 'lowercase_cast' => true, + 'lowercase_static_reference' => true, + 'magic_constant_casing' => true, + 'magic_method_casing' => true, + 'mb_str_functions' => true, + 'method_argument_space' => ['after_heredoc' => true], + 'native_function_casing' => true, + 'native_function_type_declaration_casing' => true, + 'new_with_braces' => true, + 'no_alias_functions' => true, + 'no_blank_lines_after_class_opening' => true, + 'no_blank_lines_after_phpdoc' => true, + 'no_empty_comment' => true, + 'no_empty_phpdoc' => true, + 'no_empty_statement' => true, + 'normalize_index_brace' => true, + 'no_extra_blank_lines' => [ + 'tokens' => [ + 'break', + 'case', + 'continue', + 'curly_brace_block', + 'default', + 'extra', + 'parenthesis_brace_block', + 'return', + 'square_brace_block', + 'switch', + 'throw', + 'use', + ], + ], + 'no_leading_import_slash' => true, + 'no_leading_namespace_whitespace' => true, + 'no_singleline_whitespace_before_semicolons' => true, + 'no_superfluous_phpdoc_tags' => [ + 'allow_mixed' => true, + 'remove_inheritdoc' => true, + 'allow_unused_params' => true, // Used in RemoteWebDriver::createBySessionID to maintain BC + ], + 'no_trailing_comma_in_singleline' => true, + 'no_unreachable_default_argument_value' => true, + 'no_unused_imports' => true, + 'no_useless_else' => true, + 'no_useless_return' => true, + 'no_useless_sprintf' => true, + 'no_whitespace_before_comma_in_array' => ['after_heredoc' => true], + 'no_whitespace_in_blank_line' => true, + 'non_printable_character' => true, + 'nullable_type_declaration' => [ + 'syntax' => 'question_mark', + ], + 'nullable_type_declaration_for_default_null_value' => true, + 'object_operator_without_whitespace' => true, + 'ordered_class_elements' => true, + 'ordered_imports' => true, + 'php_unit_construct' => true, + 'php_unit_dedicate_assert' => true, + 'php_unit_dedicate_assert_internal_type' => true, + 'php_unit_expectation' => ['target' => '8.4'], + 'php_unit_method_casing' => ['case' => 'camel_case'], + 'php_unit_mock_short_will_return' => true, + 'php_unit_mock' => true, + 'php_unit_namespaced' => ['target' => '6.0'], + 'php_unit_no_expectation_annotation' => true, + 'php_unit_set_up_tear_down_visibility' => true, + 'php_unit_test_case_static_method_calls' => ['call_type' => 'this'], + 'phpdoc_add_missing_param_annotation' => true, + 'phpdoc_indent' => true, + 'phpdoc_no_access' => true, + // 'phpdoc_no_empty_return' => true, // disabled to allow forward compatibility with PHP 8.1 + 'phpdoc_no_package' => true, + 'phpdoc_order_by_value' => ['annotations' => ['covers', 'group', 'throws']], + 'phpdoc_order' => true, + 'phpdoc_return_self_reference' => true, + 'phpdoc_scalar' => true, + 'phpdoc_single_line_var_spacing' => true, + 'phpdoc_trim' => true, + //'phpdoc_to_param_type' => true, // TODO: used only in tests, use in lib in next major version + //'phpdoc_to_return_type' => true, // TODO: used only in tests, use in lib in next major version + 'phpdoc_types' => true, + 'phpdoc_var_annotation_correct_order' => true, + 'pow_to_exponentiation' => true, + 'psr_autoloading' => true, + 'random_api_migration' => true, + 'self_accessor' => true, + 'set_type_to_cast' => true, + 'short_scalar_cast' => true, + 'single_blank_line_before_namespace' => true, + 'single_quote' => true, + 'single_space_after_construct' => true, + 'single_trait_insert_per_statement' => true, + 'space_after_semicolon' => true, + 'standardize_not_equals' => true, + 'strict_param' => true, + 'switch_continue_to_break' => true, + 'ternary_operator_spaces' => true, + 'ternary_to_elvis_operator' => true, + 'ternary_to_null_coalescing' => true, + 'trailing_comma_in_multiline' => ['elements' => ['arrays'], 'after_heredoc' => true], + 'trim_array_spaces' => true, + 'unary_operator_spaces' => true, + 'visibility_required' => ['elements' => ['method', 'property', 'const']], + //'void_return' => true, // TODO: used only in tests, use in lib in next major version + 'whitespace_after_comma_in_array' => true, + 'yoda_style' => ['equal' => false, 'identical' => false, 'less_and_greater' => false], + ]) + ->setRiskyAllowed(true) + ->setFinder($finder); diff --git a/.php_cs.dist b/.php_cs.dist deleted file mode 100644 index c40510179..000000000 --- a/.php_cs.dist +++ /dev/null @@ -1,77 +0,0 @@ -in([__DIR__ . '/lib', __DIR__ . '/tests']); - -return PhpCsFixer\Config::create() - ->setRules([ - '@PSR2' => true, - 'array_syntax' => ['syntax' => 'short'], - 'binary_operator_spaces' => true, - 'blank_line_before_return' => true, - 'cast_spaces' => true, - 'concat_space' => ['spacing' => 'one'], - 'function_typehint_space' => true, - 'linebreak_after_opening_tag' => true, - 'lowercase_cast' => true, - 'mb_str_functions' => true, - 'method_separation' => true, - 'native_function_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, - 'no_extra_consecutive_blank_lines' => [ - 'use', - 'break', - 'continue', - 'extra', - 'return', - 'throw', - 'useTrait', - 'curly_brace_block', - 'parenthesis_brace_block', - 'square_brace_block', - ], - 'no_leading_import_slash' => true, - 'no_leading_namespace_whitespace' => true, - 'no_singleline_whitespace_before_semicolons' => true, - 'no_trailing_comma_in_singleline_array' => true, - 'no_unreachable_default_argument_value' => true, - 'no_unused_imports' => true, - 'no_useless_else' => true, - 'no_useless_return' => true, - 'no_whitespace_in_blank_line' => true, - 'object_operator_without_whitespace' => true, - 'ordered_class_elements' => true, - 'ordered_imports' => true, - 'php_unit_construct' => true, - 'php_unit_dedicate_assert' => true, - 'phpdoc_add_missing_param_annotation' => true, - 'phpdoc_indent' => true, - 'phpdoc_no_access' => true, - 'phpdoc_no_empty_return' => true, - 'phpdoc_no_package' => true, - 'phpdoc_order' => true, - 'phpdoc_scalar' => true, - 'phpdoc_single_line_var_spacing' => true, - 'phpdoc_trim' => true, - 'phpdoc_types' => true, - 'psr4' => true, - 'self_accessor' => true, - 'short_scalar_cast' => true, - 'single_blank_line_before_namespace' => true, - 'single_quote' => true, - 'space_after_semicolon' => true, - 'standardize_not_equals' => true, - 'ternary_operator_spaces' => true, - 'trailing_comma_in_multiline_array' => true, - 'trim_array_spaces' => true, - 'unary_operator_spaces' => true, - 'whitespace_after_comma_in_array' => true, - ]) - ->setRiskyAllowed(true) - ->setFinder($finder); diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 7c0cd3e77..000000000 --- a/.travis.yml +++ /dev/null @@ -1,68 +0,0 @@ -language: php - -php: - - 5.5 - - 5.6 - - 7.0 - - 7.1 - -matrix: - include: - # Add build to run tests against Firefox (other runs are agains HtmlUnit by default) - - php: 7.0 - env: BROWSER_NAME="firefox" - # Build with lowest possible dependencies - - php: 7.0 - env: dependencies="--prefer-lowest" - # Add PHP 7 build to check codestyle only in PHP 7 build - - php: 7.0 - env: CHECK_CODESTYLE=1 - before_script: ~ - script: - - ./vendor/bin/php-cs-fixer fix --diff --dry-run - - ./vendor/bin/phpcs --standard=PSR2 ./lib/ ./tests/ - after_script: ~ - after_success: ~ - -env: - global: - - DISPLAY=:99.0 - -cache: - directories: - - $HOME/.composer/cache - - jar - -before_install: - - travis_retry composer self-update - -install: - - travis_retry composer update --no-interaction $dependencies - -before_script: - - sh -e /etc/init.d/xvfb start - # TODO: upgrade to Selenium 3.0.2 (with latest HtmlUnit) once released, as HtmlUnit in 3.0.1 is broken - - if [ ! -f jar/selenium-server-standalone-2.53.1.jar ]; then wget -q -t 3 -P jar https://selenium-release.storage.googleapis.com/2.53/selenium-server-standalone-2.53.1.jar; fi - - if [ ! -f jar/htmlunit-driver-standalone-2.20.jar ]; then wget -q -t 3 -P jar https://github.com/SeleniumHQ/htmlunit-driver/releases/download/2.20/htmlunit-driver-standalone-2.20.jar; fi - # Temporarily run HtmlUnit from standalone jar file (it was not part of Selenium server standalone in version 2.53) - - java -cp "jar/selenium-server-standalone-2.53.1.jar:jar/htmlunit-driver-standalone-2.20.jar" org.openqa.grid.selenium.GridLauncher -log ./logs/selenium.log & - # TODO: use this after upgrade to Selenium 3.0.2 - #- /usr/lib/jvm/java-8-oracle/bin/java -Dwebdriver.firefox.marionette=false -jar jar/selenium-server-standalone-3.0.2.jar -log selenium.log & - - until $(echo | nc localhost 4444); do sleep 1; echo waiting for selenium-server...; done - - php -S localhost:8000 -t tests/functional/web/ &>>./logs/php-server.log & - -script: - - ./vendor/bin/phpunit --coverage-clover ./logs/coverage-clover.xml - -after_script: - - cat ./logs/selenium.log - - cat ./logs/php-server.log - -after_success: - - travis_retry php vendor/bin/coveralls -v - -addons: - firefox: "latest-esr" - apt: - packages: - - oracle-java8-installer diff --git a/CHANGELOG.md b/CHANGELOG.md index 04ce2d4dc..a468c26f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,216 @@ 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 `` tag, providing helper methods to select and deselect options. */ class WebDriverSelect implements WebDriverSelectInterface { @@ -39,7 +26,14 @@ public function __construct(WebDriverElement $element) } $this->element = $element; $value = $element->getAttribute('multiple'); - $this->isMulti = ($value === 'true'); + + /** + * There is a bug in safari webdriver that returns 'multiple' instead of 'true' which does not match the spec. + * Apple Feedback #FB12760673 + * + * @see https://www.w3.org/TR/webdriver2/#get-element-attribute + */ + $this->isMulti = $value === 'true' || $value === 'multiple'; } public function isMultiple() @@ -236,7 +230,6 @@ public function deselectByVisiblePartialText($text) /** * Mark option selected - * @param WebDriverElement $option */ protected function selectOption(WebDriverElement $option) { @@ -247,7 +240,6 @@ protected function selectOption(WebDriverElement $option) /** * Mark option not selected - * @param WebDriverElement $option */ protected function deselectOption(WebDriverElement $option) { diff --git a/lib/WebDriverSelectInterface.php b/lib/WebDriverSelectInterface.php index cec06a33f..030a783e9 100644 --- a/lib/WebDriverSelectInterface.php +++ b/lib/WebDriverSelectInterface.php @@ -1,4 +1,5 @@ Bar; + * `` * * @param string $value The value to match against. * @@ -57,7 +58,7 @@ public function selectByValue($value); * Select all options that display text matching the argument. That is, when given "Bar" this would * select an option like: * - * ; + * `` * * @param string $text The visible text to match against. * @@ -69,7 +70,7 @@ public function selectByVisibleText($text); * Select all options that display text partially matching the argument. That is, when given "Bar" this would * select an option like: * - * ; + * `` * * @param string $text The visible text to match against. * @@ -96,7 +97,7 @@ public function deselectByIndex($index); * Deselect all options that have value attribute matching the argument. That is, when given "foo" this would * deselect an option like: * - * ; + * `` * * @param string $value The value to match against. * @throws UnsupportedOperationException If the SELECT does not support multiple selections @@ -107,7 +108,7 @@ public function deselectByValue($value); * Deselect all options that display text matching the argument. That is, when given "Bar" this would * deselect an option like: * - * ; + * `` * * @param string $text The visible text to match against. * @throws UnsupportedOperationException If the SELECT does not support multiple selections @@ -118,7 +119,7 @@ public function deselectByVisibleText($text); * Deselect all options that display text matching the argument. That is, when given "Bar" this would * deselect an option like: * - * ; + * `` * * @param string $text The visible text to match against. * @throws UnsupportedOperationException If the SELECT does not support multiple selections diff --git a/lib/WebDriverTargetLocator.php b/lib/WebDriverTargetLocator.php index e6e5dabc5..8787f66c5 100644 --- a/lib/WebDriverTargetLocator.php +++ b/lib/WebDriverTargetLocator.php @@ -1,17 +1,4 @@ executor = $executor; + $this->isW3cCompliant = $isW3cCompliant; } /** - * Specify the amount of time the driver should wait when searching for an - * element if it is not immediately present. + * Specify the amount of time the driver should wait when searching for an element if it is not immediately present. * - * @param int $seconds Wait time in second. + * @param null|int|float $seconds Wait time in seconds. * @return WebDriverTimeouts The current instance. */ public function implicitlyWait($seconds) { + if ($this->isW3cCompliant) { + $this->executor->execute( + DriverCommand::IMPLICITLY_WAIT, + ['implicit' => $seconds === null ? null : floor($seconds * 1000)] + ); + + return $this; + } + + if ($seconds === null) { + throw new \InvalidArgumentException('JsonWire Protocol implicit-wait-timeout value cannot be null'); + } $this->executor->execute( DriverCommand::IMPLICITLY_WAIT, - ['ms' => $seconds * 1000] + ['ms' => floor($seconds * 1000)] ); return $this; } /** - * Set the amount of time to wait for an asynchronous script to finish - * execution before throwing an error. + * Set the amount of time to wait for an asynchronous script to finish execution before throwing an error. * - * @param int $seconds Wait time in second. + * @param null|int|float $seconds Wait time in seconds. * @return WebDriverTimeouts The current instance. */ public function setScriptTimeout($seconds) { + if ($this->isW3cCompliant) { + $this->executor->execute( + DriverCommand::SET_SCRIPT_TIMEOUT, + ['script' => $seconds === null ? null : floor($seconds * 1000)] + ); + + return $this; + } + + if ($seconds === null) { + throw new \InvalidArgumentException('JsonWire Protocol script-timeout value cannot be null'); + } $this->executor->execute( DriverCommand::SET_SCRIPT_TIMEOUT, - ['ms' => $seconds * 1000] + ['ms' => floor($seconds * 1000)] ); return $this; } /** - * Set the amount of time to wait for a page load to complete before throwing - * an error. + * Set the amount of time to wait for a page load to complete before throwing an error. * - * @param int $seconds Wait time in second. + * @param null|int|float $seconds Wait time in seconds. * @return WebDriverTimeouts The current instance. */ public function pageLoadTimeout($seconds) { + if ($this->isW3cCompliant) { + $this->executor->execute( + DriverCommand::SET_SCRIPT_TIMEOUT, + ['pageLoad' => $seconds === null ? null : floor($seconds * 1000)] + ); + + return $this; + } + + if ($seconds === null) { + throw new \InvalidArgumentException('JsonWire Protocol page-load-timeout value cannot be null'); + } $this->executor->execute(DriverCommand::SET_TIMEOUT, [ 'type' => 'page load', - 'ms' => $seconds * 1000, + 'ms' => floor($seconds * 1000), ]); return $this; diff --git a/lib/WebDriverUpAction.php b/lib/WebDriverUpAction.php index e8a480c9e..3b045df4b 100644 --- a/lib/WebDriverUpAction.php +++ b/lib/WebDriverUpAction.php @@ -1,17 +1,4 @@ driver = $driver; - $this->timeout = isset($timeout_in_second) ? $timeout_in_second : 30; + $this->timeout = $timeout_in_second ?? 30; $this->interval = $interval_in_millisecond ?: 250; } @@ -51,9 +38,9 @@ public function __construct(WebDriver $driver, $timeout_in_second = null, $inter * @param callable|WebDriverExpectedCondition $func_or_ec * @param string $message * - * @throws NoSuchElementException - * @throws TimeOutException * @throws \Exception + * @throws NoSuchElementException + * @throws TimeoutException * @return mixed The return value of $func_or_ec */ public function until($func_or_ec, $message = '') @@ -81,6 +68,6 @@ public function until($func_or_ec, $message = '') throw $last_exception; } - throw new TimeOutException($message); + throw new TimeoutException($message); } } diff --git a/lib/WebDriverWindow.php b/lib/WebDriverWindow.php index 6b9090a99..2a69fe240 100644 --- a/lib/WebDriverWindow.php +++ b/lib/WebDriverWindow.php @@ -1,21 +1,10 @@ executor = $executor; + $this->isW3cCompliant = $isW3cCompliant; } /** @@ -72,6 +66,22 @@ public function getSize() ); } + /** + * Minimizes the current window if it is not already minimized. + * + * @return WebDriverWindow The instance. + */ + public function minimize() + { + if (!$this->isW3cCompliant) { + throw new UnsupportedOperationException('Minimize window is only supported in W3C mode'); + } + + $this->executor->execute(DriverCommand::MINIMIZE_WINDOW, []); + + return $this; + } + /** * Maximizes the current window if it is not already maximized * @@ -79,10 +89,30 @@ public function getSize() */ public function maximize() { - $this->executor->execute( - DriverCommand::MAXIMIZE_WINDOW, - [':windowHandle' => 'current'] - ); + if ($this->isW3cCompliant) { + $this->executor->execute(DriverCommand::MAXIMIZE_WINDOW, []); + } else { + $this->executor->execute( + DriverCommand::MAXIMIZE_WINDOW, + [':windowHandle' => 'current'] + ); + } + + return $this; + } + + /** + * Makes the current window full screen. + * + * @return WebDriverWindow The instance. + */ + public function fullscreen() + { + if (!$this->isW3cCompliant) { + throw new UnsupportedOperationException('The Fullscreen window command is only supported in W3C mode'); + } + + $this->executor->execute(DriverCommand::FULLSCREEN_WINDOW, []); return $this; } @@ -91,7 +121,6 @@ public function maximize() * Set the size of the current window. This will change the outer window * dimension, not just the view port. * - * @param WebDriverDimension $size * @return WebDriverWindow The instance. */ public function setSize(WebDriverDimension $size) @@ -110,7 +139,6 @@ public function setSize(WebDriverDimension $size) * Set the position of the current window. This is relative to the upper left * corner of the screen. * - * @param WebDriverPoint $position * @return WebDriverWindow The instance. */ public function setPosition(WebDriverPoint $position) @@ -146,10 +174,8 @@ public function getScreenOrientation() public function setScreenOrientation($orientation) { $orientation = mb_strtoupper($orientation); - if (!in_array($orientation, ['PORTRAIT', 'LANDSCAPE'])) { - throw new IndexOutOfBoundsException( - 'Orientation must be either PORTRAIT, or LANDSCAPE' - ); + if (!in_array($orientation, ['PORTRAIT', 'LANDSCAPE'], true)) { + throw LogicException::forError('Orientation must be either PORTRAIT, or LANDSCAPE'); } $this->executor->execute( diff --git a/lib/scripts/isElementDisplayed.js b/lib/scripts/isElementDisplayed.js new file mode 100644 index 000000000..f24bfa55e --- /dev/null +++ b/lib/scripts/isElementDisplayed.js @@ -0,0 +1,219 @@ +/* + * Imported from WebdriverIO project. + * https://github.com/webdriverio/webdriverio/blob/main/packages/webdriverio/src/scripts/isElementDisplayed.ts + * + * Copyright (C) 2017 Apple Inc. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS'' + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, + * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS + * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF + * THE POSSIBILITY OF SUCH DAMAGE. + */ + +/** + * check if element is visible + * @param {HTMLElement} elem element to check + * @return {Boolean} true if element is within viewport + */ +function isElementDisplayed(element) { + function nodeIsElement(node) { + if (!node) { + return false; + } + + switch (node.nodeType) { + case Node.ELEMENT_NODE: + case Node.DOCUMENT_NODE: + case Node.DOCUMENT_FRAGMENT_NODE: + return true; + default: + return false; + } + } + function parentElementForElement(element) { + if (!element) { + return null; + } + return enclosingNodeOrSelfMatchingPredicate(element.parentNode, nodeIsElement); + } + function enclosingNodeOrSelfMatchingPredicate(targetNode, predicate) { + for (let node = targetNode; node && node !== targetNode.ownerDocument; node = node.parentNode) + if (predicate(node)) { + return node; + } + return null; + } + function enclosingElementOrSelfMatchingPredicate(targetElement, predicate) { + for (let element = targetElement; element && element !== targetElement.ownerDocument; element = parentElementForElement(element)) + if (predicate(element)) { + return element; + } + return null; + } + function cascadedStylePropertyForElement(element, property) { + if (!element || !property) { + return null; + } + // if document-fragment, skip it and use element.host instead. This happens + // when the element is inside a shadow root. + // window.getComputedStyle errors on document-fragment. + if (element instanceof ShadowRoot) { + element = element.host; + } + let computedStyle = window.getComputedStyle(element); + let computedStyleProperty = computedStyle.getPropertyValue(property); + if (computedStyleProperty && computedStyleProperty !== 'inherit') { + return computedStyleProperty; + } + // Ideally getPropertyValue would return the 'used' or 'actual' value, but + // it doesn't for legacy reasons. So we need to do our own poor man's cascade. + // Fall back to the first non-'inherit' value found in an ancestor. + // In any case, getPropertyValue will not return 'initial'. + // FIXME: will this incorrectly inherit non-inheritable CSS properties? + // I think all important non-inheritable properties (width, height, etc.) + // for our purposes here are specially resolved, so this may not be an issue. + // Specification is here: https://drafts.csswg.org/cssom/#resolved-values + let parentElement = parentElementForElement(element); + return cascadedStylePropertyForElement(parentElement, property); + } + function elementSubtreeHasNonZeroDimensions(element) { + let boundingBox = element.getBoundingClientRect(); + if (boundingBox.width > 0 && boundingBox.height > 0) { + return true; + } + // Paths can have a zero width or height. Treat them as shown if the stroke width is positive. + if (element.tagName.toUpperCase() === 'PATH' && boundingBox.width + boundingBox.height > 0) { + let strokeWidth = cascadedStylePropertyForElement(element, 'stroke-width'); + return !!strokeWidth && (parseInt(strokeWidth, 10) > 0); + } + let cascadedOverflow = cascadedStylePropertyForElement(element, 'overflow'); + if (cascadedOverflow === 'hidden') { + return false; + } + // If the container's overflow is not hidden and it has zero size, consider the + // container to have non-zero dimensions if a child node has non-zero dimensions. + return Array.from(element.childNodes).some((childNode) => { + if (childNode.nodeType === Node.TEXT_NODE) { + return true; + } + if (nodeIsElement(childNode)) { + return elementSubtreeHasNonZeroDimensions(childNode); + } + return false; + }); + } + function elementOverflowsContainer(element) { + let cascadedOverflow = cascadedStylePropertyForElement(element, 'overflow'); + if (cascadedOverflow !== 'hidden') { + return false; + } + // FIXME: this needs to take into account the scroll position of the element, + // the display modes of it and its ancestors, and the container it overflows. + // See Selenium's bot.dom.getOverflowState atom for an exhaustive list of edge cases. + return true; + } + function isElementSubtreeHiddenByOverflow(element) { + if (!element) { + return false; + } + if (!elementOverflowsContainer(element)) { + return false; + } + if (!element.childNodes.length) { + return false; + } + // This element's subtree is hidden by overflow if all child subtrees are as well. + return Array.from(element.childNodes).every((childNode) => { + // Returns true if the child node is overflowed or otherwise hidden. + // Base case: not an element, has zero size, scrolled out, or doesn't overflow container. + // Visibility of text nodes is controlled by parent + if (childNode.nodeType === Node.TEXT_NODE) { + return false; + } + if (!nodeIsElement(childNode)) { + return true; + } + if (!elementSubtreeHasNonZeroDimensions(childNode)) { + return true; + } + // Recurse. + return isElementSubtreeHiddenByOverflow(childNode); + }); + } + // walk up the tree testing for a shadow root + function isElementInsideShadowRoot(element) { + if (!element) { + return false; + } + if (element.parentNode && element.parentNode.host) { + return true; + } + return isElementInsideShadowRoot(element.parentNode); + } + // This is a partial reimplementation of Selenium's "element is displayed" algorithm. + // When the W3C specification's algorithm stabilizes, we should implement that. + // If this command is misdirected to the wrong document (and is NOT inside a shadow root), treat it as not shown. + if (!isElementInsideShadowRoot(element) && !document.contains(element)) { + return false; + } + // Special cases for specific tag names. + switch (element.tagName.toUpperCase()) { + case 'BODY': + return true; + case 'SCRIPT': + case 'NOSCRIPT': + return false; + case 'OPTGROUP': + case 'OPTION': { + // Option/optgroup are considered shown if the containing is considered not shown. + if (element.type === 'hidden') { + return false; + } + break; + // case 'MAP': + // FIXME: Selenium has special handling for elements. We don't do anything now. + default: + break; + } + if (cascadedStylePropertyForElement(element, 'visibility') !== 'visible') { + return false; + } + let hasAncestorWithZeroOpacity = !!enclosingElementOrSelfMatchingPredicate(element, (e) => { + return Number(cascadedStylePropertyForElement(e, 'opacity')) === 0; + }); + let hasAncestorWithDisplayNone = !!enclosingElementOrSelfMatchingPredicate(element, (e) => { + return cascadedStylePropertyForElement(e, 'display') === 'none'; + }); + if (hasAncestorWithZeroOpacity || hasAncestorWithDisplayNone) { + return false; + } + if (!elementSubtreeHasNonZeroDimensions(element)) { + return false; + } + if (isElementSubtreeHiddenByOverflow(element)) { + return false; + } + return true; +} + diff --git a/mlc_config.json b/mlc_config.json new file mode 100644 index 000000000..5d70a03e0 --- /dev/null +++ b/mlc_config.json @@ -0,0 +1,7 @@ +{ + "ignorePatterns": [ + { + "pattern": "^https://stackoverflow\\.com/questions/tagged/php\\+selenium-webdriver" + } + ] +} diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 000000000..b4c1e307d --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,29 @@ +parameters: + level: 2 + paths: + - lib/ + - tests/ + + ignoreErrors: + # To be fixed in next major version: + - message: '#Call to an undefined method Facebook\\WebDriver\\WebDriver::getTouch\(\)#' + path: 'lib/Interactions/WebDriverTouchActions.php' + - message: '#Call to an undefined method Facebook\\WebDriver\\WebDriver::getTouch\(\)#' + path: 'lib/Support/Events/EventFiringWebDriver.php' + - message: '#Call to an undefined method Facebook\\WebDriver\\WebDriverElement::getCoordinates\(\)#' + path: 'lib/Support/Events/EventFiringWebElement.php' + - message: '#Call to an undefined method Facebook\\WebDriver\\WebDriverElement::equals\(\)#' + path: 'lib/Support/Events/EventFiringWebElement.php' + - message: '#Call to an undefined method Facebook\\WebDriver\\WebDriverElement::takeElementScreenshot\(\)#' + path: 'lib/Support/Events/EventFiringWebElement.php' + - message: '#Call to an undefined method Facebook\\WebDriver\\WebDriverElement::getShadowRoot\(\)#' + path: 'lib/Support/Events/EventFiringWebElement.php' + - '#Unsafe usage of new static\(\)#' + + # Parameter is intentionally not part of signature to not break BC + - message: '#PHPDoc tag \@param references unknown parameter: \$isW3cCompliant#' + path: 'lib/Remote/RemoteWebDriver.php' + - message: '#PHPDoc tag \@param references unknown parameter: \$existingCapabilities#' + path: 'lib/Remote/RemoteWebDriver.php' + + inferPrivatePropertyTypeFromConstructor: true diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 894ba470a..c58c72eaa 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,20 +1,9 @@ - - - - + tests/unit @@ -24,9 +13,13 @@ - - + + ./lib - - + + + + + + diff --git a/scripts/docs-template.html b/scripts/docs-template.html new file mode 100644 index 000000000..4814f5ec5 --- /dev/null +++ b/scripts/docs-template.html @@ -0,0 +1,7 @@ + + + + + Taking you to the latest documentation. + + \ No newline at end of file diff --git a/scripts/doctum.php b/scripts/doctum.php new file mode 100644 index 000000000..08e722917 --- /dev/null +++ b/scripts/doctum.php @@ -0,0 +1,30 @@ +files() + ->name('*.php') + ->in($srcRoot . 'lib'); + +$versions = GitVersionCollection::create($srcRoot) + ->addFromTags('1.*') // only latest minor version + ->addFromTags('0.6.0') + ->add('main', 'main branch') +; + +return new Doctum($iterator, [ + 'title' => 'php-webdriver API', + 'theme' => 'default', + 'build_dir' => $root . '/build/dist/%version%/', + 'cache_dir' => $root . '/build/cache/%version%/', + 'include_parent_data' => true, + 'remote_repository' => new GitHubRemoteRepository('php-webdriver/php-webdriver', $srcRoot), + 'versions' => $versions, + 'base_url' => 'https://php-webdriver.github.io/php-webdriver/%version%/' +]); diff --git a/scripts/update-built-docs.sh b/scripts/update-built-docs.sh new file mode 100755 index 000000000..76646bed5 --- /dev/null +++ b/scripts/update-built-docs.sh @@ -0,0 +1,50 @@ +#!/bin/sh +set -e + +cleanup() { + git ls-files ./ | xargs -r -n 1 rm + rm -rfd ./* +} + +copyToTemp() { + TEMP_DIR="$(mktemp -d --suffix=_doctum-build-php-webdriver)" + cp -rp build/dist/* "${TEMP_DIR}" + cp ./scripts/docs-template.html "${TEMP_DIR}/index.html" +} + +emptyAndRemoveTemp() { + mv "${TEMP_DIR}"/* ./ + # Create symlink for main to latest + ln -s -r ./main ./latest + # Create symlink for main to master + ln -s -r ./main ./master + # Create symlink for main to community + ln -s -r ./main ./community + rm -rf "${TEMP_DIR}" +} + +commitAndPushChanges() { + # Push the changes, only if there is changes + git add -A + git diff-index --quiet HEAD || git commit -m "Api documentations update ($(date --rfc-3339=seconds --utc))" -m "#apidocs" && if [ -z "${SKIP_PUSH}" ]; then git push; fi +} + +if [ ! -d ./build/dist ]; then + echo 'Missing built docs' + exit 1 +fi + +# Remove cache dir, do not upload it +rm -rf ./build/cache + +copyToTemp +# Remove build dir, do not upload it +rm -rf ./build + +git checkout gh-pages + +cleanup +emptyAndRemoveTemp +commitAndPushChanges + +git checkout - > /dev/null diff --git a/tests/bootstrap.php b/tests/bootstrap.php index f9e62ac66..d9cb0c2c7 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -1,2 +1,3 @@ -desiredCapabilities->getBrowserName() !== WebDriverBrowserType::CHROME) { + $this->markTestSkipped('ChromeDevTools are available only in Chrome'); + } + } + + public function testShouldExecuteDevToolsCommandWithoutParameters(): void + { + $devTools = new ChromeDevToolsDriver($this->driver); + + $result = $devTools->execute('Performance.enable'); + + $this->assertSame([], $result); + } + + public function testShouldExecuteDevToolsCommandWithParameters(): void + { + $devTools = new ChromeDevToolsDriver($this->driver); + + $result = $devTools->execute('Runtime.evaluate', [ + 'returnByValue' => true, + 'expression' => '42 + 1', + ]); + + $this->assertSame('number', $result['result']['type']); + $this->assertSame(43, $result['result']['value']); + } +} diff --git a/tests/functional/Chrome/ChromeDriverServiceTest.php b/tests/functional/Chrome/ChromeDriverServiceTest.php new file mode 100644 index 000000000..9b6c46336 --- /dev/null +++ b/tests/functional/Chrome/ChromeDriverServiceTest.php @@ -0,0 +1,90 @@ +markTestSkipped('The test is run only when running against local chrome'); + } + } + + protected function tearDown(): void + { + if ($this->driverService !== null && $this->driverService->isRunning()) { + $this->driverService->stop(); + } + } + + public function testShouldStartAndStopServiceCreatedUsingShortcutConstructor(): void + { + // The createDefaultService() method expect path to the executable to be present in the environment variable + putenv(ChromeDriverService::CHROME_DRIVER_EXECUTABLE . '=' . getenv('CHROMEDRIVER_PATH')); + + $this->driverService = ChromeDriverService::createDefaultService(); + + $this->assertSame('http://localhost:9515/', $this->driverService->getURL()); + + $this->assertInstanceOf(ChromeDriverService::class, $this->driverService->start()); + $this->assertTrue($this->driverService->isRunning()); + + $this->assertInstanceOf(ChromeDriverService::class, $this->driverService->start()); + + $this->assertInstanceOf(ChromeDriverService::class, $this->driverService->stop()); + $this->assertFalse($this->driverService->isRunning()); + + $this->assertInstanceOf(ChromeDriverService::class, $this->driverService->stop()); + } + + public function testShouldStartAndStopServiceCreatedUsingDefaultConstructor(): void + { + $this->driverService = new ChromeDriverService(getenv('CHROMEDRIVER_PATH'), 9515, ['--port=9515']); + + $this->assertSame('http://localhost:9515/', $this->driverService->getURL()); + + $this->driverService->start(); + $this->assertTrue($this->driverService->isRunning()); + + $this->driverService->stop(); + $this->assertFalse($this->driverService->isRunning()); + } + + public function testShouldThrowExceptionIfExecutableIsNotExecutable(): void + { + putenv(ChromeDriverService::CHROME_DRIVER_EXECUTABLE . '=' . __FILE__); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('is not executable'); + ChromeDriverService::createDefaultService(); + } + + public function testShouldUseDefaultExecutableIfNoneProvided(): void + { + // Put path where ChromeDriver binary is actually located to system PATH, to make sure we can locate it + putenv('PATH=' . getenv('PATH') . ':' . dirname(getenv('CHROMEDRIVER_PATH'))); + + // Unset CHROME_DRIVER_EXECUTABLE so that ChromeDriverService will attempt to run the binary from system PATH + putenv(ChromeDriverService::CHROME_DRIVER_EXECUTABLE . '='); + + $this->driverService = ChromeDriverService::createDefaultService(); + + $this->assertSame('http://localhost:9515/', $this->driverService->getURL()); + + $this->assertInstanceOf(ChromeDriverService::class, $this->driverService->start()); + $this->assertTrue($this->driverService->isRunning()); + } +} diff --git a/tests/functional/Chrome/ChromeDriverTest.php b/tests/functional/Chrome/ChromeDriverTest.php new file mode 100644 index 000000000..407b65bd8 --- /dev/null +++ b/tests/functional/Chrome/ChromeDriverTest.php @@ -0,0 +1,97 @@ +markTestSkipped('The test is run only when running against local chrome'); + } + } + + protected function tearDown(): void + { + if ($this->driver instanceof RemoteWebDriver && $this->driver->getCommandExecutor() !== null) { + $this->driver->quit(); + } + } + + /** + * @dataProvider provideDialect + */ + public function testShouldStartChromeDriver(bool $isW3cDialect): void + { + $this->startChromeDriver($isW3cDialect); + $this->assertInstanceOf(ChromeDriver::class, $this->driver); + $this->assertInstanceOf(DriverCommandExecutor::class, $this->driver->getCommandExecutor()); + + // Make sure actual browser capabilities were set + $this->assertNotEmpty($this->driver->getCapabilities()->getVersion()); + $this->assertNotEmpty($this->driver->getCapabilities()->getCapability('goog:chromeOptions')); + + $this->driver->get('http://localhost:8000/'); + + $this->assertSame('http://localhost:8000/', $this->driver->getCurrentURL()); + } + + /** + * @return array[] + */ + public function provideDialect(): array + { + return [ + 'w3c' => [true], + 'oss' => [false], + ]; + } + + public function testShouldInstantiateDevTools(): void + { + $this->startChromeDriver(); + + $devTools = $this->driver->getDevTools(); + + $this->assertInstanceOf(ChromeDevToolsDriver::class, $devTools); + + $this->driver->get('http://localhost:8000/'); + + $cdpResult = $devTools->execute( + 'Runtime.evaluate', + ['expression' => 'window.location.toString()'] + ); + + $this->assertSame(['result' => ['type' => 'string', 'value' => 'http://localhost:8000/']], $cdpResult); + } + + private function startChromeDriver($w3cDialect = true): void + { + // The createDefaultService() method expect path to the executable to be present in the environment variable + putenv(ChromeDriverService::CHROME_DRIVER_EXECUTABLE . '=' . getenv('CHROMEDRIVER_PATH')); + + // Add --no-sandbox as a workaround for Chrome crashing: https://github.com/SeleniumHQ/selenium/issues/4961 + $chromeOptions = new ChromeOptions(); + $chromeOptions->addArguments(['--no-sandbox', '--headless', '--disable-search-engine-choice-screen']); + $chromeOptions->setExperimentalOption('w3c', $w3cDialect); + $desiredCapabilities = DesiredCapabilities::chrome(); + $desiredCapabilities->setCapability(ChromeOptions::CAPABILITY, $chromeOptions); + + $this->driver = ChromeDriver::start($desiredCapabilities); + } +} diff --git a/tests/functional/FileUploadTest.php b/tests/functional/FileUploadTest.php index e8878e642..e812353ea 100644 --- a/tests/functional/FileUploadTest.php +++ b/tests/functional/FileUploadTest.php @@ -1,31 +1,26 @@ -driver->get($this->getTestPageUrl('upload.html')); + $this->driver->get($this->getTestPageUrl(TestPage::UPLOAD)); $fileElement = $this->driver->findElement(WebDriverBy::name('upload')); @@ -50,17 +45,7 @@ public function testShouldUploadAFile() $this->assertSame('10', $uploadedFileSize); } - public function xtestUselessFileDetectorSendKeys() - { - $this->driver->get($this->getTestPath('upload.html')); - - $file_input = $this->driver->findElement(WebDriverBy::id('upload')); - $file_input->sendKeys($this->getTestFilePath()); - - $this->assertEquals($this->getTestFilePath(), $file_input->getAttribute('value')); - } - - private function getTestFilePath() + 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/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 index 4f05a4319..a39a91070 100644 --- a/tests/functional/RemoteWebDriverCreateTest.php +++ b/tests/functional/RemoteWebDriverCreateTest.php @@ -1,58 +1,80 @@ -driver = RemoteWebDriver::create($this->serverUrl, $this->desiredCapabilities, 10000, 13370); + $this->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->assertSame($this->serverUrl, $this->driver->getCommandExecutor()->getAddressOfRemoteServer()); + $this->assertNotEmpty($this->driver->getCommandExecutor()->getAddressOfRemoteServer()); - $this->assertInternalType('string', $this->driver->getSessionID()); + $this->assertIsString($this->driver->getSessionID()); $this->assertNotEmpty($this->driver->getSessionID()); $returnedCapabilities = $this->driver->getCapabilities(); $this->assertInstanceOf(WebDriverCapabilities::class, $returnedCapabilities); - $this->assertSame($this->desiredCapabilities->getBrowserName(), $returnedCapabilities->getBrowserName()); + + // 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 testShouldCreateWebDriverWithRequiredCapabilities() + 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, - null, - null, + $this->connectionTimeout, + $this->requestTimeout, null, null, $requiredCapabilities @@ -60,4 +82,94 @@ public function testShouldCreateWebDriverWithRequiredCapabilities() $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 index ad1daf348..f638aa527 100644 --- a/tests/functional/RemoteWebDriverFindElementTest.php +++ b/tests/functional/RemoteWebDriverFindElementTest.php @@ -1,17 +1,4 @@ -driver->get($this->getTestPath('index.html')); + $this->driver->get($this->getTestPageUrl(TestPage::INDEX)); - $this->setExpectedException(NoSuchElementException::class, 'Unable to locate element'); + $this->expectException(NoSuchElementException::class); $this->driver->findElement(WebDriverBy::id('not_existing')); } - public function testShouldFindElementIfExistsOnAPage() + public function testShouldFindElementIfExistsOnAPage(): void { - $this->driver->get($this->getTestPath('index.html')); + $this->driver->get($this->getTestPageUrl(TestPage::INDEX)); $element = $this->driver->findElement(WebDriverBy::id('id_test')); $this->assertInstanceOf(RemoteWebElement::class, $element); } - public function testShouldReturnEmptyArrayIfElementsCannotBeFound() + public function testShouldReturnEmptyArrayIfElementsCannotBeFound(): void { - $this->driver->get($this->getTestPath('index.html')); + $this->driver->get($this->getTestPageUrl(TestPage::INDEX)); $elements = $this->driver->findElements(WebDriverBy::cssSelector('not_existing')); - $this->assertInternalType('array', $elements); + $this->assertIsArray($elements); $this->assertCount(0, $elements); } - public function testShouldFindMultipleElements() + public function testShouldFindMultipleElements(): void { - $this->driver->get($this->getTestPath('index.html')); + $this->driver->get($this->getTestPageUrl(TestPage::INDEX)); $elements = $this->driver->findElements(WebDriverBy::cssSelector('ul > li')); - $this->assertInternalType('array', $elements); - $this->assertCount(3, $elements); + $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 index 918b3514b..95a01b9b7 100644 --- a/tests/functional/RemoteWebDriverTest.php +++ b/tests/functional/RemoteWebDriverTest.php @@ -1,35 +1,21 @@ -driver->get($this->getTestPath('index.html')); + $this->driver->get($this->getTestPageUrl(TestPage::INDEX)); $this->assertEquals( 'php-webdriver test page', @@ -38,70 +24,91 @@ public function testShouldGetPageTitle() } /** - * @covers ::getCurrentURL * @covers ::get + * @covers ::getCurrentURL */ - public function testShouldGetCurrentUrl() + public function testShouldGetCurrentUrl(): void { - $this->driver->get($this->getTestPath('index.html')); + $this->driver->get($this->getTestPageUrl(TestPage::INDEX)); - $this->assertContains( - '/index.html', - $this->driver->getCurrentURL() - ); + $this->assertStringEndsWith('/index.html', $this->driver->getCurrentURL()); } /** * @covers ::getPageSource */ - public function testShouldGetPageSource() + public function testShouldGetPageSource(): void { - $this->driver->get($this->getTestPath('index.html')); + $this->driver->get($this->getTestPageUrl(TestPage::INDEX)); $source = $this->driver->getPageSource(); - $this->assertContains('

', $source); - $this->assertContains('Welcome to the facebook/php-webdriver testing page.', $source); + $this->assertStringContainsString('

', $source); + $this->assertStringContainsString('Welcome to the php-webdriver testing page.', $source); } /** * @covers ::getSessionID + * @covers ::isW3cCompliant */ - public function testShouldGetSessionId() + 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->assertInternalType('string', $sessionId); + $this->assertIsString($sessionId); $this->assertNotEmpty($sessionId); } /** + * @group exclude-saucelabs * @covers ::getAllSessions */ - public function testShouldGetAllSessions() + public function testShouldGetAllSessions(): void { - $sessions = RemoteWebDriver::getAllSessions(); + self::skipForW3cProtocol(); + + $sessions = RemoteWebDriver::getAllSessions($this->serverUrl, 30000); - $this->assertInternalType('array', $sessions); + $this->assertIsArray($sessions); $this->assertCount(1, $sessions); $this->assertArrayHasKey('capabilities', $sessions[0]); $this->assertArrayHasKey('id', $sessions[0]); - $this->assertArrayHasKey('class', $sessions[0]); } /** + * @group exclude-saucelabs * @covers ::getAllSessions * @covers ::getCommandExecutor * @covers ::quit */ - public function testShouldQuitAndUnsetExecutor() + public function testShouldQuitAndUnsetExecutor(): void { - $this->assertCount(1, RemoteWebDriver::getAllSessions()); + self::skipForW3cProtocol(); + + $this->assertCount( + 1, + RemoteWebDriver::getAllSessions($this->serverUrl) + ); $this->assertInstanceOf(HttpCommandExecutor::class, $this->driver->getCommandExecutor()); $this->driver->quit(); - $this->assertCount(0, RemoteWebDriver::getAllSessions()); + // 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()); } @@ -109,14 +116,14 @@ public function testShouldQuitAndUnsetExecutor() * @covers ::getWindowHandle * @covers ::getWindowHandles */ - public function testShouldGetWindowHandles() + public function testShouldGetWindowHandles(): void { - $this->driver->get($this->getTestPath('open_new_window.html')); + $this->driver->get($this->getTestPageUrl(TestPage::OPEN_NEW_WINDOW)); $windowHandle = $this->driver->getWindowHandle(); $windowHandles = $this->driver->getWindowHandles(); - $this->assertInternalType('string', $windowHandle); + $this->assertIsString($windowHandle); $this->assertNotEmpty($windowHandle); $this->assertSame([$windowHandle], $windowHandles); @@ -131,13 +138,15 @@ public function testShouldGetWindowHandles() } /** - * @covers ::getWindowHandles + * @covers ::close */ - public function testShouldCloseWindow() + public function testShouldCloseWindow(): void { - $this->driver->get($this->getTestPath('open_new_window.html')); + $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(); @@ -147,74 +156,113 @@ public function testShouldCloseWindow() /** * @covers ::executeScript + * @group exclude-saucelabs */ - public function testShouldExecuteScriptAndDoNotBlockExecution() + public function testShouldExecuteScriptAndDoNotBlockExecution(): void { - $this->driver->get($this->getTestPath('index.html')); + $this->driver->get($this->getTestPageUrl(TestPage::INDEX)); $element = $this->driver->findElement(WebDriverBy::id('id_test')); $this->assertSame('Test by ID', $element->getText()); - $this->driver->executeScript(' + $start = microtime(true); + $scriptResult = $this->driver->executeScript(' setTimeout( - function(){document.getElementById("id_test").innerHTML = "Text changed by script"}, + function(){document.getElementById("id_test").innerHTML = "Text changed by script";}, 250 - )'); + ); + return "returned value"; + '); + $end = microtime(true); - // Make sure the script don't block the test execution - $this->assertSame('Test by ID', $element->getText()); + $this->assertSame('returned value', $scriptResult); + + $this->assertLessThan(250, $end - $start, 'executeScript() should not block execution'); - // If we wait, the script should be executed + // 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() + public function testShouldExecuteAsyncScriptAndWaitUntilItIsFinished(): void { $this->driver->manage()->timeouts()->setScriptTimeout(1); - $this->driver->get($this->getTestPath('index.html')); + $this->driver->get($this->getTestPageUrl(TestPage::INDEX)); $element = $this->driver->findElement(WebDriverBy::id('id_test')); $this->assertSame('Test by ID', $element->getText()); - $this->driver->executeAsyncScript( + $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(); + 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() + public function testShouldTakeScreenshot(): void { if (!extension_loaded('gd')) { $this->markTestSkipped('GD extension must be enabled'); } - if ($this->desiredCapabilities->getBrowserName() == WebDriverBrowserType::HTMLUNIT) { - $this->markTestSkipped('Screenshots are not supported by HtmlUnit browser'); - } - - $this->driver->get($this->getTestPath('index.html')); + $this->driver->get($this->getTestPageUrl(TestPage::INDEX)); $outputPng = $this->driver->takeScreenshot(); $image = imagecreatefromstring($outputPng); - $this->assertInternalType('resource', $image); + $this->assertNotFalse($image); $this->assertGreaterThan(0, imagesx($image)); $this->assertGreaterThan(0, imagesy($image)); @@ -222,28 +270,59 @@ public function testShouldTakeScreenshot() /** * @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() + public function testShouldSaveScreenshotToFile(): void { if (!extension_loaded('gd')) { $this->markTestSkipped('GD extension must be enabled'); } - if ($this->desiredCapabilities->getBrowserName() == WebDriverBrowserType::HTMLUNIT) { - $this->markTestSkipped('Screenshots are not supported by HtmlUnit browser'); - } - $screenshotPath = sys_get_temp_dir() . '/selenium-screenshot.png'; + $screenshotPath = sys_get_temp_dir() . '/' . uniqid('php-webdriver-') . '/selenium-screenshot.png'; - $this->driver->get($this->getTestPath('index.html')); + $this->driver->get($this->getTestPageUrl(TestPage::INDEX)); $this->driver->takeScreenshot($screenshotPath); $image = imagecreatefrompng($screenshotPath); - $this->assertInternalType('resource', $image); + $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 index 1a04cf1a8..6a21cb36c 100644 --- a/tests/functional/RemoteWebElementTest.php +++ b/tests/functional/RemoteWebElementTest.php @@ -1,61 +1,118 @@ -driver->get($this->getTestPath('index.html')); + $this->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()); } - public function testShouldGetAttributeValue() + /** + * @covers ::getAttribute + */ + public function testShouldGetAttributeValue(): void { - $this->driver->get($this->getTestPath('index.html')); + $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')); } - public function testShouldGetLocation() + /** + * @covers ::getDomProperty + */ + public function testShouldGetDomPropertyValue(): void { - $this->driver->get($this->getTestPath('index.html')); + 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(500, $elementLocation->getY()); + $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() + ); } - public function testShouldGetSize() + /** + * @covers ::getSize + */ + public function testShouldGetSize(): void { - $this->driver->get($this->getTestPath('index.html')); + $this->driver->get($this->getTestPageUrl(TestPage::INDEX)); $element = $this->driver->findElement(WebDriverBy::id('element-with-location')); @@ -65,9 +122,12 @@ public function testShouldGetSize() $this->assertSame(66, $elementSize->getHeight()); } - public function testShouldGetCssValue() + /** + * @covers ::getCSSValue + */ + public function testShouldGetCssValue(): void { - $this->driver->get($this->getTestPath('index.html')); + $this->driver->get($this->getTestPageUrl(TestPage::INDEX)); $elementWithBorder = $this->driver->findElement(WebDriverBy::id('text-simple')); $elementWithoutBorder = $this->driver->findElement(WebDriverBy::id('text-with-spaces')); @@ -75,7 +135,407 @@ public function testShouldGetCssValue() $this->assertSame('solid', $elementWithBorder->getCSSValue('border-left-style')); $this->assertSame('none', $elementWithoutBorder->getCSSValue('border-left-style')); - $this->assertSame('rgba(0, 0, 0, 1)', $elementWithBorder->getCSSValue('border-left-color')); - $this->assertSame('rgba(0, 0, 0, 1)', $elementWithoutBorder->getCSSValue('border-left-color')); + // 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 index 753790248..2aadf8de9 100644 --- a/tests/functional/WebDriverByTest.php +++ b/tests/functional/WebDriverByTest.php @@ -1,17 +1,4 @@ -driver->get($this->getTestPath('index.html')); + string $webDriverByLocatorMethod, + string $webDriverByLocatorValue, + ?string $expectedText = null, + ?string $expectedAttributeValue = null + ): void { + $this->driver->get($this->getTestPageUrl(TestPage::INDEX)); $by = call_user_func([WebDriverBy::class, $webDriverByLocatorMethod], $webDriverByLocatorValue); $element = $this->driver->findElement($by); @@ -52,7 +35,10 @@ public function testShouldFindTextElementByLocator( } } - public function textElementsProvider() + /** + * @return array[] + */ + public function provideTextElements(): array { return [ 'id' => ['id', 'id_test', 'Test by ID'], 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 index 3a7d55f24..a5640b1be 100644 --- a/tests/functional/WebDriverSelectTest.php +++ b/tests/functional/WebDriverSelectTest.php @@ -1,17 +1,4 @@ -driver->get($this->getTestPath('form.html')); + $this->driver->get($this->getTestPageUrl(TestPage::FORM)); } - public function testShouldCreateNewInstanceForSelectElementAndDetectIfItIsMultiple() + /** + * @dataProvider multipleSelectDataProvider + */ + public function testShouldCreateNewInstanceForSelectElementAndDetectIfItIsMultiple(string $selector): void { $originalElement = $this->driver->findElement(WebDriverBy::cssSelector('#select')); - $originalMultipleElement = $this->driver->findElement(WebDriverBy::cssSelector('#select-multiple')); + $originalMultipleElement = $this->driver->findElement(WebDriverBy::cssSelector($selector)); $select = new WebDriverSelect($originalElement); $selectMultiple = new WebDriverSelect($originalMultipleElement); @@ -47,22 +38,28 @@ public function testShouldCreateNewInstanceForSelectElementAndDetectIfItIsMultip $this->assertTrue($selectMultiple->isMultiple()); } - public function testShouldThrowExceptionWhenNotInstantiatedOnSelectElement() + 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->setExpectedException( - UnexpectedTagNameException::class, - 'Element should have been "select" but was "textarea"' - ); + $this->expectException(UnexpectedTagNameException::class); + $this->expectExceptionMessage('Element should have been "select" but was "textarea"'); new WebDriverSelect($notSelectElement); } /** - * @dataProvider selectSelectorProvider - * @param string $selector + * @dataProvider provideSelectSelector */ - public function testShouldGetOptionsOfSelect($selector) + public function testShouldGetOptionsOfSelect(string $selector): void { $originalElement = $this->driver->findElement(WebDriverBy::cssSelector($selector)); $select = new WebDriverSelect($originalElement); @@ -73,7 +70,10 @@ public function testShouldGetOptionsOfSelect($selector) $this->assertCount(5, $options); } - public function selectSelectorProvider() + /** + * @return array[] + */ + public function provideSelectSelector(): array { return [ 'simple + + +
      + + +
      + +

      @@ -36,8 +48,40 @@ + + + +
      +
      + 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 index 282706cce..6202e3e9b 100644 --- a/tests/functional/web/index.html +++ b/tests/functional/web/index.html @@ -3,9 +3,42 @@ php-webdriver test page + - -

      Welcome to the facebook/php-webdriver testing 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 @@ -17,16 +50,37 @@

      Welcome to the facebook/php-webdriver testing page.

    • 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 index b56e93d69..3c790226a 100644 --- a/tests/functional/web/open_new_window.html +++ b/tests/functional/web/open_new_window.html @@ -5,6 +5,6 @@ php-webdriver test page - open new window + 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 ''; +} +?> + + + diff --git a/tests/functional/web/upload.html b/tests/functional/web/upload.html index d02c5ed4b..6762873c3 100644 --- a/tests/functional/web/upload.html +++ b/tests/functional/web/upload.html @@ -5,10 +5,10 @@ Upload a file -
    +

    - +

    diff --git a/tests/functional/web/upload.php b/tests/functional/web/upload.php index 91a61bbcf..92b52b57a 100644 --- a/tests/functional/web/upload.php +++ b/tests/functional/web/upload.php @@ -1,4 +1,5 @@ - + diff --git a/tests/functional/web/web_components.html b/tests/functional/web/web_components.html new file mode 100644 index 000000000..65e458c30 --- /dev/null +++ b/tests/functional/web/web_components.html @@ -0,0 +1,38 @@ + + + + + WebComponents and Shadow DOM tests + + + + +

    WebComponents and Shadow DOM tests

    + +

    Element out of Shadow DOM

    +
    + +
    + + + + diff --git a/tests/unit/CookieTest.php b/tests/unit/CookieTest.php new file mode 100644 index 000000000..444569499 --- /dev/null +++ b/tests/unit/CookieTest.php @@ -0,0 +1,200 @@ +setPath('/bar'); + $cookie->setDomain('foo.com'); + $cookie->setExpiry(1485388387); + $cookie->setSecure(true); + $cookie->setHttpOnly(true); + $cookie->setSameSite('Lax'); + + $this->assertSame('cookieName', $cookie->getName()); + $this->assertSame('someValue', $cookie->getValue()); + $this->assertSame('/bar', $cookie->getPath()); + $this->assertSame('foo.com', $cookie->getDomain()); + $this->assertSame(1485388387, $cookie->getExpiry()); + $this->assertTrue($cookie->isSecure()); + $this->assertTrue($cookie->isHttpOnly()); + $this->assertSame('Lax', $cookie->getSameSite()); + + return $cookie; + } + + /** + * @depends testShouldSetAllProperties + */ + public function testShouldBeConvertibleToArray(Cookie $cookie): void + { + $this->assertSame( + [ + 'name' => 'cookieName', + 'value' => 'someValue', + 'path' => '/bar', + 'domain' => 'foo.com', + 'expiry' => 1485388387, + 'secure' => true, + 'httpOnly' => true, + 'sameSite' => 'Lax', + ], + $cookie->toArray() + ); + } + + /** + * Test that there are no null values in the cookie array. + * + * Both JsonWireProtocol and w3c protocol say to leave an entry off + * rather than having a null value. + * + * https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol + * https://w3c.github.io/webdriver/#add-cookie + */ + public function testShouldNotContainNullValues(): void + { + $cookie = new Cookie('cookieName', 'someValue'); + + $cookie->setHttpOnly(null); + $cookie->setPath(null); + $cookie->setSameSite(null); + $cookieArray = $cookie->toArray(); + + foreach ($cookieArray as $key => $value) { + $this->assertNotNull($value, $key . ' should not be null'); + } + } + + /** + * @depends testShouldSetAllProperties + */ + public function testShouldProvideArrayAccessToProperties(Cookie $cookie): void + { + $this->assertSame('cookieName', $cookie['name']); + $this->assertSame('someValue', $cookie['value']); + $this->assertSame('/bar', $cookie['path']); + $this->assertSame('foo.com', $cookie['domain']); + $this->assertSame(1485388387, $cookie['expiry']); + $this->assertTrue($cookie['secure']); + $this->assertTrue($cookie['httpOnly']); + $this->assertSame('Lax', $cookie['sameSite']); + + $cookie->offsetSet('domain', 'bar.com'); + $this->assertSame('bar.com', $cookie['domain']); + $cookie->offsetUnset('domain'); + $this->assertArrayNotHasKey('domain', $cookie); + } + + public function testShouldBeCreatableFromAnArrayWithBasicValues(): void + { + $sourceArray = [ + 'name' => 'cookieName', + 'value' => 'someValue', + ]; + + $cookie = Cookie::createFromArray($sourceArray); + + $this->assertSame('cookieName', $cookie['name']); + $this->assertSame('someValue', $cookie['value']); + + $this->assertArrayNotHasKey('path', $cookie); + $this->assertNull($cookie['path']); + $this->assertNull($cookie->getPath()); + + $this->assertArrayNotHasKey('domain', $cookie); + $this->assertNull($cookie['domain']); + $this->assertNull($cookie->getDomain()); + + $this->assertArrayNotHasKey('expiry', $cookie); + $this->assertNull($cookie['expiry']); + $this->assertNull($cookie->getExpiry()); + + $this->assertArrayNotHasKey('secure', $cookie); + $this->assertNull($cookie['secure']); + $this->assertNull($cookie->isSecure()); + + $this->assertArrayNotHasKey('httpOnly', $cookie); + $this->assertNull($cookie['httpOnly']); + $this->assertNull($cookie->isHttpOnly()); + + $this->assertArrayNotHasKey('sameSite', $cookie); + $this->assertNull($cookie['sameSite']); + $this->assertNull($cookie->getSameSite()); + } + + public function testShouldBeCreatableFromAnArrayWithAllValues(): void + { + $sourceArray = [ + 'name' => 'cookieName', + 'value' => 'someValue', + 'path' => '/bar', + 'domain' => 'foo', + 'expiry' => 1485388333, + 'secure' => false, + 'httpOnly' => false, + 'sameSite' => 'Lax', + ]; + + $cookie = Cookie::createFromArray($sourceArray); + + $this->assertSame('cookieName', $cookie['name']); + $this->assertSame('someValue', $cookie['value']); + $this->assertSame('/bar', $cookie['path']); + $this->assertSame('foo', $cookie['domain']); + $this->assertSame(1485388333, $cookie['expiry']); + $this->assertFalse($cookie['secure']); + $this->assertFalse($cookie['httpOnly']); + $this->assertSame('Lax', $cookie['sameSite']); + } + + /** + * @dataProvider provideInvalidCookie + */ + public function testShouldValidateCookieOnConstruction( + ?string $name, + ?string $value, + ?string $domain, + ?string $expectedMessage + ): void { + if ($expectedMessage) { + $this->expectException(LogicException::class); + $this->expectExceptionMessage($expectedMessage); + } + + $cookie = new Cookie($name, $value); + if ($domain !== null) { + $cookie->setDomain($domain); + } + + $this->assertInstanceOf(Cookie::class, $cookie); + } + + /** + * @return array[] + */ + public function provideInvalidCookie(): array + { + return [ + // $name, $value, $domain, $expectedMessage + 'name cannot be empty' => ['', 'foo', null, 'Cookie name should be non-empty'], + 'name cannot be null' => [null, 'foo', null, 'Cookie name should be non-empty'], + 'name cannot contain semicolon' => ['name;semicolon', 'foo', null, 'Cookie name should not contain a ";"'], + 'value could be empty string' => ['name', '', null, null], + 'value cannot be null' => ['name', null, null, 'Cookie value is required when setting a cookie'], + 'domain cannot containt port' => [ + 'name', + 'value', + 'localhost:443', + 'Cookie domain "localhost:443" should not contain a port', + ], + 'cookie with valid values' => ['name', 'value', '*.localhost', null], + ]; + } +} diff --git a/tests/unit/Exception/Internal/DriverServerDiedExceptionTest.php b/tests/unit/Exception/Internal/DriverServerDiedExceptionTest.php new file mode 100644 index 000000000..2e2fc5617 --- /dev/null +++ b/tests/unit/Exception/Internal/DriverServerDiedExceptionTest.php @@ -0,0 +1,17 @@ +assertSame($dummyPreviousException, $exception->getPrevious()); + } +} diff --git a/tests/unit/Exception/Internal/IOExceptionTest.php b/tests/unit/Exception/Internal/IOExceptionTest.php new file mode 100644 index 000000000..240991963 --- /dev/null +++ b/tests/unit/Exception/Internal/IOExceptionTest.php @@ -0,0 +1,15 @@ +assertSame('Error message ("/file/path.txt")', $exception->getMessage()); + } +} diff --git a/tests/unit/Exception/Internal/LogicExceptionTest.php b/tests/unit/Exception/Internal/LogicExceptionTest.php new file mode 100644 index 000000000..ba09d58d8 --- /dev/null +++ b/tests/unit/Exception/Internal/LogicExceptionTest.php @@ -0,0 +1,26 @@ +assertSame('Error message', $exception->getMessage()); + } + + public function testShouldCreateExceptionForInvalidHttpMethod(): void + { + $exception = LogicException::forInvalidHttpMethod('http://foo.bar/', 'FOO', ['key' => 'val']); + + $this->assertSame( + 'The http method called for "http://foo.bar/" is "FOO", but it has to be POST if you want to pass' + . ' the JSON params {"key":"val"}', + $exception->getMessage() + ); + } +} diff --git a/tests/unit/Exception/Internal/RuntimeExceptionTest.php b/tests/unit/Exception/Internal/RuntimeExceptionTest.php new file mode 100644 index 000000000..97adf87b3 --- /dev/null +++ b/tests/unit/Exception/Internal/RuntimeExceptionTest.php @@ -0,0 +1,34 @@ +assertSame('Error message', $exception->getMessage()); + } + + public function testShouldCreateExceptionForDriverError(): void + { + $processMock = $this->createConfiguredMock( + Process::class, + [ + 'getCommandLine' => '/bin/true --force', + 'getErrorOutput' => 'may the force be with you', + ] + ); + + $exception = RuntimeException::forDriverError($processMock); + + $this->assertSame( + 'Error starting driver executable "/bin/true --force": may the force be with you', + $exception->getMessage() + ); + } +} diff --git a/tests/unit/Exception/Internal/UnexpectedResponseExceptionTest.php b/tests/unit/Exception/Internal/UnexpectedResponseExceptionTest.php new file mode 100644 index 000000000..80f4b4a0d --- /dev/null +++ b/tests/unit/Exception/Internal/UnexpectedResponseExceptionTest.php @@ -0,0 +1,30 @@ +assertSame('Error message', $exception->getMessage()); + } + + public function testShouldCreateExceptionForJsonDecodingError(): void + { + $exception = UnexpectedResponseException::forJsonDecodingError(JSON_ERROR_SYNTAX, 'foo'); + + $this->assertSame( + <<getMessage() + ); + } +} diff --git a/tests/unit/Exception/Internal/WebDriverCurlExceptionTest.php b/tests/unit/Exception/Internal/WebDriverCurlExceptionTest.php new file mode 100644 index 000000000..acc94cb88 --- /dev/null +++ b/tests/unit/Exception/Internal/WebDriverCurlExceptionTest.php @@ -0,0 +1,34 @@ +assertSame( + <<getMessage() + ); + } + + public function provideParams(): array + { + return [ + 'null params' => [null, ''], + 'empty params' => [[], ''], + 'array of params' => [['bar' => 'foo', 'baz' => 'bat'], ' with params: {"bar":"foo","baz":"bat"}'], + ]; + } +} diff --git a/tests/unit/Exception/WebDriverExceptionTest.php b/tests/unit/Exception/WebDriverExceptionTest.php index 1dfd94fac..c770533d2 100644 --- a/tests/unit/Exception/WebDriverExceptionTest.php +++ b/tests/unit/Exception/WebDriverExceptionTest.php @@ -1,23 +1,12 @@ -assertInstanceOf($expectedExceptionType, $e); @@ -46,7 +37,43 @@ public function testShouldThrowProperExceptionBasedOnSeleniumStatusCode($statusC /** * @return array[] */ - public function statusCodeProvider() + 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], @@ -70,7 +97,7 @@ public function statusCodeProvider() [18, NoScriptResultException::class], [19, XPathLookupException::class], [20, NoSuchCollectionException::class], - [21, TimeOutException::class], + [21, TimeoutException::class], [22, NullPointerException::class], [23, NoSuchWindowException::class], [24, InvalidCookieDomainException::class], diff --git a/tests/unit/Firefox/FirefoxOptionsTest.php b/tests/unit/Firefox/FirefoxOptionsTest.php new file mode 100644 index 000000000..c8ac09f02 --- /dev/null +++ b/tests/unit/Firefox/FirefoxOptionsTest.php @@ -0,0 +1,121 @@ + */ + public const EXPECTED_DEFAULT_PREFS = [ + FirefoxPreferences::READER_PARSE_ON_LOAD_ENABLED => false, + FirefoxPreferences::DEVTOOLS_JSONVIEW => false, + ]; + + public function testShouldBeConstructedWithDefaultOptions(): void + { + $options = new FirefoxOptions(); + + $this->assertSame( + [ + 'prefs' => self::EXPECTED_DEFAULT_PREFS, + ], + $options->toArray() + ); + } + + public function testShouldAddCustomOptions(): void + { + $options = new FirefoxOptions(); + + $options->setOption('binary', '/usr/local/firefox/bin/firefox'); + + $this->assertSame( + [ + 'binary' => '/usr/local/firefox/bin/firefox', + 'prefs' => self::EXPECTED_DEFAULT_PREFS, + ], + $options->toArray() + ); + } + + public function testShouldOverwriteDefaultOptionsWhenSpecified(): void + { + $options = new FirefoxOptions(); + + $options->setPreference(FirefoxPreferences::READER_PARSE_ON_LOAD_ENABLED, true); + + $this->assertSame( + [ + 'prefs' => [ + FirefoxPreferences::READER_PARSE_ON_LOAD_ENABLED => true, + FirefoxPreferences::DEVTOOLS_JSONVIEW => false, + ], + ], + $options->toArray() + ); + } + + public function testShouldSetCustomPreference(): void + { + $options = new FirefoxOptions(); + + $options->setPreference('browser.startup.homepage', 'https://github.com/php-webdriver/php-webdriver/'); + + $this->assertSame( + [ + 'prefs' => [ + FirefoxPreferences::READER_PARSE_ON_LOAD_ENABLED => false, + FirefoxPreferences::DEVTOOLS_JSONVIEW => false, + 'browser.startup.homepage' => 'https://github.com/php-webdriver/php-webdriver/', + ], + ], + $options->toArray() + ); + } + + public function testShouldAddArguments(): void + { + $options = new FirefoxOptions(); + + $options->addArguments(['-headless', '-profile', '/path/to/profile']); + + $this->assertSame( + [ + 'args' => ['-headless', '-profile', '/path/to/profile'], + 'prefs' => self::EXPECTED_DEFAULT_PREFS, + ], + $options->toArray() + ); + } + + public function testShouldJsonSerializeToArrayObject(): void + { + $options = new FirefoxOptions(); + $options->setOption('binary', '/usr/local/firefox/bin/firefox'); + + $jsonSerialized = $options->jsonSerialize(); + + $this->assertInstanceOf(\ArrayObject::class, $jsonSerialized); + $this->assertSame('/usr/local/firefox/bin/firefox', $jsonSerialized['binary']); + } + + public function testShouldNotAllowToSetArgumentsOptionDirectly(): void + { + $options = new FirefoxOptions(); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Use addArguments() method to add Firefox arguments'); + $options->setOption('args', []); + } + + public function testShouldNotAllowToSetPreferencesOptionDirectly(): void + { + $options = new FirefoxOptions(); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Use setPreference() method to set Firefox preferences'); + $options->setOption('prefs', []); + } +} diff --git a/tests/unit/Interactions/Internal/WebDriverButtonReleaseActionTest.php b/tests/unit/Interactions/Internal/WebDriverButtonReleaseActionTest.php index f2bb1b647..f14bad0c2 100644 --- a/tests/unit/Interactions/Internal/WebDriverButtonReleaseActionTest.php +++ b/tests/unit/Interactions/Internal/WebDriverButtonReleaseActionTest.php @@ -1,33 +1,21 @@ -webDriverMouse = $this->getMockBuilder(WebDriverMouse::class)->getMock(); $this->locationProvider = $this->getMockBuilder(WebDriverLocatable::class)->getMock(); @@ -37,12 +25,12 @@ public function setUp() ); } - public function testPerformSendsMouseUpCommand() + public function testPerformSendsMouseUpCommand(): void { $coords = $this->getMockBuilder(WebDriverCoordinates::class) ->disableOriginalConstructor()->getMock(); $this->webDriverMouse->expects($this->once())->method('mouseUp')->with($coords); - $this->locationProvider->expects($this->once())->method('getCoordinates')->will($this->returnValue($coords)); + $this->locationProvider->expects($this->once())->method('getCoordinates')->willReturn($coords); $this->webDriverButtonReleaseAction->perform(); } } diff --git a/tests/unit/Interactions/Internal/WebDriverClickActionTest.php b/tests/unit/Interactions/Internal/WebDriverClickActionTest.php index fdb868141..de12aab26 100644 --- a/tests/unit/Interactions/Internal/WebDriverClickActionTest.php +++ b/tests/unit/Interactions/Internal/WebDriverClickActionTest.php @@ -1,33 +1,21 @@ -webDriverMouse = $this->getMockBuilder(WebDriverMouse::class)->getMock(); $this->locationProvider = $this->getMockBuilder(WebDriverLocatable::class)->getMock(); @@ -37,12 +25,12 @@ public function setUp() ); } - public function testPerformSendsClickCommand() + public function testPerformSendsClickCommand(): void { $coords = $this->getMockBuilder(WebDriverCoordinates::class) ->disableOriginalConstructor()->getMock(); $this->webDriverMouse->expects($this->once())->method('click')->with($coords); - $this->locationProvider->expects($this->once())->method('getCoordinates')->will($this->returnValue($coords)); + $this->locationProvider->expects($this->once())->method('getCoordinates')->willReturn($coords); $this->webDriverClickAction->perform(); } } diff --git a/tests/unit/Interactions/Internal/WebDriverClickAndHoldActionTest.php b/tests/unit/Interactions/Internal/WebDriverClickAndHoldActionTest.php index eb3f4bc6f..da5a18dee 100644 --- a/tests/unit/Interactions/Internal/WebDriverClickAndHoldActionTest.php +++ b/tests/unit/Interactions/Internal/WebDriverClickAndHoldActionTest.php @@ -1,33 +1,21 @@ -webDriverMouse = $this->getMockBuilder(WebDriverMouse::class)->getMock(); $this->locationProvider = $this->getMockBuilder(WebDriverLocatable::class)->getMock(); @@ -37,12 +25,12 @@ public function setUp() ); } - public function testPerformSendsMouseDownCommand() + public function testPerformSendsMouseDownCommand(): void { $coords = $this->getMockBuilder(WebDriverCoordinates::class) ->disableOriginalConstructor()->getMock(); $this->webDriverMouse->expects($this->once())->method('mouseDown')->with($coords); - $this->locationProvider->expects($this->once())->method('getCoordinates')->will($this->returnValue($coords)); + $this->locationProvider->expects($this->once())->method('getCoordinates')->willReturn($coords); $this->webDriverClickAndHoldAction->perform(); } } diff --git a/tests/unit/Interactions/Internal/WebDriverContextClickActionTest.php b/tests/unit/Interactions/Internal/WebDriverContextClickActionTest.php index e9162f239..f3e66f6ee 100644 --- a/tests/unit/Interactions/Internal/WebDriverContextClickActionTest.php +++ b/tests/unit/Interactions/Internal/WebDriverContextClickActionTest.php @@ -1,33 +1,21 @@ -webDriverMouse = $this->getMockBuilder(WebDriverMouse::class)->getMock(); $this->locationProvider = $this->getMockBuilder(WebDriverLocatable::class)->getMock(); @@ -37,12 +25,12 @@ public function setUp() ); } - public function testPerformSendsContextClickCommand() + public function testPerformSendsContextClickCommand(): void { $coords = $this->getMockBuilder(WebDriverCoordinates::class) ->disableOriginalConstructor()->getMock(); $this->webDriverMouse->expects($this->once())->method('contextClick')->with($coords); - $this->locationProvider->expects($this->once())->method('getCoordinates')->will($this->returnValue($coords)); + $this->locationProvider->expects($this->once())->method('getCoordinates')->willReturn($coords); $this->webDriverContextClickAction->perform(); } } diff --git a/tests/unit/Interactions/Internal/WebDriverCoordinatesTest.php b/tests/unit/Interactions/Internal/WebDriverCoordinatesTest.php index d782edf8c..f8faee8d8 100644 --- a/tests/unit/Interactions/Internal/WebDriverCoordinatesTest.php +++ b/tests/unit/Interactions/Internal/WebDriverCoordinatesTest.php @@ -1,46 +1,25 @@ -getAuxiliary()); + $this->assertEquals(new WebDriverPoint(0, 0), $webDriverCoordinates->inViewPort()); + $this->assertEquals(new WebDriverPoint(10, 10), $webDriverCoordinates->onPage()); + $this->assertSame('auxiliary', $webDriverCoordinates->getAuxiliary()); } } diff --git a/tests/unit/Interactions/Internal/WebDriverDoubleClickActionTest.php b/tests/unit/Interactions/Internal/WebDriverDoubleClickActionTest.php index acd74550f..d4ae699f1 100644 --- a/tests/unit/Interactions/Internal/WebDriverDoubleClickActionTest.php +++ b/tests/unit/Interactions/Internal/WebDriverDoubleClickActionTest.php @@ -1,33 +1,21 @@ -webDriverMouse = $this->getMockBuilder(WebDriverMouse::class)->getMock(); $this->locationProvider = $this->getMockBuilder(WebDriverLocatable::class)->getMock(); @@ -37,12 +25,12 @@ public function setUp() ); } - public function testPerformSendsDoubleClickCommand() + public function testPerformSendsDoubleClickCommand(): void { $coords = $this->getMockBuilder(WebDriverCoordinates::class) ->disableOriginalConstructor()->getMock(); $this->webDriverMouse->expects($this->once())->method('doubleClick')->with($coords); - $this->locationProvider->expects($this->once())->method('getCoordinates')->will($this->returnValue($coords)); + $this->locationProvider->expects($this->once())->method('getCoordinates')->willReturn($coords); $this->webDriverDoubleClickAction->perform(); } } diff --git a/tests/unit/Interactions/Internal/WebDriverKeyDownActionTest.php b/tests/unit/Interactions/Internal/WebDriverKeyDownActionTest.php index 267b58b3e..0fd8aa9dd 100644 --- a/tests/unit/Interactions/Internal/WebDriverKeyDownActionTest.php +++ b/tests/unit/Interactions/Internal/WebDriverKeyDownActionTest.php @@ -1,54 +1,43 @@ -webDriverKeyboard = $this->getMockBuilder(WebDriverKeyboard::class)->getMock(); - $this->webDriverMouse = $this->getMockBuilder(WebDriverMouse::class)->getMock(); - $this->locationProvider = $this->getMockBuilder(WebDriverLocatable::class)->getMock(); + $this->webDriverKeyboard = $this->createMock(WebDriverKeyboard::class); + $this->webDriverMouse = $this->createMock(WebDriverMouse::class); + $this->locationProvider = $this->createMock(WebDriverLocatable::class); $this->webDriverKeyDownAction = new WebDriverKeyDownAction( $this->webDriverKeyboard, $this->webDriverMouse, - $this->locationProvider + $this->locationProvider, + WebDriverKeys::LEFT_SHIFT ); } - public function testPerformFocusesOnElementAndSendPressKeyCommand() + public function testPerformFocusesOnElementAndSendPressKeyCommand(): void { - $coords = $this->getMockBuilder(WebDriverCoordinates::class) - ->disableOriginalConstructor()->getMock(); + $coords = $this->createMock(WebDriverCoordinates::class); $this->webDriverMouse->expects($this->once())->method('click')->with($coords); - $this->locationProvider->expects($this->once())->method('getCoordinates')->will($this->returnValue($coords)); + $this->locationProvider->expects($this->once())->method('getCoordinates')->willReturn($coords); $this->webDriverKeyboard->expects($this->once())->method('pressKey'); $this->webDriverKeyDownAction->perform(); } diff --git a/tests/unit/Interactions/Internal/WebDriverKeyUpActionTest.php b/tests/unit/Interactions/Internal/WebDriverKeyUpActionTest.php index 970a35b0f..f10d53b09 100644 --- a/tests/unit/Interactions/Internal/WebDriverKeyUpActionTest.php +++ b/tests/unit/Interactions/Internal/WebDriverKeyUpActionTest.php @@ -1,36 +1,25 @@ -webDriverKeyboard = $this->getMockBuilder(WebDriverKeyboard::class)->getMock(); $this->webDriverMouse = $this->getMockBuilder(WebDriverMouse::class)->getMock(); @@ -40,17 +29,16 @@ public function setUp() $this->webDriverKeyboard, $this->webDriverMouse, $this->locationProvider, - 'a' + WebDriverKeys::LEFT_SHIFT ); } - public function testPerformFocusesOnElementAndSendPressKeyCommand() + public function testPerformFocusesOnElementAndSendPressKeyCommand(): void { - $coords = $this->getMockBuilder(WebDriverCoordinates::class) - ->disableOriginalConstructor()->getMock(); + $coords = $this->createMock(WebDriverCoordinates::class); $this->webDriverMouse->expects($this->once())->method('click')->with($coords); - $this->locationProvider->expects($this->once())->method('getCoordinates')->will($this->returnValue($coords)); - $this->webDriverKeyboard->expects($this->once())->method('releaseKey')->with('a'); + $this->locationProvider->expects($this->once())->method('getCoordinates')->willReturn($coords); + $this->webDriverKeyboard->expects($this->once())->method('releaseKey')->with(WebDriverKeys::LEFT_SHIFT); $this->webDriverKeyUpAction->perform(); } } diff --git a/tests/unit/Interactions/Internal/WebDriverMouseMoveActionTest.php b/tests/unit/Interactions/Internal/WebDriverMouseMoveActionTest.php index 565530ad8..f23594852 100644 --- a/tests/unit/Interactions/Internal/WebDriverMouseMoveActionTest.php +++ b/tests/unit/Interactions/Internal/WebDriverMouseMoveActionTest.php @@ -1,33 +1,21 @@ -webDriverMouse = $this->getMockBuilder(WebDriverMouse::class)->getMock(); $this->locationProvider = $this->getMockBuilder(WebDriverLocatable::class)->getMock(); @@ -38,12 +26,12 @@ public function setUp() ); } - public function testPerformFocusesOnElementAndSendPressKeyCommand() + public function testPerformFocusesOnElementAndSendPressKeyCommand(): void { $coords = $this->getMockBuilder(WebDriverCoordinates::class) ->disableOriginalConstructor()->getMock(); $this->webDriverMouse->expects($this->once())->method('mouseMove')->with($coords); - $this->locationProvider->expects($this->once())->method('getCoordinates')->will($this->returnValue($coords)); + $this->locationProvider->expects($this->once())->method('getCoordinates')->willReturn($coords); $this->webDriverMouseMoveAction->perform(); } } diff --git a/tests/unit/Interactions/Internal/WebDriverMouseToOffsetActionTest.php b/tests/unit/Interactions/Internal/WebDriverMouseToOffsetActionTest.php index d2d5286a1..966b88325 100644 --- a/tests/unit/Interactions/Internal/WebDriverMouseToOffsetActionTest.php +++ b/tests/unit/Interactions/Internal/WebDriverMouseToOffsetActionTest.php @@ -1,35 +1,23 @@ -webDriverMouse = $this->getMockBuilder(WebDriverMouse::class)->getMock(); $this->locationProvider = $this->getMockBuilder(WebDriverLocatable::class)->getMock(); @@ -42,12 +30,12 @@ public function setUp() ); } - public function testPerformFocusesOnElementAndSendPressKeyCommand() + public function testPerformFocusesOnElementAndSendPressKeyCommand(): void { $coords = $this->getMockBuilder(WebDriverCoordinates::class) ->disableOriginalConstructor()->getMock(); $this->webDriverMouse->expects($this->once())->method('mouseMove')->with($coords, 150, 200); - $this->locationProvider->expects($this->once())->method('getCoordinates')->will($this->returnValue($coords)); + $this->locationProvider->expects($this->once())->method('getCoordinates')->willReturn($coords); $this->webDriverMoveToOffsetAction->perform(); } } diff --git a/tests/unit/Interactions/Internal/WebDriverSendKeysActionTest.php b/tests/unit/Interactions/Internal/WebDriverSendKeysActionTest.php index 9b06b3df7..b610d083f 100644 --- a/tests/unit/Interactions/Internal/WebDriverSendKeysActionTest.php +++ b/tests/unit/Interactions/Internal/WebDriverSendKeysActionTest.php @@ -1,38 +1,26 @@ -webDriverKeyboard = $this->getMockBuilder(WebDriverKeyboard::class)->getMock(); $this->webDriverMouse = $this->getMockBuilder(WebDriverMouse::class)->getMock(); @@ -47,13 +35,13 @@ public function setUp() ); } - public function testPerformFocusesOnElementAndSendPressKeyCommand() + public function testPerformFocusesOnElementAndSendPressKeyCommand(): void { $coords = $this->getMockBuilder(WebDriverCoordinates::class) ->disableOriginalConstructor()->getMock(); $this->webDriverKeyboard->expects($this->once())->method('sendKeys')->with($this->keys); $this->webDriverMouse->expects($this->once())->method('click')->with($coords); - $this->locationProvider->expects($this->once())->method('getCoordinates')->will($this->returnValue($coords)); + $this->locationProvider->expects($this->once())->method('getCoordinates')->willReturn($coords); $this->webDriverSendKeysAction->perform(); } } diff --git a/tests/unit/Interactions/Internal/WebDriverSingleKeyActionTest.php b/tests/unit/Interactions/Internal/WebDriverSingleKeyActionTest.php new file mode 100644 index 000000000..9a3e61160 --- /dev/null +++ b/tests/unit/Interactions/Internal/WebDriverSingleKeyActionTest.php @@ -0,0 +1,32 @@ +expectException(LogicException::class); + $this->expectExceptionMessage( + 'keyDown / keyUp actions can only be used for modifier keys, but "foo" was given' + ); + + new WebDriverKeyUpAction( + $this->createMock(WebDriverKeyboard::class), + $this->createMock(WebDriverMouse::class), + $this->createMock(WebDriverLocatable::class), + 'foo' + ); + + $this->assertTrue(true); // To generate coverage, see https://github.com/sebastianbergmann/phpunit/issues/3016 + } +} diff --git a/tests/unit/Remote/CustomWebDriverCommandTest.php b/tests/unit/Remote/CustomWebDriverCommandTest.php new file mode 100644 index 000000000..8e829c615 --- /dev/null +++ b/tests/unit/Remote/CustomWebDriverCommandTest.php @@ -0,0 +1,38 @@ + 'bar'] + ); + + $this->assertSame('/some-url', $command->getCustomUrl()); + $this->assertSame('POST', $command->getCustomMethod()); + } + + public function testCustomCommandInvalidUrlExceptionInit(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('URL of custom command has to start with / but is "url-without-leading-slash"'); + + new CustomWebDriverCommand('session-id-123', 'url-without-leading-slash', 'POST', []); + } + + public function testCustomCommandInvalidMethodExceptionInit(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Invalid custom method "invalid-method", must be one of [GET, POST]'); + + new CustomWebDriverCommand('session-id-123', '/some-url', 'invalid-method', []); + } +} diff --git a/tests/unit/Remote/DesiredCapabilitiesTest.php b/tests/unit/Remote/DesiredCapabilitiesTest.php index dacdcd139..e57b2a4d3 100644 --- a/tests/unit/Remote/DesiredCapabilitiesTest.php +++ b/tests/unit/Remote/DesiredCapabilitiesTest.php @@ -1,28 +1,18 @@ - 'fooVal', WebDriverCapabilityType::PLATFORM => WebDriverPlatform::ANY] @@ -37,7 +27,7 @@ public function testShouldInstantiateWithCapabilitiesGivenInConstructor() ); } - public function testShouldInstantiateEmptyInstance() + public function testShouldInstantiateEmptyInstance(): void { $capabilities = new DesiredCapabilities(); @@ -45,7 +35,7 @@ public function testShouldInstantiateEmptyInstance() $this->assertSame([], $capabilities->toArray()); } - public function testShouldProvideAccessToCapabilitiesUsingSettersAndGetters() + public function testShouldProvideAccessToCapabilitiesUsingSettersAndGetters(): void { $capabilities = new DesiredCapabilities(); // generic capability setter @@ -61,18 +51,32 @@ public function testShouldProvideAccessToCapabilitiesUsingSettersAndGetters() $this->assertSame(333, $capabilities->getVersion()); } - /** - * @expectedException \Exception - * @expectedExceptionMessage isJavascriptEnable() is a htmlunit-only option - */ - public function testShouldNotAllowToDisableJavascriptForNonHtmlUnitBrowser() + public function testShouldAccessCapabilitiesIsser(): void { + $capabilities = new DesiredCapabilities(); + + $capabilities->setCapability('custom', 1337); + $capabilities->setCapability('customBooleanTrue', true); + $capabilities->setCapability('customBooleanFalse', false); + $capabilities->setCapability('customNull', null); + + $this->assertTrue($capabilities->is('custom')); + $this->assertTrue($capabilities->is('customBooleanTrue')); + $this->assertFalse($capabilities->is('customBooleanFalse')); + $this->assertFalse($capabilities->is('customNull')); + } + + public function testShouldNotAllowToDisableJavascriptForNonHtmlUnitBrowser(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('isJavascriptEnabled() is a htmlunit-only option'); + $capabilities = new DesiredCapabilities(); $capabilities->setBrowserName(WebDriverBrowserType::FIREFOX); $capabilities->setJavascriptEnabled(false); } - public function testShouldAllowToDisableJavascriptForHtmlUnitBrowser() + public function testShouldAllowToDisableJavascriptForHtmlUnitBrowser(): void { $capabilities = new DesiredCapabilities(); $capabilities->setBrowserName(WebDriverBrowserType::HTMLUNIT); @@ -82,16 +86,13 @@ public function testShouldAllowToDisableJavascriptForHtmlUnitBrowser() } /** - * @dataProvider browserCapabilitiesProvider - * @param string $setupMethod - * @param string $expectedBrowser - * @param string $expectedPlatform + * @dataProvider provideBrowserCapabilities */ public function testShouldProvideShortcutSetupForCapabilitiesOfEachBrowser( - $setupMethod, - $expectedBrowser, - $expectedPlatform - ) { + string $setupMethod, + string $expectedBrowser, + string $expectedPlatform + ): void { /** @var DesiredCapabilities $capabilities */ $capabilities = call_user_func([DesiredCapabilities::class, $setupMethod]); @@ -100,9 +101,9 @@ public function testShouldProvideShortcutSetupForCapabilitiesOfEachBrowser( } /** - * @return array + * @return array[] */ - public function browserCapabilitiesProvider() + public function provideBrowserCapabilities(): array { return [ ['android', WebDriverBrowserType::ANDROID, WebDriverPlatform::ANDROID], @@ -120,14 +121,199 @@ public function browserCapabilitiesProvider() ]; } - public function testShouldSetupFirefoxProfileAndDisableReaderViewForFirefoxBrowser() + public function testShouldSetupFirefoxWithDefaultOptions(): void { + $capabilitiesArray = DesiredCapabilities::firefox()->toArray(); + + $this->assertSame('firefox', $capabilitiesArray['browserName']); + $this->assertSame( + [ + 'prefs' => FirefoxOptionsTest::EXPECTED_DEFAULT_PREFS, + ], + $capabilitiesArray['moz:firefoxOptions'] + ); + } + + public function testShouldSetupFirefoxWithCustomOptions(): void + { + $firefoxOptions = new FirefoxOptions(); + $firefoxOptions->addArguments(['-headless']); + $firefoxOptions->setOption('binary', '/foo/bar/firefox'); + $capabilities = DesiredCapabilities::firefox(); + $capabilities->setCapability(FirefoxOptions::CAPABILITY, $firefoxOptions); + + $capabilitiesArray = $capabilities->toArray(); - /** @var FirefoxProfile $firefoxProfile */ - $firefoxProfile = $capabilities->getCapability(FirefoxDriver::PROFILE); - $this->assertInstanceOf(FirefoxProfile::class, $firefoxProfile); + $this->assertSame('firefox', $capabilitiesArray['browserName']); + $this->assertSame( + [ + 'binary' => '/foo/bar/firefox', + 'args' => ['-headless'], + 'prefs' => FirefoxOptionsTest::EXPECTED_DEFAULT_PREFS, + ], + $capabilitiesArray['moz:firefoxOptions'] + ); + } - $this->assertSame('false', $firefoxProfile->getPreference(FirefoxPreferences::READER_PARSE_ON_LOAD_ENABLED)); + public function testShouldNotOverwriteDefaultFirefoxOptionsWhenAddingFirefoxOptionAsArray(): void + { + $capabilities = DesiredCapabilities::firefox(); + $capabilities->setCapability('moz:firefoxOptions', ['args' => ['-headless']]); + + $this->assertSame( + [ + 'prefs' => FirefoxOptionsTest::EXPECTED_DEFAULT_PREFS, + 'args' => ['-headless'], + ], + $capabilities->toArray()['moz:firefoxOptions'] + ); + } + + /** + * @dataProvider provideW3cCapabilities + */ + public function testShouldConvertCapabilitiesToW3cCompatible( + DesiredCapabilities $inputJsonWireCapabilities, + array $expectedW3cCapabilities + ): void { + $this->assertJsonStringEqualsJsonString( + json_encode($expectedW3cCapabilities, JSON_THROW_ON_ERROR), + json_encode($inputJsonWireCapabilities->toW3cCompatibleArray(), JSON_THROW_ON_ERROR) + ); + } + + /** + * @return array[] + */ + public function provideW3cCapabilities(): array + { + $chromeOptions = new ChromeOptions(); + $chromeOptions->addArguments(['--headless']); + + $firefoxOptions = new FirefoxOptions(); + $firefoxOptions->addArguments(['-headless']); + + $firefoxProfileEncoded = (new FirefoxProfile())->encode(); + + return [ + 'changed name' => [ + new DesiredCapabilities([ + WebDriverCapabilityType::BROWSER_NAME => WebDriverBrowserType::CHROME, + WebDriverCapabilityType::VERSION => '67.0.1', + WebDriverCapabilityType::PLATFORM => WebDriverPlatform::LINUX, + WebDriverCapabilityType::ACCEPT_SSL_CERTS => true, + ]), + [ + 'browserName' => 'chrome', + 'browserVersion' => '67.0.1', + 'platformName' => 'linux', + 'acceptInsecureCerts' => true, + ], + ], + 'removed capabilities' => [ + new DesiredCapabilities([ + WebDriverCapabilityType::WEB_STORAGE_ENABLED => true, + WebDriverCapabilityType::TAKES_SCREENSHOT => false, + ]), + [], + ], + 'custom invalid capability should be removed' => [ + new DesiredCapabilities([ + 'customInvalidCapability' => 'shouldBeRemoved', + ]), + [], + ], + 'already W3C capabilities' => [ + new DesiredCapabilities([ + 'pageLoadStrategy' => 'eager', + 'strictFileInteractability' => false, + ]), + [ + 'pageLoadStrategy' => 'eager', + 'strictFileInteractability' => false, + ], + ], + '"ANY" platform should be completely removed' => [ + new DesiredCapabilities([ + WebDriverCapabilityType::PLATFORM => WebDriverPlatform::ANY, + ]), + [], + ], + 'custom vendor extension' => [ + new DesiredCapabilities([ + 'vendor:prefix' => 'vendor extension should be kept', + ]), + [ + 'vendor:prefix' => 'vendor extension should be kept', + ], + ], + 'chromeOptions should be an object if empty' => [ + new DesiredCapabilities([ + ChromeOptions::CAPABILITY => new ChromeOptions(), + ]), + [ + 'goog:chromeOptions' => new \ArrayObject(), + ], + ], + 'chromeOptions should be converted' => [ + new DesiredCapabilities([ + ChromeOptions::CAPABILITY => $chromeOptions, + ]), + [ + 'goog:chromeOptions' => new \ArrayObject( + [ + 'args' => ['--headless'], + ] + ), + ], + ], + 'chromeOptions as W3C capability should be converted' => [ + new DesiredCapabilities([ + ChromeOptions::CAPABILITY_W3C => $chromeOptions, + ]), + [ + 'goog:chromeOptions' => new \ArrayObject( + [ + 'args' => ['--headless'], + ] + ), + ], + ], + 'firefox_profile should be converted' => [ + new DesiredCapabilities([ + FirefoxDriver::PROFILE => $firefoxProfileEncoded, + ]), + [ + 'moz:firefoxOptions' => [ + 'profile' => $firefoxProfileEncoded, + ], + ], + ], + 'firefox_profile should not be overwritten if already present' => [ + new DesiredCapabilities([ + FirefoxDriver::PROFILE => $firefoxProfileEncoded, + FirefoxOptions::CAPABILITY => ['profile' => 'w3cProfile'], + ]), + [ + 'moz:firefoxOptions' => [ + 'profile' => 'w3cProfile', + ], + ], + ], + 'firefox_profile should be merged with moz:firefoxOptions if they already exists' => [ + new DesiredCapabilities([ + FirefoxDriver::PROFILE => $firefoxProfileEncoded, + FirefoxOptions::CAPABILITY => $firefoxOptions, + ]), + [ + 'moz:firefoxOptions' => [ + 'profile' => $firefoxProfileEncoded, + 'args' => ['-headless'], + 'prefs' => FirefoxOptionsTest::EXPECTED_DEFAULT_PREFS, + ], + ], + ], + ]; } } diff --git a/tests/unit/Remote/HttpCommandExecutorTest.php b/tests/unit/Remote/HttpCommandExecutorTest.php index 268b3e873..b29d788b1 100644 --- a/tests/unit/Remote/HttpCommandExecutorTest.php +++ b/tests/unit/Remote/HttpCommandExecutorTest.php @@ -1,50 +1,55 @@ -executor = new HttpCommandExecutor('http://localhost:4444/'); } /** - * @dataProvider commandProvider - * @param int $command - * @param array $params - * @param string $expectedUrl - * @param string $expectedPostData + * @dataProvider provideCommand */ - public function testShouldSendRequestToAssembledUrl($command, array $params, $expectedUrl, $expectedPostData) - { - $command = new WebDriverCommand('foo-123', $command, $params); + public function testShouldSendRequestToAssembledUrl( + WebDriverCommand $command, + bool $shouldResetExpectHeader, + string $expectedUrl, + ?string $expectedPostData + ): void { + $expectedCurlSetOptCalls = [ + [$this->anything(), CURLOPT_URL, $expectedUrl], + [$this->anything()], + ]; - $curlSetoptMock = $this->getFunctionMock(__NAMESPACE__, 'curl_setopt'); - $curlSetoptMock->expects($this->at(0)) - ->with($this->anything(), CURLOPT_URL, $expectedUrl); + if ($shouldResetExpectHeader) { + $expectedCurlSetOptCalls[] = [ + $this->anything(), + CURLOPT_HTTPHEADER, + ['Content-Type: application/json;charset=UTF-8', 'Accept: application/json', 'Expect:'], + ]; + } else { + $expectedCurlSetOptCalls[] = [ + $this->anything(), + CURLOPT_HTTPHEADER, + ['Content-Type: application/json;charset=UTF-8', 'Accept: application/json'], + ]; + } - $curlSetoptMock->expects($this->at(2)) - ->with($this->anything(), CURLOPT_POSTFIELDS, $expectedPostData); + $expectedCurlSetOptCalls[] = [$this->anything(), CURLOPT_POSTFIELDS, $expectedPostData]; + + $curlSetoptMock = $this->getFunctionMock(__NAMESPACE__, 'curl_setopt'); + $curlSetoptMock->expects($this->exactly(4)) + ->withConsecutive(...$expectedCurlSetOptCalls); $curlExecMock = $this->getFunctionMock(__NAMESPACE__, 'curl_exec'); $curlExecMock->expects($this->once()) @@ -56,39 +61,65 @@ public function testShouldSendRequestToAssembledUrl($command, array $params, $ex /** * @return array[] */ - public function commandProvider() + public function provideCommand(): array { return [ 'POST command having :id placeholder in url' => [ - DriverCommand::SEND_KEYS_TO_ELEMENT, - ['value' => 'submitted-value', ':id' => '1337'], - 'http://localhost:4444/session/foo-123/element/1337/value', + new WebDriverCommand( + 'fooSession', + DriverCommand::SEND_KEYS_TO_ELEMENT, + ['value' => 'submitted-value', ':id' => '1337'] + ), + true, + 'http://localhost:4444/session/fooSession/element/1337/value', '{"value":"submitted-value"}', ], 'POST command without :id placeholder in url' => [ - DriverCommand::TOUCH_UP, - ['x' => 3, 'y' => 6], - 'http://localhost:4444/session/foo-123/touch/up', + new WebDriverCommand('fooSession', DriverCommand::TOUCH_UP, ['x' => 3, 'y' => 6]), + true, + 'http://localhost:4444/session/fooSession/touch/up', '{"x":3,"y":6}', ], 'Extra useless placeholder parameter should be removed' => [ - DriverCommand::TOUCH_UP, - ['x' => 3, 'y' => 6, ':useless' => 'foo'], - 'http://localhost:4444/session/foo-123/touch/up', + new WebDriverCommand('fooSession', DriverCommand::TOUCH_UP, ['x' => 3, 'y' => 6, ':useless' => 'foo']), + true, + 'http://localhost:4444/session/fooSession/touch/up', '{"x":3,"y":6}', ], 'DELETE command' => [ - DriverCommand::DELETE_COOKIE, - [':name' => 'cookie-name'], - 'http://localhost:4444/session/foo-123/cookie/cookie-name', + new WebDriverCommand('fooSession', DriverCommand::DELETE_COOKIE, [':name' => 'cookie-name']), + false, + 'http://localhost:4444/session/fooSession/cookie/cookie-name', null, ], 'GET command without session in URL' => [ - DriverCommand::GET_ALL_SESSIONS, - [], + new WebDriverCommand('fooSession', DriverCommand::GET_ALL_SESSIONS, []), + false, 'http://localhost:4444/sessions', null, ], + 'Custom GET command' => [ + new CustomWebDriverCommand( + 'fooSession', + '/session/:sessionId/custom-command/:someParameter', + 'GET', + [':someParameter' => 'someValue'] + ), + false, + 'http://localhost:4444/session/fooSession/custom-command/someValue', + null, + ], + 'Custom POST command' => [ + new CustomWebDriverCommand( + 'fooSession', + '/session/:sessionId/custom-post-command/', + 'POST', + ['someParameter' => 'someValue'] + ), + true, + 'http://localhost:4444/session/fooSession/custom-post-command/', + '{"someParameter":"someValue"}', + ], ]; } } diff --git a/tests/unit/Remote/LocalFileDetectorTest.php b/tests/unit/Remote/LocalFileDetectorTest.php new file mode 100644 index 000000000..dc6112636 --- /dev/null +++ b/tests/unit/Remote/LocalFileDetectorTest.php @@ -0,0 +1,24 @@ +getLocalFile(__DIR__ . '/./' . basename(__FILE__)); + + $this->assertSame(__FILE__, $file); + } + + public function testShouldReturnNullIfFileNotDetected(): void + { + $detector = new LocalFileDetector(); + + $this->assertNull($detector->getLocalFile('/this/is/not/a/file')); + } +} diff --git a/tests/unit/Remote/RemoteWebDriverTest.php b/tests/unit/Remote/RemoteWebDriverTest.php new file mode 100644 index 000000000..666ac9e5c --- /dev/null +++ b/tests/unit/Remote/RemoteWebDriverTest.php @@ -0,0 +1,134 @@ +driver = RemoteWebDriver::createBySessionID( + 'session-id', + 'http://foo.bar:4444/', + null, + null, + true, + new DesiredCapabilities([]) + ); + } + + /** + * @covers ::manage + */ + public function testShouldCreateWebDriverOptionsInstance(): void + { + $wait = $this->driver->manage(); + + $this->assertInstanceOf(WebDriverOptions::class, $wait); + } + + /** + * @covers ::navigate + */ + public function testShouldCreateWebDriverNavigationInstance(): void + { + $wait = $this->driver->navigate(); + + $this->assertInstanceOf(WebDriverNavigation::class, $wait); + } + + /** + * @covers ::switchTo + */ + public function testShouldCreateRemoteTargetLocatorInstance(): void + { + $wait = $this->driver->switchTo(); + + $this->assertInstanceOf(RemoteTargetLocator::class, $wait); + } + + /** + * @covers ::getMouse + */ + public function testShouldCreateRemoteMouseInstance(): void + { + $wait = $this->driver->getMouse(); + + $this->assertInstanceOf(RemoteMouse::class, $wait); + } + + /** + * @covers ::getKeyboard + */ + public function testShouldCreateRemoteKeyboardInstance(): void + { + $wait = $this->driver->getKeyboard(); + + $this->assertInstanceOf(RemoteKeyboard::class, $wait); + } + + /** + * @covers ::getTouch + */ + public function testShouldCreateRemoteTouchScreenInstance(): void + { + $wait = $this->driver->getTouch(); + + $this->assertInstanceOf(RemoteTouchScreen::class, $wait); + } + + /** + * @covers ::action + */ + public function testShouldCreateWebDriverActionsInstance(): void + { + $wait = $this->driver->action(); + + $this->assertInstanceOf(WebDriverActions::class, $wait); + } + + /** + * @covers ::wait + */ + public function testShouldCreateWebDriverWaitInstance(): void + { + $wait = $this->driver->wait(15, 1337); + + $this->assertInstanceOf(WebDriverWait::class, $wait); + } + + /** + * @covers ::findElements + * @covers \Facebook\WebDriver\Exception\Internal\UnexpectedResponseException + */ + public function testShouldThrowExceptionOnUnexpectedValueFromRemoteEndWhenFindingElements(): void + { + $executorMock = $this->createMock(HttpCommandExecutor::class); + $executorMock->expects($this->once()) + ->method('execute') + ->with($this->isInstanceOf(WebDriverCommand::class)) + ->willReturn(new WebDriverResponse('session-id')); + + $this->driver->setCommandExecutor($executorMock); + + $this->expectException(UnexpectedResponseException::class); + $this->expectExceptionMessage('Server response to findElements command is not an array'); + $this->driver->findElements($this->createMock(WebDriverBy::class)); + } +} diff --git a/tests/unit/Remote/RemoteWebElementTest.php b/tests/unit/Remote/RemoteWebElementTest.php new file mode 100644 index 000000000..a7ec76d8a --- /dev/null +++ b/tests/unit/Remote/RemoteWebElementTest.php @@ -0,0 +1,25 @@ +createMock(RemoteExecuteMethod::class); + $element = new RemoteWebElement($executeMethod, 333); + + $this->assertSame(333, $element->getID()); + } +} diff --git a/tests/unit/Remote/WebDriverCommandTest.php b/tests/unit/Remote/WebDriverCommandTest.php index 9993c6ce5..182b72d94 100644 --- a/tests/unit/Remote/WebDriverCommandTest.php +++ b/tests/unit/Remote/WebDriverCommandTest.php @@ -1,23 +1,12 @@ - 'bar']); @@ -25,4 +14,13 @@ public function testShouldSetOptionsUsingConstructor() $this->assertSame('bar-baz-name', $command->getName()); $this->assertSame(['foo' => 'bar'], $command->getParameters()); } + + public function testShouldCreateNewSessionCommand(): void + { + $command = WebDriverCommand::newSession(['bar' => 'baz']); + + $this->assertNull($command->getSessionID()); + $this->assertSame('newSession', $command->getName()); + $this->assertSame(['bar' => 'baz'], $command->getParameters()); + } } diff --git a/tests/unit/Support/ScreenshotHelperTest.php b/tests/unit/Support/ScreenshotHelperTest.php new file mode 100644 index 000000000..5f7ff0ca6 --- /dev/null +++ b/tests/unit/Support/ScreenshotHelperTest.php @@ -0,0 +1,103 @@ +assertDirectoryDoesNotExist($directoryPath); + + $executorMock = $this->createMock(RemoteExecuteMethod::class); + $executorMock->expects($this->once()) + ->method('execute') + ->with($this->equalTo(DriverCommand::SCREENSHOT)) + ->willReturn(self::BLACK_PIXEL); + + $helper = new ScreenshotHelper($executorMock); + $output = $helper->takePageScreenshot($fullFilePath); + + $this->assertSame(base64_decode(self::BLACK_PIXEL, true), $output); + + $this->assertDirectoryExists($directoryPath); + $this->assertFileExists($fullFilePath); + + unlink($fullFilePath); + rmdir($directoryPath); + } + + public function testShouldOnlyReturnBase64IfDirectoryNotProvided(): void + { + $executorMock = $this->createMock(RemoteExecuteMethod::class); + $executorMock->expects($this->once()) + ->method('execute') + ->with($this->equalTo(DriverCommand::SCREENSHOT)) + ->willReturn(self::BLACK_PIXEL); + + $helper = new ScreenshotHelper($executorMock); + $output = $helper->takePageScreenshot(); + + $this->assertSame(base64_decode(self::BLACK_PIXEL, true), $output); + } + + public function testShouldSaveElementScreenshotToSubdirectoryIfNotExists(): void + { + $fullFilePath = sys_get_temp_dir() . '/' . uniqid('php-webdriver-', true) . '/screenshot.png'; + $directoryPath = dirname($fullFilePath); + $this->assertDirectoryDoesNotExist($directoryPath); + $elementId = 'foo-id'; + + $executorMock = $this->createMock(RemoteExecuteMethod::class); + $executorMock->expects($this->once()) + ->method('execute') + ->with(DriverCommand::TAKE_ELEMENT_SCREENSHOT, [':id' => $elementId]) + ->willReturn(self::BLACK_PIXEL); + + $helper = new ScreenshotHelper($executorMock); + $output = $helper->takeElementScreenshot($elementId, $fullFilePath); + + $this->assertSame(base64_decode(self::BLACK_PIXEL, true), $output); + $this->assertDirectoryExists($directoryPath); + $this->assertFileExists($fullFilePath); + + unlink($fullFilePath); + rmdir($directoryPath); + } + + /** + * @dataProvider provideInvalidData + * @param mixed $data + */ + public function testShouldThrowExceptionWhenInvalidDataReceived($data, string $expectedExceptionMessage): void + { + $executorMock = $this->createMock(RemoteExecuteMethod::class); + $executorMock->expects($this->once()) + ->method('execute') + ->with($this->equalTo(DriverCommand::SCREENSHOT)) + ->willReturn($data); + + $helper = new ScreenshotHelper($executorMock); + + $this->expectException(UnexpectedResponseException::class); + $this->expectExceptionMessage($expectedExceptionMessage); + $helper->takePageScreenshot(); + } + + public function provideInvalidData(): array + { + return [ + 'empty response' => [null, 'Error taking screenshot, no data received from the remote end'], + 'not valid base64 response' => ['invalid%base64', 'Error decoding screenshot data'], + ]; + } +} diff --git a/tests/unit/Support/XPathEscaperTest.php b/tests/unit/Support/XPathEscaperTest.php index 21ac4aea0..9804d93e1 100644 --- a/tests/unit/Support/XPathEscaperTest.php +++ b/tests/unit/Support/XPathEscaperTest.php @@ -1,28 +1,15 @@ - ['', "''"], diff --git a/tests/unit/WebDriverExpectedConditionTest.php b/tests/unit/WebDriverExpectedConditionTest.php index 93d98b392..4ac75336b 100644 --- a/tests/unit/WebDriverExpectedConditionTest.php +++ b/tests/unit/WebDriverExpectedConditionTest.php @@ -1,17 +1,4 @@ -driverMock = $this - ->getMockBuilder(RemoteWebDriver::class) - ->disableOriginalConstructor() - ->getMock(); + $this->driverMock = $this->createMock(RemoteWebDriver::class); $this->wait = new WebDriverWait($this->driverMock, 1, 1); } - public function testShouldDetectTitleIsCondition() + public function testShouldDetectTitleIsCondition(): void { $this->driverMock->expects($this->any()) ->method('getTitle') @@ -54,7 +38,7 @@ public function testShouldDetectTitleIsCondition() $this->assertTrue(call_user_func($condition->getApply(), $this->driverMock)); } - public function testShouldDetectTitleContainsCondition() + public function testShouldDetectTitleContainsCondition(): void { $this->driverMock->expects($this->any()) ->method('getTitle') @@ -67,7 +51,7 @@ public function testShouldDetectTitleContainsCondition() $this->assertTrue(call_user_func($condition->getApply(), $this->driverMock)); } - public function testShouldDetectTitleMatchesCondition() + public function testShouldDetectTitleMatchesCondition(): void { $this->driverMock->expects($this->any()) ->method('getTitle') @@ -80,7 +64,7 @@ public function testShouldDetectTitleMatchesCondition() $this->assertTrue(call_user_func($condition->getApply(), $this->driverMock)); } - public function testShouldDetectUrlIsCondition() + public function testShouldDetectUrlIsCondition(): void { $this->driverMock->expects($this->any()) ->method('getCurrentURL') @@ -93,7 +77,7 @@ public function testShouldDetectUrlIsCondition() $this->assertTrue(call_user_func($condition->getApply(), $this->driverMock)); } - public function testShouldDetectUrlContainsCondition() + public function testShouldDetectUrlContainsCondition(): void { $this->driverMock->expects($this->any()) ->method('getCurrentURL') @@ -106,7 +90,7 @@ public function testShouldDetectUrlContainsCondition() $this->assertTrue(call_user_func($condition->getApply(), $this->driverMock)); } - public function testShouldDetectUrlMatchesCondition() + public function testShouldDetectUrlMatchesCondition(): void { $this->driverMock->expects($this->any()) ->method('getCurrentURL') @@ -119,89 +103,193 @@ public function testShouldDetectUrlMatchesCondition() $this->assertTrue(call_user_func($condition->getApply(), $this->driverMock)); } - public function testShouldDetectPresenceOfElementLocatedCondition() + public function testShouldDetectPresenceOfElementLocatedCondition(): void { $element = new RemoteWebElement(new RemoteExecuteMethod($this->driverMock), 'id'); - $this->driverMock->expects($this->at(0)) + $this->driverMock->expects($this->exactly(2)) ->method('findElement') ->with($this->isInstanceOf(WebDriverBy::class)) - ->willThrowException(new NoSuchElementException('')); - - $this->driverMock->expects($this->at(1)) - ->method('findElement') - ->with($this->isInstanceOf(WebDriverBy::class)) - ->willReturn($element); + ->willReturnOnConsecutiveCalls( + $this->throwException(new NoSuchElementException('')), + $element + ); $condition = WebDriverExpectedCondition::presenceOfElementLocated(WebDriverBy::cssSelector('.foo')); $this->assertSame($element, $this->wait->until($condition)); } - public function testShouldDetectPresenceOfAllElementsLocatedByCondition() + public function testShouldDetectNotPresenceOfElementLocatedCondition(): void { - $element = $this->createRemoteWebElementMock(); + $element = new RemoteWebElement(new RemoteExecuteMethod($this->driverMock), 'id'); - $this->driverMock->expects($this->at(0)) - ->method('findElements') + $this->driverMock->expects($this->exactly(2)) + ->method('findElement') ->with($this->isInstanceOf(WebDriverBy::class)) - ->willReturn([]); + ->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)); + } - $this->driverMock->expects($this->at(1)) + public function testShouldDetectPresenceOfAllElementsLocatedByCondition(): void + { + $element = $this->createMock(RemoteWebElement::class); + + $this->driverMock->expects($this->exactly(2)) ->method('findElements') ->with($this->isInstanceOf(WebDriverBy::class)) - ->willReturn([$element]); + ->willReturnOnConsecutiveCalls( + [], + [$element] + ); $condition = WebDriverExpectedCondition::presenceOfAllElementsLocatedBy(WebDriverBy::cssSelector('.foo')); $this->assertSame([$element], $this->wait->until($condition)); } - public function testShouldDetectVisibilityOfElementLocatedCondition() + 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 true and condition will match + // Call #4: return Element, isDisplayed will return true and condition will match - $element = $this->createRemoteWebElementMock(); - $element->expects($this->at(0)) + $element = $this->createMock(RemoteWebElement::class); + $element->expects($this->exactly(3)) ->method('isDisplayed') - ->willThrowException(new StaleElementReferenceException('')); + ->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), + ]; - $element->expects($this->at(1)) + $elementList[0]->expects($this->once()) ->method('isDisplayed') ->willReturn(false); - $element->expects($this->at(2)) + $elementList[1]->expects($this->once()) ->method('isDisplayed') ->willReturn(true); - $this->setupDriverToReturnElementAfterAnException($element, 4); + $elementList[2]->expects($this->once()) + ->method('isDisplayed') + ->willReturn(true); - $condition = WebDriverExpectedCondition::visibilityOfElementLocated(WebDriverBy::cssSelector('.foo')); + $this->driverMock->expects($this->once()) + ->method('findElements') + ->with($this->isInstanceOf(WebDriverBy::class)) + ->willReturn($elementList); - $this->assertSame($element, $this->wait->until($condition)); + $condition = WebDriverExpectedCondition::visibilityOfAnyElementLocated(WebDriverBy::cssSelector('.foo')); + + $this->assertSame([$elementList[1], $elementList[2]], $this->wait->until($condition)); } - public function testShouldDetectVisibilityOfCondition() + public function testShouldDetectInvisibilityOfElementLocatedConditionOnNoSuchElementException(): void { - $element = $this->createRemoteWebElementMock(); - $element->expects($this->at(0)) - ->method('isDisplayed') - ->willReturn(false); + $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->at(1)) + $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() + public function testShouldDetectElementTextContainsCondition(): void { // Set-up the consecutive calls to apply() as follows: // Call #1: throws NoSuchElementException @@ -209,27 +297,23 @@ public function testShouldDetectElementTextContainsCondition() // Call #3: return Element, but getText will throw StaleElementReferenceException // Call #4: return Element, getText will return new text and condition will match - $element = $this->createRemoteWebElementMock(); - $element->expects($this->at(0)) - ->method('getText') - ->willReturn('this is an old text'); - - $element->expects($this->at(1)) + $element = $this->createMock(RemoteWebElement::class); + $element->expects($this->exactly(3)) ->method('getText') - ->willThrowException(new StaleElementReferenceException('')); + ->willReturnOnConsecutiveCalls( + 'this is an old text', + $this->throwException(new StaleElementReferenceException('')), + 'this is a new text' + ); - $element->expects($this->at(2)) - ->method('getText') - ->willReturn('this is a new text'); - - $this->setupDriverToReturnElementAfterAnException($element, 4); + $this->setupDriverToReturnElementAfterAnException($element, 3); $condition = WebDriverExpectedCondition::elementTextContains(WebDriverBy::cssSelector('.foo'), 'new'); $this->assertTrue($this->wait->until($condition)); } - public function testShouldDetectElementTextIsCondition() + public function testShouldDetectElementTextIsCondition(): void { // Set-up the consecutive calls to apply() as follows: // Call #1: throws NoSuchElementException @@ -237,20 +321,16 @@ public function testShouldDetectElementTextIsCondition() // 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->createRemoteWebElementMock(); - $element->expects($this->at(0)) - ->method('getText') - ->willThrowException(new StaleElementReferenceException('')); - - $element->expects($this->at(1)) - ->method('getText') - ->willReturn('this is a new text, but not exactly'); - - $element->expects($this->at(2)) + $element = $this->createMock(RemoteWebElement::class); + $element->expects($this->exactly(3)) ->method('getText') - ->willReturn('this is a new text'); + ->willReturnOnConsecutiveCalls( + $this->throwException(new StaleElementReferenceException('')), + 'this is a new text, but not exactly', + 'this is a new text' + ); - $this->setupDriverToReturnElementAfterAnException($element, 4); + $this->setupDriverToReturnElementAfterAnException($element, 3); $condition = WebDriverExpectedCondition::elementTextIs( WebDriverBy::cssSelector('.foo'), @@ -260,7 +340,7 @@ public function testShouldDetectElementTextIsCondition() $this->assertTrue($this->wait->until($condition)); } - public function testShouldDetectElementTextMatchesCondition() + public function testShouldDetectElementTextMatchesCondition(): void { // Set-up the consecutive calls to apply() as follows: // Call #1: throws NoSuchElementException @@ -268,21 +348,17 @@ public function testShouldDetectElementTextMatchesCondition() // Call #3: return Element, getText will return not-matching text // Call #4: return Element, getText will return matching text - $element = $this->createRemoteWebElementMock(); + $element = $this->createMock(RemoteWebElement::class); - $element->expects($this->at(0)) + $element->expects($this->exactly(3)) ->method('getText') - ->willThrowException(new StaleElementReferenceException('')); + ->willReturnOnConsecutiveCalls( + $this->throwException(new StaleElementReferenceException('')), + 'non-matching', + 'matching-123' + ); - $element->expects($this->at(1)) - ->method('getText') - ->willReturn('non-matching'); - - $element->expects($this->at(2)) - ->method('getText') - ->willReturn('matching-123'); - - $this->setupDriverToReturnElementAfterAnException($element, 4); + $this->setupDriverToReturnElementAfterAnException($element, 3); $condition = WebDriverExpectedCondition::elementTextMatches( WebDriverBy::cssSelector('.foo'), @@ -292,7 +368,36 @@ public function testShouldDetectElementTextMatchesCondition() $this->assertTrue($this->wait->until($condition)); } - public function testShouldDetectNumberOfWindowsToBeCondition() + 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') @@ -305,35 +410,21 @@ public function testShouldDetectNumberOfWindowsToBeCondition() $this->assertTrue(call_user_func($condition->getApply(), $this->driverMock)); } - /** - * @param RemoteWebElement $element - * @param int $expectedNumberOfFindElementCalls - */ - private function setupDriverToReturnElementAfterAnException($element, $expectedNumberOfFindElementCalls) - { - $this->driverMock->expects($this->at(0)) - ->method('findElement') - ->with($this->isInstanceOf(WebDriverBy::class)) - ->willThrowException(new NoSuchElementException('')); + private function setupDriverToReturnElementAfterAnException( + RemoteWebElement $element, + int $expectedNumberOfFindElementCalls + ): void { + $consecutiveReturn = [ + $this->throwException(new NoSuchElementException('')), + ]; - for ($i = 1; $i < $expectedNumberOfFindElementCalls; $i++) { - $this->driverMock->expects($this->at($i)) - ->method('findElement') - ->with($this->isInstanceOf(WebDriverBy::class)) - ->willReturn($element); + for ($i = 0; $i < $expectedNumberOfFindElementCalls; $i++) { + $consecutiveReturn[] = $element; } - } - /** - * @todo Replace with createMock() once PHP 5.5 support is dropped - * @return \PHPUnit_Framework_MockObject_MockObject|RemoteWebElement - */ - private function createRemoteWebElementMock() - { - return $this->getMockBuilder(RemoteWebElement::class) - ->disableOriginalConstructor() - ->disableOriginalClone() - ->disableArgumentCloning() - ->getMock(); + $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" + } +}