├── .github ├── FUNDING.yml └── workflows │ └── ci.yml ├── .php-cs-fixer.dist.php ├── CHANGELOG.md ├── LICENSE ├── README.md ├── composer.json ├── panther.svg ├── phpunit.xml.dist.10 └── src ├── Client.php ├── Cookie └── CookieJar.php ├── DomCrawler ├── Crawler.php ├── Field │ ├── ChoiceFormField.php │ ├── FileFormField.php │ ├── FormFieldTrait.php │ ├── InputFormField.php │ └── TextareaFormField.php ├── Form.php ├── Image.php └── Link.php ├── Exception ├── ExceptionInterface.php ├── InvalidArgumentException.php ├── LogicException.php └── RuntimeException.php ├── ExceptionThrower.php ├── PantherTestCase.php ├── PantherTestCaseTrait.php ├── ProcessManager ├── BrowserManagerInterface.php ├── ChromeManager.php ├── FirefoxManager.php ├── SeleniumManager.php ├── WebServerManager.php └── WebServerReadinessProbeTrait.php ├── ServerExtension.php ├── ServerExtensionLegacy.php ├── ServerTrait.php ├── WebDriver ├── PantherWebDriverExpectedCondition.php ├── WebDriverCheckbox.php └── WebDriverMouse.php └── WebTestAssertionsTrait.php /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: https://www.panthera.org/donate 2 | tidelift: "packagist/symfony/panther" 3 | github: [dunglas] 4 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | php-cs-fixer: 9 | runs-on: ubuntu-latest 10 | name: Coding Standards 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | 15 | - name: Setup PHP 16 | uses: shivammathur/setup-php@v2 17 | with: 18 | php-version: '8.3' 19 | tools: php-cs-fixer, cs2pr 20 | 21 | - name: PHP Coding Standards Fixer 22 | run: php-cs-fixer fix --dry-run --format checkstyle | cs2pr 23 | 24 | phpstan: 25 | runs-on: ubuntu-latest 26 | name: Static Analysis 27 | steps: 28 | - name: Checkout 29 | uses: actions/checkout@v4 30 | 31 | - name: Setup PHP 32 | uses: shivammathur/setup-php@v2 33 | with: 34 | php-version: '8.4' 35 | tools: phpstan,flex 36 | extensions: zip 37 | 38 | - name: Install dependencies 39 | uses: ramsey/composer-install@v3 40 | env: 41 | SYMFONY_REQUIRE: ^7 42 | 43 | - name: Install PHPUnit dependencies 44 | run: vendor/bin/simple-phpunit --version 45 | 46 | - name: PHPStan 47 | run: phpstan analyse --no-progress 48 | 49 | phpunit: 50 | runs-on: ubuntu-latest 51 | strategy: 52 | matrix: 53 | php-versions: ['8.1', '8.2', '8.3', '8.4'] 54 | fail-fast: false 55 | name: PHP ${{ matrix.php-versions }} Test on ubuntu-latest 56 | steps: 57 | - name: Checkout 58 | uses: actions/checkout@v4 59 | 60 | - name: Setup PHP 61 | uses: shivammathur/setup-php@v2 62 | with: 63 | php-version: ${{ matrix.php-versions }} 64 | extensions: zip 65 | 66 | - name: Install dependencies 67 | uses: ramsey/composer-install@v3 68 | 69 | - name: Run tests 70 | run: vendor/bin/simple-phpunit 71 | 72 | phpunit-dev: 73 | runs-on: ubuntu-latest 74 | strategy: 75 | matrix: 76 | php-versions: ['8.1', '8.2', '8.3', '8.4'] 77 | fail-fast: false 78 | name: PHP ${{ matrix.php-versions }} Test dev dependencies on ubuntu-latest 79 | steps: 80 | - name: Checkout 81 | uses: actions/checkout@v4 82 | 83 | - name: Setup PHP 84 | uses: shivammathur/setup-php@v2 85 | with: 86 | php-version: ${{ matrix.php-versions }} 87 | extensions: zip 88 | 89 | - name: Allow dev dependencies 90 | run: composer config minimum-stability dev 91 | 92 | - name: Install dependencies 93 | uses: ramsey/composer-install@v3 94 | 95 | - name: Run tests 96 | run: vendor/bin/simple-phpunit 97 | 98 | phpunit-lowest: 99 | runs-on: ubuntu-latest 100 | name: PHP 8.4 (lowest) Test on ubuntu-latest 101 | steps: 102 | - name: Checkout 103 | uses: actions/checkout@v4 104 | 105 | - name: Setup PHP 106 | uses: shivammathur/setup-php@v2 107 | with: 108 | php-version: '8.4' 109 | extensions: zip 110 | 111 | - name: Install dependencies 112 | uses: ramsey/composer-install@v3 113 | with: 114 | dependency-versions: "lowest" 115 | 116 | - name: Run tests 117 | env: 118 | SYMFONY_DEPRECATIONS_HELPER: "disabled=1" 119 | run: vendor/bin/simple-phpunit 120 | 121 | phpunit-windows: 122 | runs-on: windows-latest 123 | name: PHP 8.4 Test on windows-latest 124 | env: 125 | PANTHER_FIREFOX_BINARY: 'C:\Program Files\Mozilla Firefox\firefox.exe' 126 | SKIP_FIREFOX: 1 127 | steps: 128 | - name: Checkout 129 | uses: actions/checkout@v4 130 | 131 | - name: Setup PHP 132 | uses: shivammathur/setup-php@v2 133 | with: 134 | php-version: '8.4' 135 | extensions: zip 136 | 137 | - name: Install dependencies 138 | uses: ramsey/composer-install@v3 139 | 140 | - name: Run tests 141 | run: vendor/bin/simple-phpunit 142 | 143 | phpunit-macos: 144 | runs-on: macos-latest 145 | name: PHP 8.4 Test on macos-latest 146 | steps: 147 | - name: Checkout 148 | uses: actions/checkout@v4 149 | 150 | - name: Setup PHP 151 | uses: shivammathur/setup-php@v2 152 | with: 153 | php-version: '8.4' 154 | extensions: zip 155 | 156 | - name: Install Firefox 157 | run: brew install --cask firefox 158 | 159 | - name: Install Geckodriver 160 | run: brew install geckodriver 161 | 162 | - name: Install dependencies 163 | uses: ramsey/composer-install@v3 164 | 165 | - name: Run tests 166 | run: vendor/bin/simple-phpunit 167 | 168 | phpunit-10: 169 | runs-on: ubuntu-latest 170 | strategy: 171 | matrix: 172 | php-versions: [ '8.1', '8.2', '8.3', '8.4'] 173 | fail-fast: false 174 | name: PHP ${{ matrix.php-versions }} (PHPUnit 11) Test on ubuntu-latest 175 | steps: 176 | - name: Checkout 177 | uses: actions/checkout@v4 178 | 179 | - name: Setup PHP 180 | uses: shivammathur/setup-php@v2 181 | with: 182 | php-version: ${{ matrix.php-versions }} 183 | extensions: zip 184 | 185 | - name: Install dependencies 186 | uses: ramsey/composer-install@v3 187 | with: 188 | composer-options: "--prefer-dist" 189 | 190 | - name: Remove phpunit-bridge dependency (not yet PHPUnit 10+ compliant) 191 | run: composer remove --dev symfony/phpunit-bridge 192 | 193 | - name: Install latest PHPUnit 11 194 | run: composer require --dev --prefer-dist 'phpunit/phpunit:>=10' 195 | 196 | - name: Run tests 197 | run: vendor/bin/phpunit --configuration phpunit.xml.dist.10 198 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | $header = <<<'HEADER' 4 | This file is part of the Panther project. 5 | 6 | (c) Kévin Dunglas <dunglas@gmail.com> 7 | 8 | For the full copyright and license information, please view the LICENSE 9 | file that was distributed with this source code. 10 | HEADER; 11 | 12 | $finder = PhpCsFixer\Finder::create()->in(__DIR__); 13 | 14 | return (new PhpCsFixer\Config()) 15 | ->setRiskyAllowed(true) 16 | ->setRules([ 17 | '@Symfony' => true, 18 | '@Symfony:risky' => true, 19 | 'array_syntax' => [ 20 | 'syntax' => 'short', 21 | ], 22 | 'braces' => [ 23 | 'allow_single_line_closure' => true, 24 | ], 25 | 'declare_strict_types' => true, 26 | 'header_comment' => [ 27 | 'header' => $header, 28 | 'location' => 'after_open', 29 | ], 30 | 'modernize_types_casting' => true, 31 | 'native_function_invocation' => ['include' => ['@compiler_optimized'], 'scope' => 'namespaced'], 32 | 'no_useless_else' => true, 33 | 'no_useless_return' => true, 34 | 'ordered_imports' => true, 35 | 'phpdoc_add_missing_param_annotation' => [ 36 | 'only_untyped' => true, 37 | ], 38 | 'phpdoc_order' => true, 39 | 'semicolon_after_instruction' => true, 40 | 'strict_comparison' => true, 41 | 'strict_param' => true, 42 | 'ternary_to_null_coalescing' => true, 43 | ]) 44 | ->setFinder($finder) 45 | ; 46 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | CHANGELOG 2 | ========= 3 | 4 | 2.2.0 5 | ----- 6 | 7 | * Add support for PHP 8.4 8 | * Add support for using Selenium with the built-in web server 9 | * Add a `PANTHER_NO_REDUCED_MOTION` environment variable to instruct the website to disable the reduction of non-essential movement 10 | * Add the ability to pass options to `HttpClient` when using `HttpBrowser` 11 | * Use a custom exception hierarchy instead of native exceptions directly 12 | * The Firefox `window-size` option is not set by default anymore in headless mode 13 | * Add explicit error messages in `wait*` methods 14 | * Fix support for checkbox and radio buttons having `0` as value 15 | * Fix catching of WebDriver exceptions 16 | * Ignore curl exceptions when closing WebDriver inside the destructor 17 | * Documentation has been moved from the Git repository to https://symfony.com/doc/current/testing/end_to_end.html 18 | 19 | 2.1.2 20 | ----- 21 | 22 | * Updated PHPDoc: getIterator method on Crawler returns an ArrayIterator of WebDriverElements 23 | 24 | 2.1.1 25 | ----- 26 | 27 | * Allow Symfony 7 28 | * Improve DX when using the Symfony binary 29 | * Fix screenshot on test failure 30 | * Add missing arguments when calling the legacy PHPUnit extension 31 | 32 | 2.1.0 33 | ----- 34 | 35 | * Add support for PHPUnit 10 36 | * Add support for `matches()` and `closest()` in `Crawler` 37 | 38 | 2.0.1 39 | ----- 40 | 41 | * Fix accessing `PantherTestCaseTrait::$webServerDir` before initialization 42 | 43 | 2.0.0 44 | ----- 45 | 46 | * Allow Symfony 6 47 | * Add type declarations everywhere possible 48 | * Remove Support for Symfony 4.4 49 | 50 | 1.1.2 51 | ----- 52 | 53 | * Allow deprecation-contracts 3 54 | * Fix `Form::offsetGet()` return type 55 | 56 | 1.1.1 57 | ----- 58 | 59 | * Fix a bug preventing to disable the headless mode 60 | 61 | 1.1.0 62 | ----- 63 | 64 | * Add a `PANTHER_DEVTOOLS` environment variable to disable the dev tools 65 | * Add a `PANTHER_ERROR_SCREENSHOT_ATTACH` environment variable to attach screenshots to PHPUnit reports in the JUnit format 66 | * Add a `chromedriver_arguments` option to pass custom arguments to Chromedriver 67 | * Add an `env` option to pass custom environment variables to the built-in web server from `PantherTestCase` 68 | * Add the possibility to pass options to `ChromeManager` 69 | * Automatically find the Chromedriver binary installed by `lanfest/binary-chromedriver` 70 | * Symfony 5.3 compatibility 71 | * Fix assertions that were not working with clients other than `PantherClient` 72 | * Fix the ability to keep the window of the browser open when a test fail by using the `--debug` option 73 | * Fix the `ServerExtension` when `registerClient()` is called multiple times 74 | * Fix `undefined constant` errors when using `PantherTestCaseTrait` directly 75 | 76 | 1.0.1 77 | ----- 78 | 79 | * Fix storing screenshots in the wrong directory when `PANTHER_ERROR_SCREENSHOT_DIR` is enabled 80 | 81 | 1.0.0 82 | ----- 83 | 84 | * Add `Client::waitForEnabled()`, `Client::waitForDisabled()`, `Client::waitForAttributeToContain()` and `Client::waitForAttributeToNotContain()` methods 85 | * Add `PantherTestCase::assertSelectorAttributeContains()`, `PantherTestCase::assertSelectorAttributeNotContains()`, `PantherTestCase::assertSelectorWillExist()`, 86 | `PantherTestCase::assertSelectorWillNotExist()`, `PantherTestCase::assertSelectorWillBeVisible()`, `PantherTestCase::assertSelectorWillNotBeVisible()`, 87 | `PantherTestCase::assertSelectorWillContain()`, `PantherTestCase::assertSelectorWillNotContain()`, `PantherTestCase::assertSelectorWillBeEnabled()`, 88 | `PantherTestCase::assertSelectorWillBeDisabled`, `PantherTestCase::assertSelectorAttributeWillContain()`, and `PantherTestCase::assertSelectorAttributeWillNotContain()` 89 | assertions 90 | * Automatically take a screenshot when a test fail and if the `PANTHER_ERROR_SCREENSHOT_DIR` environment variable is set 91 | * Add missing return types 92 | * **Breaking Change**: Remove the deprecated PHPUnit listener, use the PHPUnit extension instead 93 | * **Breaking Change**: Remove deprecated support for Goutte, use `HttpBrowser` instead 94 | * **Breaking Change**: Remove deprecated support for `PANTHER_CHROME_DRIVER_BINARY` and `PANTHER_GECKO_DRIVER_BINARY` environment variables, add the binaries in your `PATH` instead 95 | * Don't allow unserializing classes with a destructor 96 | 97 | 0.9.0 98 | ----- 99 | 100 | * **Breaking Change**: ChromeDriver and geckodriver binaries are not included in the archive anymore and must be installed separately, [refer to the documentation](README.md#installing-chromedriver-and-geckodriver) 101 | * PHP 8 compatibility 102 | * Add `Client::waitForStaleness()` method to wait for an element to be removed from the DOM 103 | * Add `Client::waitForInvisibility()` method to wait for an element to be invisible 104 | * Add `Client::waitForElementToContain()` method to wait for an element containing the given parameter 105 | * Add `Client::waitForElementToNotContain()` method to wait for an element to not contain the given parameter 106 | * Add `PantherTestCase::assertSelectorIsVisible()`, `PantherTestCase::assertSelectorIsNotVisible()`, `PantherTestCase::assertSelectorIsEnabled()` and `PantherTestCase::assertSelectorIsDisabled()` assertions 107 | * Fix `baseUri` not taken into account when using Symfony HttpBrowser 108 | 109 | 0.8.0 110 | ----- 111 | 112 | * Upgrade ChromeDriver to version 85.0.4183.87 113 | * Upgrade geckodriver to version 0.27.0 114 | * Add a `Client::waitForVisibility()` method to wait for an element to appear 115 | * Allow passing options to the browser manager from `PantherTestCase::createPantherClient()` 116 | * Add a `Client::ping()` method to check if the WebDriver connection is still active 117 | * Fix setting a new value to an input field when there is an existing value 118 | * Improve the error message when the web server crashes 119 | * Throw an explanative `LogicException` when driver is not started yet 120 | * Prevent timeouts caused by the integrated web server 121 | * Fix the value of cookie secure flags 122 | * Throw an exception when getting history (unsupported feature) 123 | * Add docs to use Panther with GitHub Actions 124 | * Various bug fixes and documentation improvements 125 | 126 | 0.7.1 127 | ----- 128 | 129 | * Fix some inconsistencies between Chrome and Firefox 130 | 131 | 0.7.0 132 | ----- 133 | 134 | * Add built-in support for Firefox (using GeckoDriver) 135 | * Add support for Symfony HttpBrowser 136 | * Deprecate Goutte support (use HttpBrowser instead) 137 | * Allow configuring `RemoteWebDriver` timeouts when using Selenium 138 | * Allow passing custom environment variables to the built-in web server 139 | * Fix some compatibility issues with PHP WebDriver 1.8 140 | * Upgrade ChromeDriver to version 80.0.3987.106 141 | * Prevent access to fixture files even if the web server is misconfigured 142 | 143 | 0.6.1 144 | ----- 145 | 146 | * Upgrade ChromeDriver to version 79.0.3945.36 147 | * Allow passing custom timeouts as options of `ChromeManager` (`connection_timeout_in_ms` and `request_timeout_in_ms`) 148 | 149 | 0.6.0 150 | ----- 151 | 152 | * Add compatibility with Symfony 5 153 | * Allow using `Client::waitFor()` to wait for invisible elements 154 | * Add support to pass XPath expressions as parameters of `Client::waitFor()` 155 | * Fix `Crawler::attr()` signature (it can return `null`) 156 | * Deprecate `ServerListener` (use `ServerExtension` instead) 157 | * Upgrade ChromeDriver to version 78.0.3904.70 158 | * New logo 159 | * Various docs fixes and improvements 160 | 161 | 0.5.2 162 | ----- 163 | 164 | * Fix a bug occurring when using a non-fresh client 165 | 166 | 0.5.1 167 | ----- 168 | 169 | * Allow to override the `APP_ENV` environment variable passed to the web server by setting `PANTHER_APP_ENV` 170 | * Fix using assertions with a client created through `PantherTestCase::createClient()` 171 | * Don't call `PantherTestCase::getClient()` if this method isn't `static` 172 | * Fix remaining deprecations 173 | 174 | 0.5.0 175 | ----- 176 | 177 | * Add support for [Crawler test assertions](https://symfony.com/doc/current/testing/functional_tests_assertions.html#crawler) 178 | * Add the `PantherTestCase::createAdditionalPantherClient()` to retrieve additional isolated browsers, useful to test applications using [Mercure](https://mercure.rocks) or [WebSocket](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API) 179 | * Improved support for non-standard web server directories 180 | * Allow the integrated web server to start even if the homepage doesn't return a 200 HTTP status code 181 | * Increase default timeouts from 5 seconds to 30 seconds 182 | * Improve error messages 183 | * Add compatibility with Symfony 4.3 184 | * Upgrade ChromeDriver to version 76.0.3809.68 185 | * Various quality improvements 186 | 187 | 0.4.1 188 | ----- 189 | 190 | * Remove the direct dependency to `symfony/contracts` 191 | 192 | 0.4.0 193 | ----- 194 | 195 | * Speed up the boot sequence 196 | * Add basic support for file uploads 197 | * Add a `readinessPath` option to use a custom path for server readiness detection 198 | * Fix the behavior of `ChoiceFormField::getValue()` to be consistent with other BrowserKit implementations 199 | * Ensure to clean the previous content of field when using `TextareaFormField::setValue()` and `InputFormField::setValue()` 200 | 201 | 0.3.0 202 | ----- 203 | 204 | * Add a new API to manipulate the mouse 205 | * Keep the browser window open on fail, when running in non-headless mode 206 | * Automatically open Chrome DevTools when running in non-headless mode 207 | * PHPUnit 8 compatibility 208 | * Add a PHPUnit extension to keep alive the web server, and the client between tests 209 | * Change the default port of the web server to `9080` to prevent a conflict with Xdebug 210 | * Allow to use an external web server instead of the built-in one for testing 211 | * Allow to use a custom router script 212 | * Allow to use a custom Chrome binary 213 | 214 | 0.2.0 215 | ----- 216 | 217 | * Add JS execution capabilities to `Client` 218 | * Allow keeping the web server and client active even after test teardown 219 | * Add a method to refresh the crawler (`Client::refreshCrawler()`) 220 | * Add options to configure the web server and ChromeDriver 221 | * PHP 7.1 compatibility 222 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT license 2 | 3 | Copyright (c) 2018-present Kévin Dunglas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is furnished 10 | to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | <h1 align="center"><img src="panther.svg" alt="Panther" width="250" height="250"></h1> 2 | 3 | **A browser testing and web scraping library for [PHP](https://php.net) and [Symfony](https://symfony.com)** 4 | 5 |  6 | 7 | *Panther* is a convenient standalone library to scrape websites and to run end-to-end tests **using real browsers**. 8 | 9 | Panther is super powerful. It leverages [the W3C's WebDriver protocol](https://www.w3.org/TR/webdriver/) to drive native web browsers such as Google Chrome and Firefox. 10 | 11 | ## Resources 12 | 13 | * [Documentation](https://symfony.com/doc/current/testing/end_to_end.html) 14 | * [Contributing](https://symfony.com/doc/current/contributing/index.html) 15 | * [Report issues](https://github.com/symfony/symfony/issues) and [send Pull Requests](https://github.com/symfony/symfony/pulls) in the [main Symfony repository](https://github.com/symfony/symfony) 16 | 17 | ## Save the Panthers 18 | 19 | Many of the wild cat species are highly threatened. 20 | If you like this software, help save the (real) panthers by [donating to the Panthera organization](https://www.panthera.org/donate). 21 | 22 | ## Credits 23 | 24 | Created by [Kévin Dunglas](https://dunglas.fr). Sponsored by [Les-Tilleuls.coop](https://les-tilleuls.coop). 25 | 26 | Panther is built on top of [PHP WebDriver](https://github.com/php-webdriver/php-webdriver) and [several other FOSS libraries](https://symfony.com/blog/introducing-symfony-panther-a-browser-testing-and-web-scrapping-library-for-php#thank-you-open-source). 27 | It has been inspired by [Nightwatch.js](http://nightwatchjs.org/), a WebDriver-based testing tool for JavaScript. 28 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "symfony/panther", 3 | "description": "A browser testing and web scraping library for PHP and Symfony.", 4 | "license": "MIT", 5 | "type": "library", 6 | "keywords": [ 7 | "scraping", 8 | "E2E", 9 | "testing", 10 | "webdriver", 11 | "selenium", 12 | "symfony" 13 | ], 14 | "authors": [ 15 | { 16 | "name": "Kévin Dunglas", 17 | "email": "dunglas@gmail.com", 18 | "homepage": "https://dunglas.fr" 19 | }, 20 | { 21 | "name": "Symfony Community", 22 | "homepage": "https://symfony.com/contributors" 23 | } 24 | ], 25 | "homepage": "https://dunglas.fr", 26 | "require": { 27 | "php": ">=8.0", 28 | "ext-dom": "*", 29 | "ext-libxml": "*", 30 | "php-webdriver/webdriver": "^1.8.2", 31 | "symfony/browser-kit": "^5.4 || ^6.4 || ^7.0", 32 | "symfony/dependency-injection": "^5.4 || ^6.4 || ^7.0", 33 | "symfony/deprecation-contracts": "^2.4 || ^3", 34 | "symfony/dom-crawler": "^5.4 || ^6.4 || ^7.0", 35 | "symfony/http-client": "^6.4 || ^7.0", 36 | "symfony/http-kernel": "^5.4 || ^6.4 || ^7.0", 37 | "symfony/process": "^5.4 || ^6.4 || ^7.0" 38 | }, 39 | "require-dev": { 40 | "symfony/css-selector": "^5.4 || ^6.4 || ^7.0", 41 | "symfony/framework-bundle": "^5.4 || ^6.4 || ^7.0", 42 | "symfony/mime": "^5.4 || ^6.4 || ^7.0", 43 | "symfony/phpunit-bridge": "^7.2.0" 44 | }, 45 | "minimum-stability": "dev", 46 | "prefer-stable": true, 47 | "autoload": { 48 | "psr-4": { 49 | "Symfony\\Component\\Panther\\": "src/" 50 | } 51 | }, 52 | "autoload-dev": { 53 | "psr-4": { 54 | "Symfony\\Component\\Panther\\Tests\\": "tests/" 55 | } 56 | }, 57 | "config": { 58 | "sort-packages": true 59 | }, 60 | "extra": { 61 | "branch-alias": { 62 | "dev-main": "2.0.x-dev" 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /panther.svg: -------------------------------------------------------------------------------- 1 | <svg id="Calque_1" data-name="Calque 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 402.1 498.4"><defs><style>.cls-1{fill:#fff;}.cls-2{fill:#f2b243;}.cls-3{fill:#fdfefe;}.cls-4{fill:#c6e5ef;}.cls-5{fill:#242122;}.cls-6{fill:#ed9d3f;}.cls-11,.cls-7{fill:none;}.cls-7{stroke:#2f2c2d;stroke-miterlimit:10;stroke-width:21px;}.cls-10,.cls-12,.cls-8,.cls-9{fill:#221f20;}.cls-8{opacity:0.3;}.cls-8,.cls-9{isolation:isolate;}.cls-9{opacity:0.2;}.cls-12{fill-rule:evenodd;}.cls-13{opacity:0.5;}</style></defs><path class="cls-1" d="M261.6,372.91l37.1,6.6,37.1-6.6,42.1-46.7,66.5-83.6,2.9-115.8,22.6-77.9-6-4.7-45.4,29.7L298.7,43.21,178.9,73.91l-45.4-29.7-6,4.7,22.6,77.9,2.9,115.8,66.5,83.6Z" transform="translate(-96.6 -11.21)"/><polygon class="cls-2" points="367.1 14 318.1 46 201.1 16 84.1 46 35.1 14 12.1 32 37.1 118 40.1 237 110.1 325 156.1 376 201.1 384 246.1 376 292.1 325 362.1 237 365.1 118 390.1 32 367.1 14"/><polygon class="cls-3" points="35.1 93 26.1 50 43.1 49 80.1 72 41.1 115 35.1 93"/><polygon class="cls-1" points="367.1 93 376.1 50 359.1 49 322.1 72 361.1 115 367.1 93"/><polygon class="cls-4" points="103.9 135.8 131.7 123 185.3 163.3 138.2 177.6 103.9 135.8"/><path class="cls-5" d="M233.2,194l-39.8-48.6L229,129l62.7,47.2Zm-25.5-45.3,28.6,34.9,35.7-10.8-44.3-33.3Z" transform="translate(-96.6 -11.21)"/><polygon class="cls-4" points="298.02 136.8 272.96 124 224.9 164.3 267.21 178.6 298.02 136.8"/><path class="cls-5" d="M365.25,195l-52.55-17.8L369,130l32,16.4Zm-34.94-21.2,32.06,10.8,25.69-34.9L370,140.41Z" transform="translate(-96.6 -11.21)"/><polygon class="cls-6" points="240.1 201.5 200.1 197.5 160.1 201.5 112.1 262.5 116.1 327.5 157.1 370.5 200.1 379.5 243.1 370.5 284.1 327.5 288.1 262.5 240.1 201.5"/><polygon class="cls-2" points="230.1 187 200.1 189.2 170.1 187 158.1 281.5 200.1 309 242.1 281.5 230.1 187"/><path class="cls-7" d="M269.7,207.21" transform="translate(-96.6 -11.21)"/><polygon class="cls-6" points="133.1 349 194.1 338 264.1 354 246.1 376 201.1 384 156.1 376 133.1 349"/><polygon class="cls-8" points="161.6 263.5 199.6 273.5 238.6 264.5 251.6 289.5 200.6 302.5 153.6 289.5 161.6 263.5"/><polygon class="cls-8" points="120.1 313 194.2 331 284.1 314 285.1 337 265.3 355 198.4 340 132.6 350 120.1 313"/><polygon class="cls-8" points="153.1 360 198.1 365 248.1 362 231.1 379 185.1 379 153.1 360"/><polygon class="cls-9" points="125.1 129 166.1 167 185.1 163 134.1 124 125.1 129"/><polygon class="cls-9" points="279.1 129 238.1 167 219.1 163 270.1 124 279.1 129"/><circle class="cls-10" cx="255.1" cy="147" r="9"/><circle class="cls-10" cx="147.1" cy="147" r="9"/><polygon class="cls-8" points="285.1 285 358.1 214 365.1 238 288.1 323 285.1 285"/><polygon class="cls-8" points="119.1 285 46.1 214 39.1 238 116.1 323 119.1 285"/><polygon class="cls-8" points="28.1 50 43.1 49 80.1 72 71.1 82 39.1 56 26.1 58 28.1 50"/><polygon class="cls-8" points="374.1 50.1 359.1 49 322.1 71.9 331.1 82 363.1 56 376.1 58.1 374.1 50.1"/><path class="cls-5" d="M215.7,102.21a90.87,90.87,0,0,1,19.7,5.4,156,156,0,0,1,18.1,8.4,163.45,163.45,0,0,1,16.8,10.6,92.58,92.58,0,0,1,15.2,13.6,90.87,90.87,0,0,1-19.7-5.4,147.27,147.27,0,0,1-18-8.4,163.45,163.45,0,0,1-16.8-10.6A85.25,85.25,0,0,1,215.7,102.21Z" transform="translate(-96.6 -11.21)"/><path class="cls-5" d="M388.7,102.21a88.23,88.23,0,0,1-15.2,13.6,148,148,0,0,1-16.8,10.6,147.27,147.27,0,0,1-18,8.4,96.92,96.92,0,0,1-19.7,5.4,84.27,84.27,0,0,1,15.2-13.6A148,148,0,0,1,351,116a142,142,0,0,1,18.1-8.4A88.47,88.47,0,0,1,388.7,102.21Z" transform="translate(-96.6 -11.21)"/><line class="cls-11" x1="200.6" y1="312.5" x2="200.6" y2="338.5"/><rect class="cls-5" x="194.1" y="312.5" width="13" height="26"/><polygon class="cls-10" points="159.1 283 244.8 283 200.2 323 159.1 283"/><path class="cls-5" d="M328.7,203.21c2.3,7.7,4.4,15.5,6.4,23.2s3.9,15.6,5.8,23.3l2.7,11.7,2.6,11.7c1.7,7.8,3.4,15.6,5,23.5l.8,3.8-3.5,2.2c-3.8,2.4-7.7,4.8-11.6,7.1s-7.7,4.7-11.6,7.1-7.8,4.7-11.7,7l-11.7,6.9-3.3,1.9-3.3-1.9-11.7-6.9c-3.9-2.3-7.8-4.6-11.7-7s-7.8-4.7-11.6-7.1-7.8-4.7-11.6-7.1l-3.6-2.2.8-3.8,2.5-11.8,2.6-11.7,2.7-11.7,2.7-11.7c1.9-7.8,3.8-15.6,5.8-23.3s4.1-15.5,6.4-23.2c-.5,8-1.3,16-2.1,24s-1.7,16-2.7,23.9L262.3,263l-1.6,11.9-1.7,11.9-1.8,11.9-2.8-6c4,2.2,7.9,4.4,11.9,6.6s7.9,4.4,11.9,6.7,7.9,4.5,11.8,6.8l11.8,6.8h-6.6l11.8-6.8c3.9-2.3,7.9-4.5,11.8-6.8s7.9-4.5,11.9-6.7,7.9-4.4,11.9-6.6l-2.8,6c-1.2-7.9-2.3-15.8-3.5-23.8L334.7,263l-1.5-11.9c-1-8-1.9-15.9-2.7-23.9C330,219.21,329.2,211.21,328.7,203.21Z" transform="translate(-96.6 -11.21)"/><polygon class="cls-5" points="289.6 327.7 280.6 327.3 284.2 261.3 293.6 243.4 301.6 247.6 293 263.7 289.6 327.7"/><polygon class="cls-5" points="114.1 327.7 110.7 263.7 102.1 247.6 110.1 243.4 119.5 261.3 123.1 327.3 114.1 327.7"/><path class="cls-10" d="M297.7,404.91l-50.4-9-48.8-54.2-72.2-90.8-3.1-121.2L96.6,38.21l34.5-27,51.4,33.6,115.2-29.4,115.1,29.5,51.4-33.6,34.5,27-26.6,91.5L469,251l-72.5,91.2-48.4,53.7Zm-39.6-28.4,39.6,7,39.6-7,43.4-48.1,67.6-85,2.9-116.8.4-1.3,23-79.2-11.5-9-46.6,30.4L297.7,37,178.8,67.51l-46.6-30.4-11.5,9,23.4,80.5,2.9,116.8,67.6,85Z" transform="translate(-96.6 -11.21)"/><path class="cls-5" d="M297.7,401.81l-48.4-8.6-31.8-37.4,73.4-13.2,81.5,18.6-26.2,32Zm-41.6-20.6,41.6,7.4,41.5-7.4,9.8-12-58.5-13.4-48.6,8.8Z" transform="translate(-96.6 -11.21)"/><polygon class="cls-5" points="123.1 61.4 160.2 48.3 183.1 71.2 159.8 94.5 123.1 61.4"/><polygon class="cls-5" points="278.1 61.4 241 48.3 218.1 71.2 241.4 94.5 278.1 61.4"/><polygon class="cls-5" points="341.1 173.4 318.1 165.3 303.8 179.5 318.3 194 341.1 173.4"/><polygon class="cls-5" points="343.1 134.1 327.5 128.6 317.8 138.2 327.6 148 343.1 134.1"/><polygon class="cls-5" points="319.1 213.4 296.1 205.3 281.8 219.5 296.3 234 319.1 213.4"/><polygon class="cls-5" points="61.8 167.4 84.9 159.3 99.1 173.5 84.6 188 61.8 167.4"/><polygon class="cls-5" points="59.8 128.1 75.5 122.6 85.1 132.2 75.3 142 59.8 128.1"/><polygon class="cls-5" points="83.8 207.4 106.9 199.3 121.1 213.5 106.6 228 83.8 207.4"/><polygon class="cls-10" points="369.03 244.04 372.4 239.8 372.74 226.34 113.22 26.82 104.28 28.91 96.92 30.79 369.03 244.04"/><path class="cls-12" d="M353.37,120.65c25.4-1.31,55.26,10.63,57.22,29.53,2.15,19-28.82,58.64-56.59,60-27.77,1.52-56-41.66-55.79-60.72C298.21,130.41,327.91,122.18,353.37,120.65Z" transform="translate(-96.6 -11.21)"/><path class="cls-1" d="M355.68,135.85a27.78,27.78,0,1,0,27.78,27.77A27.77,27.77,0,0,0,355.68,135.85Zm15,16.07a1.94,1.94,0,0,1-2.21-1.89,1.91,1.91,0,0,1,.39-1.24c.28-.56.34-.62.34-.87,0-.73-1.14-.76-1.44-.75-4.17.14-5.27,5.77-6.16,10.35l-.44,2.41a7.47,7.47,0,0,0,5.06-.7c1.33-.87-.38-1.76-.16-2.75A2,2,0,0,1,367.9,155a1.92,1.92,0,0,1,1.73,2.1c0,1.78-2.4,4.21-7.1,4.12-.58,0-1.11-.06-1.6-.12L360,166c-.79,3.71-1.85,8.78-5.63,13.21-3.25,3.86-6.54,4.46-8,4.51-2.76.09-4.59-1.38-4.65-3.35a2.91,2.91,0,0,1,2.72-3,2.35,2.35,0,0,1,2.53,2.24,1.49,1.49,0,0,1-.86,1.57c-.24.19-.61.39-.59.82a.71.71,0,0,0,.82.58,4.32,4.32,0,0,0,2.48-1c2.69-2.24,3.72-6.15,5.08-13.26l.28-1.72c.47-2.31,1-4.89,1.76-7.46-1.9-1.43-3-3.2-5.58-3.89-1.75-.47-2.81-.07-3.56.88a2.58,2.58,0,0,0,.26,3.44l1.42,1.56c1.73,2,2.68,3.57,2.33,5.67-.57,3.35-4.57,5.92-9.29,4.47-4-1.24-4.78-4.09-4.3-5.67a1.89,1.89,0,0,1,2.6-1.31,2.25,2.25,0,0,1,1.28,2.83,5,5,0,0,1-.22.56,8.62,8.62,0,0,0-.49.91c-.26.84.9,1.44,1.71,1.69a3.16,3.16,0,0,0,4-1.85,2.62,2.62,0,0,0-.79-2.64L343.63,164c-.78-.87-2.5-3.29-1.66-6a5.84,5.84,0,0,1,2-2.9,7.19,7.19,0,0,1,6.51-1.19c2.8.81,4.14,2.66,5.88,4.08a24,24,0,0,1,4.36-8,10,10,0,0,1,7.11-3.81c2.83-.09,5,1.19,5,3.21A2.39,2.39,0,0,1,370.66,151.92Z" transform="translate(-96.6 -11.21)"/><path class="cls-10" d="M385,164.48a30.27,30.27,0,1,1-30.27-30.27A30.27,30.27,0,0,1,385,164.48Z" transform="translate(-96.6 -11.21)"/><path class="cls-1" d="M368,145.41a10.84,10.84,0,0,0-7.76,4.14,26.22,26.22,0,0,0-4.74,8.74c-1.9-1.56-3.36-3.57-6.41-4.45a7.82,7.82,0,0,0-7.09,1.3,6.27,6.27,0,0,0-2.17,3.16c-.92,3,1,5.61,1.81,6.56l1.86,2a2.81,2.81,0,0,1,.85,2.87,3.42,3.42,0,0,1-4.36,2c-.88-.27-2.14-.92-1.86-1.84a9,9,0,0,1,.53-1,4.77,4.77,0,0,0,.24-.62,2.46,2.46,0,0,0-1.39-3.08,2.06,2.06,0,0,0-2.83,1.43c-.53,1.72.29,4.83,4.68,6.18,5.15,1.58,9.5-1.22,10.11-4.87.39-2.29-.64-4-2.53-6.18l-1.55-1.7a2.82,2.82,0,0,1-.28-3.75c.81-1,2-1.47,3.88-1,2.78.76,4,2.69,6.08,4.24-.85,2.8-1.41,5.61-1.91,8.13l-.31,1.88c-1.48,7.74-2.61,12-5.54,14.44a4.7,4.7,0,0,1-2.7,1.09.79.79,0,0,1-.9-.64c0-.46.38-.68.64-.89a1.61,1.61,0,0,0,1-1.7,2.57,2.57,0,0,0-2.76-2.45,3.2,3.2,0,0,0-3,3.24c.07,2.14,2.07,3.75,5.08,3.65,1.6-.06,5.19-.71,8.73-4.92,4.12-4.82,5.27-10.35,6.14-14.39l1-5.34c.53.06,1.11.1,1.73.12,5.13.11,7.7-2.55,7.74-4.48a2.11,2.11,0,0,0-1.88-2.3,2.16,2.16,0,0,0-2,1.65c-.24,1.08,1.63,2.05.17,3a8.15,8.15,0,0,1-5.51.76l.48-2.63c1-5,2.17-11.12,6.71-11.27.33,0,1.54,0,1.57.82,0,.26-.06.33-.37.95a2.18,2.18,0,0,0-.43,1.35,2.14,2.14,0,0,0,2.42,2.06,2.61,2.61,0,0,0,2.38-2.83C373.38,146.7,371.06,145.31,368,145.41Z" transform="translate(-96.6 -11.21)"/><path class="cls-10" d="M263.9,508.31a8.92,8.92,0,0,1-9-9.1v-44.8H238.5v45a9,9,0,0,1-18,0v-54.1a9,9,0,1,1,18,0v3c0,.7.4.5.4.3,2.6-8.1,8.5-12,16.2-12,9.9,0,17.6,6.3,17.6,16.7v45.9A8.76,8.76,0,0,1,263.9,508.31Z" transform="translate(-96.6 -11.21)"/><path class="cls-10" d="M318.4,436.71c11.5,0,11.5,17.7,0,17.7h-7.7v45.2c0,12-17.9,12-17.9,0v-45.2h-7.7c-11.6,0-11.6-17.7,0-17.7Z" transform="translate(-96.6 -11.21)"/><path class="cls-10" d="M329.8,445.31c0-11.5,17.9-11.7,17.9-.1v22.4h16.8v-22.3c0-11.5,18-11.7,18-.1v54.7c0,11.6-18,11.6-18,0v-29.7l-16.8,15.2v14.5c0,11.6-17.9,11.6-17.9,0Z" transform="translate(-96.6 -11.21)"/><path class="cls-10" d="M404.2,454.51v9.4h19.9a9,9,0,0,1,0,17.9H404.2v8.6h21.6a8.94,8.94,0,0,1,8.9,9,9.19,9.19,0,0,1-8.9,8.9H401.2a15,15,0,0,1-14.9-15.1v-5.5c0-7,1-13.2,8.9-15.9-7.3-2.5-8.9-8.1-8.9-15.1v-4.8a15,15,0,0,1,14.9-15.1h24.6a8.9,8.9,0,0,1,8.9,8.8,9.16,9.16,0,0,1-8.9,9.1H404.2Z" transform="translate(-96.6 -11.21)"/><path class="cls-10" d="M155.6,450.61l-9.8-9.8a14.73,14.73,0,0,0-10.5-4.2H121.7a15.15,15.15,0,0,0-15.1,15.1v47.4a9.11,9.11,0,0,0,9,9.1,8.8,8.8,0,0,0,8.7-9.1v-8.6h11a14,14,0,0,0,10.5-4.3l8.9-8.5C160.9,471.81,162,456.71,155.6,450.61Zm-20.8,26.2-17.9-16.1,18.1-6.4,11.2,11.2Z" transform="translate(-96.6 -11.21)"/><path class="cls-10" d="M210.1,450.21l-13.4-11c-5.1-4.3-8.9-4.1-14.1,0l-13,11c-3.8,3.3-6.1,5.9-6.5,10.9v38.7a9,9,0,0,0,17.9,0v-25.1l17.7,16.4v8.7a8.86,8.86,0,0,0,9,8.8,8.75,8.75,0,0,0,8.8-8.8v-38.7C216.1,456.11,213.9,453.41,210.1,450.21Zm-15.3,26.6-17.9-16.1,18.1-6.4,11.2,11.2Z" transform="translate(-96.6 -11.21)"/><path class="cls-10" d="M490.8,464.41v-4a14.75,14.75,0,0,0-4.8-11l-8.7-8.4a15,15,0,0,0-10.7-4.4H446.7a9,9,0,0,0-9,9v53.8a8.94,8.94,0,0,0,9,8.9,8.83,8.83,0,0,0,8.8-8.9v-26.2l17.4,21.2v5a8.75,8.75,0,1,0,17.5,0v-5.2c0-6.4-2.2-11.3-8.6-15.2C487.5,476.21,490.8,471.51,490.8,464.41Zm-20,8.4-17.9-16.1,18.1-6.4,11.2,11.2Z" transform="translate(-96.6 -11.21)"/><g class="cls-13"><path class="cls-1" d="M229.4,436.31a9,9,0,0,1,9.1,9v3c0,.37.11.49.22.49a.2.2,0,0,0,.18-.19c2.6-8.1,8.5-12,16.2-12,9.9,0,17.6,6.3,17.6,16.7v45.9a8.76,8.76,0,0,1-8.8,9.1,8.92,8.92,0,0,1-9-9.1v-44.8H238.5v45a9,9,0,0,1-18,0v-54.1a9,9,0,0,1,8.9-9m0-1a10,10,0,0,0-9.9,10v54.1a9.91,9.91,0,0,0,9.9,9.9,10,10,0,0,0,10.1-9.9v-44h14.4v43.8a9.94,9.94,0,0,0,10,10.1,9.67,9.67,0,0,0,7-2.89,10,10,0,0,0,2.82-7.23V453.31c0-10.42-7.65-17.7-18.6-17.7-7.08,0-12.51,3.18-15.62,9a10.08,10.08,0,0,0-10.08-9.34Z" transform="translate(-96.6 -11.21)"/><path class="cls-1" d="M318.4,436.71c11.5,0,11.5,17.7,0,17.7h-7.7v45.2a9,9,0,1,1-17.9,0v-45.2h-7.7c-11.6,0-11.6-17.7,0-17.7h33.3m0-1H285.1c-6.37,0-9.7,5-9.7,9.85s3.33,9.85,9.7,9.85h6.7v44.2a9.73,9.73,0,0,0,3.13,7.5,10.55,10.55,0,0,0,13.64,0,9.73,9.73,0,0,0,3.13-7.5v-44.2h6.7c6.32,0,9.62-5,9.62-9.85s-3.3-9.85-9.62-9.85Z" transform="translate(-96.6 -11.21)"/><path class="cls-1" d="M338.84,436.6c4.44,0,8.86,2.85,8.86,8.61v22.4h16.8v-22.3c0-5.79,4.56-8.71,9.09-8.71s8.91,2.85,8.91,8.61v54.7c0,5.8-4.5,8.7-9,8.7s-9-2.9-9-8.7v-29.7l-16.8,15.2v14.5c0,5.8-4.47,8.7-8.95,8.7s-8.95-2.9-8.95-8.7v-54.6c0-5.79,4.53-8.71,9-8.71m0-1h0c-5,0-10,3.33-10,9.71v54.6c0,6.37,5,9.7,9.95,9.7s9.95-3.33,9.95-9.7V485.85l14.8-13.39v27.45c0,6.37,5,9.7,10,9.7s10-3.33,10-9.7v-54.7c0-4.63-3.1-9.61-9.91-9.61-5,0-10.09,3.33-10.09,9.71v21.3H348.7v-21.4c0-4.63-3.09-9.61-9.86-9.61Z" transform="translate(-96.6 -11.21)"/><path class="cls-1" d="M425.8,436.81a8.9,8.9,0,0,1,8.9,8.8,9.16,9.16,0,0,1-8.9,9.1H404.2v9.2h19.9a9,9,0,0,1,0,17.9H404.2v8.6h21.6a8.94,8.94,0,0,1,8.9,9,9.19,9.19,0,0,1-8.9,8.9H401.2a15,15,0,0,1-14.9-15.1v-5.5c0-7,1-13.2,8.9-15.9-7.3-2.5-8.9-8.1-8.9-15.1v-4.8a15,15,0,0,1,14.9-15.1h24.6m0-1H401.2a16,16,0,0,0-15.9,16.1v4.8c0,5.7.93,11.84,7.32,15.1-6.71,3.41-7.32,9.94-7.32,15.9v5.5a16,16,0,0,0,15.9,16.1h24.6a10.2,10.2,0,0,0,9.9-9.9,10,10,0,0,0-9.9-10H405.2v-6.6h18.9a10,10,0,0,0,0-19.9H405.2v-7.2h20.6a10.13,10.13,0,0,0,9.9-10.1,9.86,9.86,0,0,0-9.9-9.8Z" transform="translate(-96.6 -11.21)"/><path class="cls-1" d="M135.3,436.61a14.73,14.73,0,0,1,10.5,4.2l9.8,9.8c6.4,6.1,5.3,21.2-.9,27.1l-8.9,8.5a14,14,0,0,1-10.5,4.3h-11v8.6a8.8,8.8,0,0,1-8.7,9.1,9.11,9.11,0,0,1-9-9.1v-47.4a15.15,15.15,0,0,1,15.1-15.1h13.6m-.5,40.2,11.4-11.3L135,454.31l-18.1,6.4,17.9,16.1m.5-41.2H121.7a16.12,16.12,0,0,0-16.1,16.1v47.4a10.06,10.06,0,0,0,10,10.1,9.8,9.8,0,0,0,9.7-10.1v-7.6h10a15.12,15.12,0,0,0,11.21-4.59l8.88-8.49c3.4-3.23,5.51-9.14,5.51-15.43,0-5.54-1.68-10.32-4.61-13.11l-9.78-9.79a15.77,15.77,0,0,0-11.21-4.49Zm-16.48,25.48,15.92-5.63,10,10.05-10,9.93-16-14.35Z" transform="translate(-96.6 -11.21)"/><path class="cls-1" d="M189.77,436.06c2.21,0,4.41,1,6.93,3.15l13.4,11c3.8,3.2,6,5.9,6.4,10.9v38.7a8.75,8.75,0,0,1-8.8,8.8,8.86,8.86,0,0,1-9-8.8v-8.7L181,474.71v25.1a9,9,0,0,1-17.9,0v-38.7c.4-5,2.7-7.6,6.5-10.9l13-11c2.63-2.07,4.9-3.15,7.17-3.15m5,40.75,11.4-11.3L195,454.31l-18.1,6.4,17.9,16.1m-5-41.75c-2.4,0-4.88,1.07-7.79,3.37l-13,11c-3.61,3.13-6.4,6-6.85,11.58v38.78a10,10,0,0,0,19.9,0V477l15.7,14.55v8.26a9.92,9.92,0,0,0,10,9.8,9.69,9.69,0,0,0,9.8-9.8V461c-.43-5.29-2.8-8.25-6.76-11.58l-13.41-11c-2.73-2.3-5.13-3.38-7.56-3.38Zm-11,26,15.92-5.63,10,10.05-10,9.93-16-14.35Z" transform="translate(-96.6 -11.21)"/><path class="cls-1" d="M466.6,436.61a15,15,0,0,1,10.7,4.4l8.7,8.4a14.75,14.75,0,0,1,4.8,11v4c0,7.1-3.3,11.8-9,14.6,6.4,3.9,8.6,8.8,8.6,15.2v5.2a8.75,8.75,0,1,1-17.5,0v-5l-17.4-21.2v26.2a8.83,8.83,0,0,1-8.8,8.9,8.94,8.94,0,0,1-9-8.9v-53.8a9,9,0,0,1,9-9h19.9m4.2,36.2,11.4-11.3L471,450.31l-18.1,6.4,17.9,16.1m-4.2-37.2H446.7a10,10,0,0,0-10,10v53.8a10,10,0,0,0,10,9.9,9.86,9.86,0,0,0,9.8-9.9V476l15.4,18.76v4.64a9.75,9.75,0,1,0,19.5,0v-5.2c0-6.7-2.37-11.43-7.65-15.12,5.34-3.17,8.05-8.11,8.05-14.68v-4a15.86,15.86,0,0,0-5.1-11.72l-8.71-8.4a15.84,15.84,0,0,0-11.39-4.68Zm-11.78,21.48,15.92-5.63,10,10.05-10,9.93-15.95-14.35Z" transform="translate(-96.6 -11.21)"/></g></svg> -------------------------------------------------------------------------------- /phpunit.xml.dist.10: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="UTF-8"?> 2 | 3 | <!-- http://phpunit.de/manual/4.1/en/appendixes.configuration.html --> 4 | <phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 5 | xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.0/phpunit.xsd" 6 | backupGlobals="false" 7 | bootstrap="vendor/autoload.php" 8 | colors="true" 9 | cacheDirectory=".phpunit.cache" 10 | > 11 | 12 | <coverage> 13 | <include> 14 | <directory>.</directory> 15 | </include> 16 | <exclude> 17 | <directory>tests</directory> 18 | <directory>vendor</directory> 19 | </exclude> 20 | </coverage> 21 | 22 | <php> 23 | <env name="KERNEL_CLASS" value="Symfony\Component\Panther\Tests\DummyKernel"/> 24 | <env name="SYMFONY_DEPRECATIONS_HELPER" value="max[direct]=0"/> 25 | <server name="SYMFONY_PHPUNIT_VERSION" value=">=10"/> 26 | </php> 27 | 28 | <testsuites> 29 | <testsuite name="Project Test Suite"> 30 | <directory>tests</directory> 31 | </testsuite> 32 | </testsuites> 33 | 34 | </phpunit> 35 | -------------------------------------------------------------------------------- /src/Client.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | /* 4 | * This file is part of the Panther project. 5 | * 6 | * (c) Kévin Dunglas <dunglas@gmail.com> 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Symfony\Component\Panther; 15 | 16 | use Facebook\WebDriver\Exception\NoSuchElementException; 17 | use Facebook\WebDriver\Exception\TimeoutException; 18 | use Facebook\WebDriver\Exception\WebDriverException; 19 | use Facebook\WebDriver\JavaScriptExecutor; 20 | use Facebook\WebDriver\Remote\RemoteWebDriver; 21 | use Facebook\WebDriver\WebDriver; 22 | use Facebook\WebDriver\WebDriverBy; 23 | use Facebook\WebDriver\WebDriverCapabilities; 24 | use Facebook\WebDriver\WebDriverElement; 25 | use Facebook\WebDriver\WebDriverExpectedCondition; 26 | use Facebook\WebDriver\WebDriverHasInputDevices; 27 | use Facebook\WebDriver\WebDriverKeyboard; 28 | use Facebook\WebDriver\WebDriverNavigationInterface; 29 | use Facebook\WebDriver\WebDriverOptions; 30 | use Facebook\WebDriver\WebDriverTargetLocator; 31 | use Facebook\WebDriver\WebDriverWait; 32 | use Symfony\Component\BrowserKit\AbstractBrowser; 33 | use Symfony\Component\BrowserKit\History; 34 | use Symfony\Component\BrowserKit\Request; 35 | use Symfony\Component\BrowserKit\Response; 36 | use Symfony\Component\DomCrawler\Crawler; 37 | use Symfony\Component\DomCrawler\Form; 38 | use Symfony\Component\DomCrawler\Link; 39 | use Symfony\Component\Panther\Cookie\CookieJar; 40 | use Symfony\Component\Panther\DomCrawler\Crawler as PantherCrawler; 41 | use Symfony\Component\Panther\DomCrawler\Form as PantherForm; 42 | use Symfony\Component\Panther\DomCrawler\Link as PantherLink; 43 | use Symfony\Component\Panther\Exception\InvalidArgumentException; 44 | use Symfony\Component\Panther\Exception\LogicException; 45 | use Symfony\Component\Panther\ProcessManager\BrowserManagerInterface; 46 | use Symfony\Component\Panther\ProcessManager\ChromeManager; 47 | use Symfony\Component\Panther\ProcessManager\FirefoxManager; 48 | use Symfony\Component\Panther\ProcessManager\SeleniumManager; 49 | use Symfony\Component\Panther\WebDriver\PantherWebDriverExpectedCondition; 50 | use Symfony\Component\Panther\WebDriver\WebDriverMouse; 51 | 52 | /** 53 | * @author Kévin Dunglas <dunglas@gmail.com> 54 | * @author Dany Maillard <danymaillard93b@gmail.com> 55 | * 56 | * @method PantherCrawler getCrawler() 57 | */ 58 | final class Client extends AbstractBrowser implements WebDriver, JavaScriptExecutor, WebDriverHasInputDevices 59 | { 60 | use ExceptionThrower; 61 | 62 | private ?WebDriver $webDriver = null; 63 | private BrowserManagerInterface $browserManager; 64 | private ?string $baseUri = null; 65 | private bool $isFirefox = false; 66 | 67 | /** 68 | * @param string[]|null $arguments 69 | */ 70 | public static function createChromeClient(?string $chromeDriverBinary = null, ?array $arguments = null, array $options = [], ?string $baseUri = null): self 71 | { 72 | return new self(new ChromeManager($chromeDriverBinary, $arguments, $options), $baseUri); 73 | } 74 | 75 | /** 76 | * @param string[]|null $arguments 77 | */ 78 | public static function createFirefoxClient(?string $geckodriverBinary = null, ?array $arguments = null, array $options = [], ?string $baseUri = null): self 79 | { 80 | return new self(new FirefoxManager($geckodriverBinary, $arguments, $options), $baseUri); 81 | } 82 | 83 | public static function createSeleniumClient(?string $host = null, ?WebDriverCapabilities $capabilities = null, ?string $baseUri = null, array $options = []): self 84 | { 85 | return new self(new SeleniumManager($host, $capabilities, $options), $baseUri); 86 | } 87 | 88 | public function __construct(BrowserManagerInterface $browserManager, ?string $baseUri = null) 89 | { 90 | $this->browserManager = $browserManager; 91 | $this->baseUri = $baseUri; 92 | } 93 | 94 | public function getBrowserManager(): BrowserManagerInterface 95 | { 96 | return $this->browserManager; 97 | } 98 | 99 | public function __sleep(): array 100 | { 101 | throw new \BadMethodCallException('Cannot serialize '.__CLASS__); 102 | } 103 | 104 | public function __wakeup(): void 105 | { 106 | throw new \BadMethodCallException('Cannot unserialize '.__CLASS__); 107 | } 108 | 109 | public function __destruct() 110 | { 111 | try { 112 | $this->quit(); 113 | } catch (WebDriverException) { 114 | // ignore 115 | } 116 | } 117 | 118 | public function start(): void 119 | { 120 | if (null !== $this->webDriver) { 121 | return; 122 | } 123 | 124 | $this->webDriver = $this->browserManager->start(); 125 | if ($this->browserManager instanceof FirefoxManager) { 126 | $this->isFirefox = true; 127 | 128 | return; 129 | } 130 | 131 | if ($this->browserManager instanceof ChromeManager) { 132 | $this->isFirefox = false; 133 | 134 | return; 135 | } 136 | 137 | if (method_exists($this->webDriver, 'getCapabilities')) { 138 | $this->isFirefox = 'firefox' === $this->webDriver->getCapabilities()->getBrowserName(); 139 | 140 | return; 141 | } 142 | 143 | $this->isFirefox = false; 144 | } 145 | 146 | public function getRequest(): object 147 | { 148 | throw new LogicException('HttpFoundation Request object is not available when using WebDriver.'); 149 | } 150 | 151 | public function getResponse(): object 152 | { 153 | throw new LogicException('HttpFoundation Response object is not available when using WebDriver.'); 154 | } 155 | 156 | public function followRedirects($followRedirects = true): void 157 | { 158 | if (!$followRedirects) { 159 | throw new InvalidArgumentException('Redirects are always followed when using WebDriver.'); 160 | } 161 | } 162 | 163 | public function isFollowingRedirects(): bool 164 | { 165 | return true; 166 | } 167 | 168 | public function setMaxRedirects($maxRedirects): void 169 | { 170 | if (-1 !== $maxRedirects) { 171 | throw new InvalidArgumentException('There are no max redirects when using WebDriver.'); 172 | } 173 | } 174 | 175 | public function getMaxRedirects(): int 176 | { 177 | return -1; 178 | } 179 | 180 | public function insulate($insulated = true): void 181 | { 182 | if (!$insulated) { 183 | throw new InvalidArgumentException('Requests are always insulated when using WebDriver.'); 184 | } 185 | } 186 | 187 | public function setServerParameters(array $server): void 188 | { 189 | throw new InvalidArgumentException('Server parameters cannot be set when using WebDriver.'); 190 | } 191 | 192 | public function setServerParameter($key, $value): void 193 | { 194 | throw new InvalidArgumentException('Server parameters cannot be set when using WebDriver.'); 195 | } 196 | 197 | public function getServerParameter($key, $default = ''): mixed 198 | { 199 | throw new InvalidArgumentException('Server parameters cannot be retrieved when using WebDriver.'); 200 | } 201 | 202 | public function getHistory(): History 203 | { 204 | throw new LogicException('History is not available when using WebDriver.'); 205 | } 206 | 207 | public function click(Link $link, array $serverParameters = []): Crawler 208 | { 209 | if ($link instanceof PantherLink) { 210 | $link->getElement()->click(); 211 | 212 | return $this->crawler = $this->createCrawler(); 213 | } 214 | 215 | return parent::click($link, $serverParameters); 216 | } 217 | 218 | public function submit(Form $form, array $values = [], array $serverParameters = []): Crawler 219 | { 220 | if ($form instanceof PantherForm) { 221 | foreach ($values as $field => $value) { 222 | $form->get($field)->setValue($value); 223 | } 224 | 225 | $button = $form->getButton(); 226 | 227 | if ($this->isFirefox) { 228 | // For Firefox, we have to wait for the page to reload 229 | // https://github.com/SeleniumHQ/selenium/issues/4570#issuecomment-327473270 230 | $selector = WebDriverBy::cssSelector('html'); 231 | $previousId = $this->webDriver->findElement($selector)->getID(); 232 | 233 | null === $button ? $form->getElement()->submit() : $button->click(); 234 | 235 | try { 236 | $this->webDriver->wait(5)->until(static function (WebDriver $driver) use ($previousId, $selector) { 237 | try { 238 | return $previousId !== $driver->findElement($selector)->getID(); 239 | } catch (NoSuchElementException $e) { 240 | // The html element isn't already available 241 | return false; 242 | } 243 | }); 244 | } catch (TimeoutException $e) { 245 | // Probably a form using AJAX, do nothing 246 | } 247 | } else { 248 | null === $button ? $form->getElement()->submit() : $button->click(); 249 | } 250 | 251 | return $this->crawler = $this->createCrawler(); 252 | } 253 | 254 | return parent::submit($form, $values, $serverParameters); 255 | } 256 | 257 | public function refreshCrawler(): PantherCrawler 258 | { 259 | return $this->crawler = $this->createCrawler(); 260 | } 261 | 262 | public function request(string $method, string $uri, array $parameters = [], array $files = [], array $server = [], ?string $content = null, bool $changeHistory = true): PantherCrawler 263 | { 264 | if ('GET' !== $method) { 265 | throw new InvalidArgumentException('Only the GET method is supported when using WebDriver.'); 266 | } 267 | if (null !== $content) { 268 | throw new InvalidArgumentException('Setting a content is not supported when using WebDriver.'); 269 | } 270 | if (!$changeHistory) { 271 | throw new InvalidArgumentException('The history always change when using WebDriver.'); 272 | } 273 | 274 | foreach (['parameters', 'files', 'server'] as $arg) { 275 | if ([] !== $arg) { 276 | throw new InvalidArgumentException(\sprintf('The parameter "$%s" is not supported when using WebDriver.', $arg)); 277 | } 278 | } 279 | 280 | $this->get($uri); 281 | 282 | // @phpstan-ignore-next-line The above call to get() sets the proper crawler 283 | return $this->crawler; 284 | } 285 | 286 | protected function createCrawler(): PantherCrawler 287 | { 288 | $this->start(); 289 | $elements = $this->webDriver->findElements(WebDriverBy::cssSelector('html')); 290 | 291 | return new PantherCrawler($elements, $this->webDriver, $this->webDriver->getCurrentURL()); 292 | } 293 | 294 | protected function doRequest($request) 295 | { 296 | throw new LogicException('Not useful in WebDriver mode.'); 297 | } 298 | 299 | public function back(): PantherCrawler 300 | { 301 | $this->start(); 302 | $this->webDriver->navigate()->back(); 303 | 304 | return $this->crawler = $this->createCrawler(); 305 | } 306 | 307 | public function forward(): PantherCrawler 308 | { 309 | $this->start(); 310 | $this->webDriver->navigate()->forward(); 311 | 312 | return $this->crawler = $this->createCrawler(); 313 | } 314 | 315 | public function reload(): PantherCrawler 316 | { 317 | $this->start(); 318 | $this->webDriver->navigate()->refresh(); 319 | 320 | return $this->crawler = $this->createCrawler(); 321 | } 322 | 323 | public function followRedirect(): PantherCrawler 324 | { 325 | throw new LogicException('Redirects are always followed when using WebDriver.'); 326 | } 327 | 328 | public function restart(): void 329 | { 330 | if (null !== $this->webDriver) { 331 | $this->webDriver->manage()->deleteAllCookies(); 332 | } 333 | 334 | $this->quit(false); 335 | $this->start(); 336 | } 337 | 338 | public function getCookieJar(): CookieJar 339 | { 340 | $this->start(); 341 | 342 | return new CookieJar($this->webDriver); 343 | } 344 | 345 | /** 346 | * @param string $locator The path to an element to be waited for. Can be a CSS selector or Xpath expression. 347 | * 348 | * @throws NoSuchElementException 349 | * @throws TimeoutException 350 | */ 351 | public function waitFor(string $locator, int $timeoutInSecond = 30, int $intervalInMillisecond = 250): PantherCrawler 352 | { 353 | $by = self::createWebDriverByFromLocator($locator); 354 | 355 | $this->wait($timeoutInSecond, $intervalInMillisecond)->until( 356 | WebDriverExpectedCondition::presenceOfElementLocated($by), 357 | \sprintf('Element "%s" not found within %d seconds.', $locator, $timeoutInSecond), 358 | ); 359 | 360 | return $this->crawler = $this->createCrawler(); 361 | } 362 | 363 | /** 364 | * @param string $locator The path to an element that will be removed from the DOM. 365 | * Can be a CSS selector or Xpath expression. 366 | * 367 | * @throws NoSuchElementException 368 | * @throws TimeoutException 369 | */ 370 | public function waitForStaleness(string $locator, int $timeoutInSecond = 30, int $intervalInMillisecond = 250): PantherCrawler 371 | { 372 | $by = self::createWebDriverByFromLocator($locator); 373 | $element = $this->findElement($by); 374 | 375 | $this->wait($timeoutInSecond, $intervalInMillisecond)->until( 376 | WebDriverExpectedCondition::stalenessOf($element), 377 | \sprintf('Element "%s" did not become stale within %d seconds.', $locator, $timeoutInSecond), 378 | ); 379 | 380 | return $this->crawler = $this->createCrawler(); 381 | } 382 | 383 | /** 384 | * @param string $locator The path to an element to be waited for. Can be a CSS selector or Xpath expression. 385 | * 386 | * @throws NoSuchElementException 387 | * @throws TimeoutException 388 | */ 389 | public function waitForVisibility(string $locator, int $timeoutInSecond = 30, int $intervalInMillisecond = 250): PantherCrawler 390 | { 391 | $by = self::createWebDriverByFromLocator($locator); 392 | 393 | $this->wait($timeoutInSecond, $intervalInMillisecond)->until( 394 | WebDriverExpectedCondition::visibilityOfElementLocated($by), 395 | \sprintf('Element "%s" did not become visible within %d seconds.', $locator, $timeoutInSecond), 396 | ); 397 | 398 | return $this->crawler = $this->createCrawler(); 399 | } 400 | 401 | /** 402 | * @param string $locator The path to an element waited to be invisible. Can be a CSS selector or Xpath expression. 403 | * 404 | * @throws NoSuchElementException 405 | * @throws TimeoutException 406 | */ 407 | public function waitForInvisibility(string $locator, int $timeoutInSecond = 30, int $intervalInMillisecond = 250): PantherCrawler 408 | { 409 | $by = self::createWebDriverByFromLocator($locator); 410 | 411 | $this->wait($timeoutInSecond, $intervalInMillisecond)->until( 412 | WebDriverExpectedCondition::invisibilityOfElementLocated($by), 413 | \sprintf('Element "%s" did not become invisible within %d seconds.', $locator, $timeoutInSecond), 414 | ); 415 | 416 | return $this->crawler = $this->createCrawler(); 417 | } 418 | 419 | /** 420 | * @param string $locator The path to the element that will contain the given text. Can be a CSS selector or Xpath expression. 421 | * 422 | * @throws NoSuchElementException 423 | * @throws TimeoutException 424 | */ 425 | public function waitForElementToContain(string $locator, string $text, int $timeoutInSecond = 30, int $intervalInMillisecond = 250): PantherCrawler 426 | { 427 | $by = self::createWebDriverByFromLocator($locator); 428 | 429 | $this->wait($timeoutInSecond, $intervalInMillisecond)->until( 430 | WebDriverExpectedCondition::elementTextContains($by, $text), 431 | \sprintf('Element "%s" did not contain "%s" within %d seconds.', $locator, $text, $timeoutInSecond), 432 | ); 433 | 434 | return $this->crawler = $this->createCrawler(); 435 | } 436 | 437 | /** 438 | * @param string $locator The path to the element that will not contain the given text. Can be a CSS selector or Xpath expression. 439 | * 440 | * @throws NoSuchElementException 441 | * @throws TimeoutException 442 | */ 443 | public function waitForElementToNotContain(string $locator, string $text, int $timeoutInSecond = 30, int $intervalInMillisecond = 250): PantherCrawler 444 | { 445 | $by = self::createWebDriverByFromLocator($locator); 446 | 447 | $this->wait($timeoutInSecond, $intervalInMillisecond)->until( 448 | PantherWebDriverExpectedCondition::elementTextNotContains($by, $text), 449 | \sprintf('Element "%s" still contained "%s" after %d seconds.', $locator, $text, $timeoutInSecond), 450 | ); 451 | 452 | return $this->crawler = $this->createCrawler(); 453 | } 454 | 455 | /** 456 | * @param string $locator The path to the element that will have an attribute containing the given the text. Can be a CSS selector or Xpath expression. 457 | * 458 | * @throws NoSuchElementException 459 | * @throws TimeoutException 460 | */ 461 | public function waitForAttributeToContain(string $locator, string $attribute, string $text, int $timeoutInSecond = 30, int $intervalInMillisecond = 250): PantherCrawler 462 | { 463 | $by = self::createWebDriverByFromLocator($locator); 464 | 465 | $this->wait($timeoutInSecond, $intervalInMillisecond)->until( 466 | PantherWebDriverExpectedCondition::elementAttributeContains($by, $attribute, $text), 467 | \sprintf('Element "%s" attribute "%s" did not contain "%s" within %d seconds.', $locator, $attribute, $text, $timeoutInSecond), 468 | ); 469 | 470 | return $this->crawler = $this->createCrawler(); 471 | } 472 | 473 | /** 474 | * @param string $locator The path to the element that will have an attribute not containing the given the text. Can be a CSS selector or Xpath expression. 475 | * 476 | * @throws NoSuchElementException 477 | * @throws TimeoutException 478 | */ 479 | public function waitForAttributeToNotContain(string $locator, string $attribute, string $text, int $timeoutInSecond = 30, int $intervalInMillisecond = 250): PantherCrawler 480 | { 481 | $by = self::createWebDriverByFromLocator($locator); 482 | 483 | $this->wait($timeoutInSecond, $intervalInMillisecond)->until( 484 | PantherWebDriverExpectedCondition::elementAttributeNotContains($by, $attribute, $text), 485 | \sprintf('Element "%s" attribute "%s" still contained "%s" after %d seconds.', $locator, $attribute, $text, $timeoutInSecond), 486 | ); 487 | 488 | return $this->crawler = $this->createCrawler(); 489 | } 490 | 491 | /** 492 | * @param string $locator The path to the element that will be enabled. Can be a CSS selector or Xpath expression. 493 | * 494 | * @throws NoSuchElementException 495 | * @throws TimeoutException 496 | */ 497 | public function waitForEnabled(string $locator, int $timeoutInSecond = 30, int $intervalInMillisecond = 250): PantherCrawler 498 | { 499 | $by = self::createWebDriverByFromLocator($locator); 500 | 501 | $this->wait($timeoutInSecond, $intervalInMillisecond)->until( 502 | PantherWebDriverExpectedCondition::elementEnabled($by), 503 | \sprintf('Element "%s" did not become enabled within %d seconds.', $locator, $timeoutInSecond), 504 | ); 505 | 506 | return $this->crawler = $this->createCrawler(); 507 | } 508 | 509 | /** 510 | * @param string $locator The path to the element that will be disabled. Can be a CSS selector or Xpath expression. 511 | * 512 | * @throws NoSuchElementException 513 | * @throws TimeoutException 514 | */ 515 | public function waitForDisabled(string $locator, int $timeoutInSecond = 30, int $intervalInMillisecond = 250): PantherCrawler 516 | { 517 | $by = self::createWebDriverByFromLocator($locator); 518 | 519 | $this->wait($timeoutInSecond, $intervalInMillisecond)->until( 520 | PantherWebDriverExpectedCondition::elementDisabled($by), 521 | \sprintf('Element "%s" did not become disabled within %d seconds.', $locator, $timeoutInSecond), 522 | ); 523 | 524 | return $this->crawler = $this->createCrawler(); 525 | } 526 | 527 | public function getWebDriver(): WebDriver 528 | { 529 | $this->start(); 530 | 531 | return $this->webDriver; 532 | } 533 | 534 | /** 535 | * @param string $url 536 | */ 537 | public function get($url): self 538 | { 539 | $this->start(); 540 | 541 | // Prepend the base URI to URIs without a host 542 | if (null !== $this->baseUri && (false !== $components = parse_url($url)) && !isset($components['host'])) { 543 | if (str_starts_with($url, '/') && str_ends_with($this->baseUri, '/')) { 544 | $url = substr($url, 1); 545 | } elseif (!str_starts_with($url, '/') && !str_ends_with($this->baseUri, '/')) { 546 | $url = '/'.$url; 547 | } 548 | $url = $this->baseUri.$url; 549 | } 550 | 551 | $this->internalRequest = new Request($url, 'GET'); 552 | $this->webDriver->get($url); 553 | $this->internalResponse = new Response($this->webDriver->getPageSource()); 554 | 555 | $this->crawler = $this->createCrawler(); 556 | 557 | return $this; 558 | } 559 | 560 | public function close(): WebDriver 561 | { 562 | $this->start(); 563 | 564 | return $this->webDriver->close(); 565 | } 566 | 567 | public function getCurrentURL(): string 568 | { 569 | $this->start(); 570 | 571 | return $this->webDriver->getCurrentURL(); 572 | } 573 | 574 | public function getPageSource(): string 575 | { 576 | $this->start(); 577 | 578 | return $this->webDriver->getPageSource(); 579 | } 580 | 581 | public function getTitle(): string 582 | { 583 | $this->start(); 584 | 585 | return $this->webDriver->getTitle(); 586 | } 587 | 588 | public function getWindowHandle(): string 589 | { 590 | $this->start(); 591 | 592 | return $this->webDriver->getWindowHandle(); 593 | } 594 | 595 | public function getWindowHandles(): array 596 | { 597 | $this->start(); 598 | 599 | return $this->webDriver->getWindowHandles(); 600 | } 601 | 602 | public function quit(bool $quitBrowserManager = true): void 603 | { 604 | if (null !== $this->webDriver) { 605 | $this->webDriver->quit(); 606 | $this->webDriver = null; 607 | } 608 | 609 | if ($quitBrowserManager) { 610 | $this->browserManager->quit(); 611 | } 612 | } 613 | 614 | public function takeScreenshot($saveAs = null): string 615 | { 616 | $this->start(); 617 | 618 | return $this->webDriver->takeScreenshot($saveAs); 619 | } 620 | 621 | /** 622 | * @param int $timeoutInSecond 623 | * @param int $intervalInMillisecond 624 | */ 625 | public function wait($timeoutInSecond = 30, $intervalInMillisecond = 250): WebDriverWait 626 | { 627 | $this->start(); 628 | 629 | return $this->webDriver->wait($timeoutInSecond, $intervalInMillisecond); 630 | } 631 | 632 | public function manage(): WebDriverOptions 633 | { 634 | $this->start(); 635 | 636 | return $this->webDriver->manage(); 637 | } 638 | 639 | public function navigate(): WebDriverNavigationInterface 640 | { 641 | $this->start(); 642 | 643 | return $this->webDriver->navigate(); 644 | } 645 | 646 | public function switchTo(): WebDriverTargetLocator 647 | { 648 | $this->start(); 649 | 650 | return $this->webDriver->switchTo(); 651 | } 652 | 653 | /** 654 | * @param string $name 655 | * @param array $params 656 | */ 657 | public function execute($name, $params): mixed 658 | { 659 | $this->start(); 660 | 661 | return $this->webDriver->execute($name, $params); 662 | } 663 | 664 | public function findElement(WebDriverBy $locator): WebDriverElement 665 | { 666 | $this->start(); 667 | 668 | return $this->webDriver->findElement($locator); 669 | } 670 | 671 | /** 672 | * @return WebDriverElement[] 673 | */ 674 | public function findElements(WebDriverBy $locator): array 675 | { 676 | $this->start(); 677 | 678 | return $this->webDriver->findElements($locator); 679 | } 680 | 681 | /** 682 | * @param string $script 683 | * 684 | * @throws \Exception 685 | */ 686 | public function executeScript($script, array $arguments = []): mixed 687 | { 688 | if (!$this->webDriver instanceof JavaScriptExecutor) { 689 | throw $this->createException(JavaScriptExecutor::class); 690 | } 691 | 692 | return $this->webDriver->executeScript($script, $arguments); 693 | } 694 | 695 | /** 696 | * @param string $script 697 | * 698 | * @throws \Exception 699 | */ 700 | public function executeAsyncScript($script, array $arguments = []): mixed 701 | { 702 | if (!$this->webDriver instanceof JavaScriptExecutor) { 703 | throw $this->createException(JavaScriptExecutor::class); 704 | } 705 | 706 | return $this->webDriver->executeAsyncScript($script, $arguments); 707 | } 708 | 709 | /** 710 | * @throws \Exception 711 | */ 712 | public function getKeyboard(): WebDriverKeyboard 713 | { 714 | if (!$this->webDriver instanceof WebDriverHasInputDevices) { 715 | throw $this->createException(WebDriverHasInputDevices::class); 716 | } 717 | 718 | return $this->webDriver->getKeyboard(); 719 | } 720 | 721 | /** 722 | * @throws \Exception 723 | */ 724 | public function getMouse(): WebDriverMouse 725 | { 726 | if (!$this->webDriver instanceof WebDriverHasInputDevices) { 727 | throw $this->createException(WebDriverHasInputDevices::class); 728 | } 729 | 730 | return new WebDriverMouse($this->webDriver->getMouse(), $this); 731 | } 732 | 733 | /** 734 | * @internal 735 | */ 736 | public static function createWebDriverByFromLocator(string $locator): WebDriverBy 737 | { 738 | $locator = trim($locator); 739 | 740 | return '' === $locator || '/' !== $locator[0] 741 | ? WebDriverBy::cssSelector($locator) 742 | : WebDriverBy::xpath($locator); 743 | } 744 | 745 | /** 746 | * Checks the web driver connection (and logs "pong" into the DevTools console). 747 | * 748 | * @param int $timeout sets the connection/request timeout in ms 749 | * 750 | * @return bool true if connected, false otherwise 751 | */ 752 | public function ping(int $timeout = 1000): bool 753 | { 754 | if (null === $this->webDriver) { 755 | return false; 756 | } 757 | 758 | if ($this->webDriver instanceof RemoteWebDriver) { 759 | $this 760 | ->webDriver 761 | ->getCommandExecutor() 762 | ->setConnectionTimeout($timeout) 763 | ->setRequestTimeout($timeout); 764 | } 765 | 766 | try { 767 | if ($this->webDriver instanceof JavaScriptExecutor) { 768 | $this->webDriver->executeScript('console.log("pong");'); 769 | } else { 770 | $this->webDriver->findElement(WebDriverBy::tagName('html')); 771 | } 772 | } catch (\Exception $e) { 773 | return false; 774 | } finally { 775 | if ($this->webDriver instanceof RemoteWebDriver) { 776 | $this 777 | ->webDriver 778 | ->getCommandExecutor() 779 | ->setConnectionTimeout(0) 780 | ->setRequestTimeout(0); 781 | } 782 | } 783 | 784 | return true; 785 | } 786 | 787 | /** 788 | * @return \LogicException|\RuntimeException 789 | */ 790 | private function createException(string $implementableClass): \Exception 791 | { 792 | if (null === $this->webDriver) { 793 | return new \LogicException(\sprintf('WebDriver not started yet. Call method `start()` first before calling any `%s` method.', $implementableClass)); 794 | } 795 | 796 | return new \RuntimeException(\sprintf('"%s" does not implement "%s".', \get_class($this->webDriver), $implementableClass)); 797 | } 798 | } 799 | -------------------------------------------------------------------------------- /src/Cookie/CookieJar.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | /* 4 | * This file is part of the Panther project. 5 | * 6 | * (c) Kévin Dunglas <dunglas@gmail.com> 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Symfony\Component\Panther\Cookie; 15 | 16 | use Facebook\WebDriver\Cookie as WebDriverCookie; 17 | use Facebook\WebDriver\Exception\NoSuchCookieException; 18 | use Facebook\WebDriver\WebDriver; 19 | use Symfony\Component\BrowserKit\Cookie; 20 | use Symfony\Component\BrowserKit\CookieJar as BaseCookieJar; 21 | use Symfony\Component\BrowserKit\Response; 22 | use Symfony\Component\Panther\ExceptionThrower; 23 | 24 | /** 25 | * @author Kévin Dunglas <dunglas@gmail.com> 26 | */ 27 | final class CookieJar extends BaseCookieJar 28 | { 29 | use ExceptionThrower; 30 | 31 | private WebDriver $webDriver; 32 | 33 | public function __construct(WebDriver $webDriver) 34 | { 35 | $this->webDriver = $webDriver; 36 | } 37 | 38 | public function set(Cookie $cookie): void 39 | { 40 | $this->webDriver->manage()->addCookie($this->symfonyToWebDriver($cookie)); 41 | } 42 | 43 | public function get($name, $path = '/', $domain = null): ?Cookie 44 | { 45 | if (null === $cookie = $this->getWebDriverCookie($name, $path, $domain)) { 46 | return null; 47 | } 48 | 49 | return $this->webDriverToSymfony($cookie); 50 | } 51 | 52 | public function expire($name, $path = '/', $domain = null): void 53 | { 54 | if (null !== $this->getWebDriverCookie($name, $path, $domain)) { 55 | $this->webDriver->manage()->deleteCookieNamed($name); 56 | } 57 | } 58 | 59 | public function clear(): void 60 | { 61 | $this->webDriver->manage()->deleteAllCookies(); 62 | } 63 | 64 | public function updateFromSetCookie(array $setCookies, $uri = null): void 65 | { 66 | throw $this->createNotSupportedException(__METHOD__); 67 | } 68 | 69 | public function updateFromResponse(Response $response, $uri = null): void 70 | { 71 | throw $this->createNotSupportedException(__METHOD__); 72 | } 73 | 74 | public function all(): array 75 | { 76 | $cookies = []; 77 | foreach ($this->webDriver->manage()->getCookies() as $webDriverCookie) { 78 | $cookies[] = $this->webDriverToSymfony($webDriverCookie); 79 | } 80 | 81 | return $cookies; 82 | } 83 | 84 | public function allValues($uri, $returnsRawValue = false): array 85 | { 86 | throw $this->createNotSupportedException(__METHOD__); 87 | } 88 | 89 | public function allRawValues($uri): array 90 | { 91 | throw $this->createNotSupportedException(__METHOD__); 92 | } 93 | 94 | public function flushExpiredCookies(): void 95 | { 96 | throw $this->createNotSupportedException(__METHOD__); 97 | } 98 | 99 | private function symfonyToWebDriver(Cookie $cookie): WebDriverCookie 100 | { 101 | $webDriverCookie = new WebDriverCookie($cookie->getName(), $cookie->getValue()); 102 | 103 | if ('' !== $domain = $cookie->getDomain()) { 104 | $webDriverCookie->setDomain($domain); 105 | } 106 | 107 | if (null !== $expiresTime = $cookie->getExpiresTime()) { 108 | $webDriverCookie->setExpiry((int) $expiresTime); 109 | } 110 | 111 | if ('/' !== $path = $cookie->getPath()) { 112 | $webDriverCookie->setPath($path); 113 | } 114 | 115 | if ($cookie->isHttpOnly()) { 116 | $webDriverCookie->setHttpOnly(true); 117 | } 118 | 119 | if ($cookie->isSecure()) { 120 | $webDriverCookie->setSecure(true); 121 | } 122 | 123 | return $webDriverCookie; 124 | } 125 | 126 | private function webDriverToSymfony(WebDriverCookie $cookie): Cookie 127 | { 128 | $expiry = $cookie->getExpiry(); 129 | if (null !== $expiry) { 130 | $expiry = (string) $expiry; 131 | } 132 | 133 | return new Cookie($cookie->getName(), $cookie->getValue(), $expiry, $cookie->getPath(), (string) $cookie->getDomain(), (bool) $cookie->isSecure(), (bool) $cookie->isHttpOnly()); 134 | } 135 | 136 | private function getWebDriverCookie(string $name, string $path = '/', ?string $domain = null): ?WebDriverCookie 137 | { 138 | try { 139 | $cookie = $this->webDriver->manage()->getCookieNamed($name); 140 | } catch (NoSuchCookieException $e) { 141 | return null; 142 | } 143 | 144 | if (null === $cookie) { 145 | return null; 146 | } 147 | 148 | $cookiePath = $cookie->getPath() ?? '/'; 149 | if (!str_starts_with($path, $cookiePath)) { 150 | return null; 151 | } 152 | 153 | $cookieDomain = $cookie->getDomain(); 154 | if (null === $domain || null === $cookieDomain) { 155 | return $cookie; 156 | } 157 | 158 | $cookieDomain = '.'.ltrim($cookieDomain, '.'); 159 | if ($cookieDomain !== substr('.'.$domain, -\strlen($cookieDomain))) { 160 | return null; 161 | } 162 | 163 | return $cookie; 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/DomCrawler/Crawler.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | /* 4 | * This file is part of the Panther project. 5 | * 6 | * (c) Kévin Dunglas <dunglas@gmail.com> 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Symfony\Component\Panther\DomCrawler; 15 | 16 | use Facebook\WebDriver\Exception\NoSuchElementException; 17 | use Facebook\WebDriver\WebDriver; 18 | use Facebook\WebDriver\WebDriverBy; 19 | use Facebook\WebDriver\WebDriverElement; 20 | use Symfony\Component\CssSelector\CssSelectorConverter; 21 | use Symfony\Component\DomCrawler\Crawler as BaseCrawler; 22 | use Symfony\Component\Panther\Exception\InvalidArgumentException; 23 | use Symfony\Component\Panther\Exception\LogicException; 24 | use Symfony\Component\Panther\ExceptionThrower; 25 | 26 | /** 27 | * @author Kévin Dunglas <dunglas@gmail.com> 28 | */ 29 | final class Crawler extends BaseCrawler implements WebDriverElement 30 | { 31 | use ExceptionThrower; 32 | 33 | private array $elements; 34 | private ?WebDriver $webDriver; 35 | 36 | /** 37 | * @param WebDriverElement[] $elements 38 | */ 39 | public function __construct(array $elements = [], ?WebDriver $webDriver = null, ?string $uri = null) 40 | { 41 | $this->uri = $uri; 42 | $this->webDriver = $webDriver; 43 | $this->elements = $elements; 44 | } 45 | 46 | public function clear(): void 47 | { 48 | throw $this->createNotSupportedException(__METHOD__); 49 | } 50 | 51 | public function add($node): void 52 | { 53 | throw $this->createNotSupportedException(__METHOD__); 54 | } 55 | 56 | public function addContent($content, $type = null): void 57 | { 58 | throw $this->createNotSupportedException(__METHOD__); 59 | } 60 | 61 | public function addHtmlContent($content, $charset = 'UTF-8'): void 62 | { 63 | throw $this->createNotSupportedException(__METHOD__); 64 | } 65 | 66 | public function addXmlContent($content, $charset = 'UTF-8', $options = \LIBXML_NONET): void 67 | { 68 | throw $this->createNotSupportedException(__METHOD__); 69 | } 70 | 71 | public function addDocument(\DOMDocument $dom): void 72 | { 73 | throw $this->createNotSupportedException(__METHOD__); 74 | } 75 | 76 | public function addNodeList(\DOMNodeList $nodes): void 77 | { 78 | throw $this->createNotSupportedException(__METHOD__); 79 | } 80 | 81 | public function addNodes(array $nodes): void 82 | { 83 | throw $this->createNotSupportedException(__METHOD__); 84 | } 85 | 86 | public function addNode(\DOMNode $node): void 87 | { 88 | throw $this->createNotSupportedException(__METHOD__); 89 | } 90 | 91 | public function eq($position): static 92 | { 93 | if (isset($this->elements[$position])) { 94 | return $this->createSubCrawler([$this->elements[$position]]); 95 | } 96 | 97 | return $this->createSubCrawler(); 98 | } 99 | 100 | public function each(\Closure $closure): array 101 | { 102 | $data = []; 103 | foreach ($this->elements as $i => $element) { 104 | $data[] = $closure($this->createSubCrawler([$element]), $i); 105 | } 106 | 107 | return $data; 108 | } 109 | 110 | public function slice($offset = 0, $length = null): static 111 | { 112 | return $this->createSubCrawler(\array_slice($this->elements, $offset, $length)); 113 | } 114 | 115 | public function reduce(\Closure $closure): static 116 | { 117 | $elements = []; 118 | foreach ($this->elements as $i => $element) { 119 | if (false !== $closure($this->createSubCrawler([$element]), $i)) { 120 | $elements[] = $element; 121 | } 122 | } 123 | 124 | return $this->createSubCrawler($elements); 125 | } 126 | 127 | public function last(): static 128 | { 129 | return $this->eq(\count($this->elements) - 1); 130 | } 131 | 132 | public function siblings(): static 133 | { 134 | return $this->createSubCrawlerFromXpath('(preceding-sibling::* | following-sibling::*)'); 135 | } 136 | 137 | public function matches(string $selector): bool 138 | { 139 | $converter = $this->createCssSelectorConverter(); 140 | $xpath = $converter->toXPath($selector, 'self::'); 141 | 142 | return $this->filterXPath($xpath)->count() > 0; 143 | } 144 | 145 | public function closest(string $selector): ?self 146 | { 147 | $converter = $this->createCssSelectorConverter(); 148 | $xpath = WebDriverBy::xpath($converter->toXPath($selector, 'self::')); 149 | 150 | /** @var WebDriverElement[] $elements */ 151 | $elements = [...$this->elements, ...$this->ancestors()->elements]; 152 | foreach ($elements as $element) { 153 | try { 154 | $element->findElement($xpath); 155 | 156 | return $this->createSubCrawler([$element]); 157 | } catch (NoSuchElementException) { 158 | } 159 | } 160 | 161 | return null; 162 | } 163 | 164 | public function nextAll(): static 165 | { 166 | return $this->createSubCrawlerFromXpath('following-sibling::*'); 167 | } 168 | 169 | public function previousAll(): static 170 | { 171 | return $this->createSubCrawlerFromXpath('preceding-sibling::*'); 172 | } 173 | 174 | public function ancestors(): static 175 | { 176 | return $this->createSubCrawlerFromXpath('ancestor::*', true); 177 | } 178 | 179 | /** 180 | * @see https://github.com/symfony/symfony/issues/26432 181 | */ 182 | public function children(?string $selector = null): static 183 | { 184 | $xpath = 'child::*'; 185 | if (null !== $selector) { 186 | $converter = $this->createCssSelectorConverter(); 187 | $xpath = $converter->toXPath($selector, 'child::'); 188 | } 189 | 190 | return $this->createSubCrawlerFromXpath($xpath); 191 | } 192 | 193 | public function attr($attribute, $default = null): ?string 194 | { 195 | $element = $this->getElementOrThrow(); 196 | if ('_text' === $attribute) { 197 | return $this->text(); 198 | } 199 | 200 | return $element->getAttribute($attribute) ?? $default; 201 | } 202 | 203 | public function nodeName(): string 204 | { 205 | return $this->getElementOrThrow()->getTagName(); 206 | } 207 | 208 | public function text(?string $default = null, bool $normalizeWhitespace = true): string 209 | { 210 | if (!$normalizeWhitespace) { 211 | throw new InvalidArgumentException('Panther only supports getting normalized text.'); 212 | } 213 | 214 | try { 215 | return $this->getElementOrThrow()->getText(); 216 | } catch (InvalidArgumentException $e) { 217 | if (null === $default) { 218 | throw $e; 219 | } 220 | 221 | return $default; 222 | } 223 | } 224 | 225 | public function html(?string $default = null): string 226 | { 227 | try { 228 | $element = $this->getElementOrThrow(); 229 | 230 | if ('html' === $element->getTagName()) { 231 | return $this->webDriver->getPageSource(); 232 | } 233 | 234 | return $this->attr('outerHTML', (string) $default); 235 | } catch (InvalidArgumentException $e) { 236 | if (null === $default) { 237 | throw $e; 238 | } 239 | 240 | return $default; 241 | } 242 | } 243 | 244 | public function evaluate($xpath): static 245 | { 246 | throw $this->createNotSupportedException(__METHOD__); 247 | } 248 | 249 | public function extract($attributes): array 250 | { 251 | $count = \count($attributes); 252 | 253 | $data = []; 254 | foreach ($this->elements as $element) { 255 | $elements = []; 256 | foreach ($attributes as $attribute) { 257 | $elements[] = '_text' === $attribute ? $element->getText() : (string) $element->getAttribute($attribute); 258 | } 259 | 260 | $data[] = 1 === $count ? $elements[0] : $elements; 261 | } 262 | 263 | return $data; 264 | } 265 | 266 | public function filterXPath($xpath): static 267 | { 268 | return $this->filterWebDriverBy(WebDriverBy::xpath($xpath)); 269 | } 270 | 271 | public function filter($selector): static 272 | { 273 | return $this->filterWebDriverBy(WebDriverBy::cssSelector($selector)); 274 | } 275 | 276 | public function selectLink($value): static 277 | { 278 | return $this->selectFromXpath( 279 | \sprintf('descendant-or-self::a[contains(concat(\' \', normalize-space(string(.)), \' \'), %1$s) or ./img[contains(concat(\' \', normalize-space(string(@alt)), \' \'), %1$s)]]', self::xpathLiteral(' '.$value.' ')) 280 | ); 281 | } 282 | 283 | public function selectImage($value): static 284 | { 285 | return $this->selectFromXpath(\sprintf('descendant-or-self::img[contains(normalize-space(string(@alt)), %s)]', self::xpathLiteral($value))); 286 | } 287 | 288 | public function selectButton($value): static 289 | { 290 | return $this->selectFromXpath( 291 | \sprintf( 292 | 'descendant-or-self::input[((contains(%1$s, "submit") or contains(%1$s, "button")) and contains(concat(\' \', normalize-space(string(@value)), \' \'), %2$s)) or (contains(%1$s, "image") and contains(concat(\' \', normalize-space(string(@alt)), \' \'), %2$s)) or @id=%3$s or @name=%3$s] | descendant-or-self::button[contains(concat(\' \', normalize-space(string(.)), \' \'), %2$s) or @id=%3$s or @name=%3$s]', 293 | 'translate(@type, "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz")', 294 | self::xpathLiteral(' '.$value.' '), 295 | self::xpathLiteral($value) 296 | ) 297 | ); 298 | } 299 | 300 | public function link($method = 'get'): Link 301 | { 302 | $element = $this->getElementOrThrow(); 303 | if ('get' !== $method) { 304 | throw new InvalidArgumentException('Only the "get" method is supported in WebDriver mode.'); 305 | } 306 | 307 | return new Link($element, $this->webDriver->getCurrentURL()); 308 | } 309 | 310 | public function links(): array 311 | { 312 | $links = []; 313 | foreach ($this->elements as $element) { 314 | $links[] = new Link($element, $this->webDriver->getCurrentURL()); 315 | } 316 | 317 | return $links; 318 | } 319 | 320 | public function image(): Image 321 | { 322 | return new Image($this->getElementOrThrow()); 323 | } 324 | 325 | public function images(): array 326 | { 327 | $images = []; 328 | foreach ($this->elements as $element) { 329 | $images[] = new Image($element); 330 | } 331 | 332 | return $images; 333 | } 334 | 335 | public function form(?array $values = null, $method = null): Form 336 | { 337 | $form = new Form($this->getElementOrThrow(), $this->webDriver); 338 | if (null !== $values) { 339 | $form->setValues($values); 340 | } 341 | 342 | return $form; 343 | } 344 | 345 | public function setDefaultNamespacePrefix($prefix): void 346 | { 347 | throw $this->createNotSupportedException(__METHOD__); 348 | } 349 | 350 | public function registerNamespace($prefix, $namespace): void 351 | { 352 | throw $this->createNotSupportedException(__METHOD__); 353 | } 354 | 355 | public function getNode($position): ?\DOMElement 356 | { 357 | throw new InvalidArgumentException('The "getNode" method cannot be used in WebDriver mode. Use "getElement" instead.'); 358 | } 359 | 360 | public function getElement(int $position): ?WebDriverElement 361 | { 362 | return $this->elements[$position] ?? null; 363 | } 364 | 365 | public function count(): int 366 | { 367 | return \count($this->elements); 368 | } 369 | 370 | /** 371 | * @return \ArrayIterator<int, WebDriverElement> 372 | */ 373 | public function getIterator(): \ArrayIterator 374 | { 375 | return new \ArrayIterator($this->elements); 376 | } 377 | 378 | protected function sibling($node, $siblingDir = 'nextSibling'): array 379 | { 380 | throw $this->createNotSupportedException(__METHOD__); 381 | } 382 | 383 | private function selectFromXpath(string $xpath): self 384 | { 385 | $selector = WebDriverBy::xpath($xpath); 386 | 387 | $data = []; 388 | foreach ($this->elements as $element) { 389 | $data[] = $element->findElements($selector); 390 | } 391 | 392 | return $this->createSubCrawler(array_merge([], ...$data)); 393 | } 394 | 395 | /** 396 | * @param WebDriverElement[]|null $nodes 397 | */ 398 | private function createSubCrawler(?array $nodes = null): self 399 | { 400 | return new self($nodes ?? [], $this->webDriver, $this->uri); 401 | } 402 | 403 | private function createSubCrawlerFromXpath(string $selector, bool $reverse = false): self 404 | { 405 | try { 406 | $elements = $this->getElementOrThrow()->findElements(WebDriverBy::xpath($selector)); 407 | } catch (NoSuchElementException $e) { 408 | return $this->createSubCrawler(); 409 | } 410 | 411 | return $this->createSubCrawler($reverse ? array_reverse($elements) : $elements); 412 | } 413 | 414 | private function filterWebDriverBy(WebDriverBy $selector): self 415 | { 416 | $subElements = []; 417 | foreach ($this->elements as $element) { 418 | $subElements[] = $element->findElements($selector); 419 | } 420 | 421 | return $this->createSubCrawler(array_merge([], ...$subElements)); 422 | } 423 | 424 | private function getElementOrThrow(): WebDriverElement 425 | { 426 | $element = $this->getElement(0); 427 | if (!$element) { 428 | throw new InvalidArgumentException('The current node list is empty.'); 429 | } 430 | 431 | return $element; 432 | } 433 | 434 | public function click(): WebDriverElement 435 | { 436 | return $this->getElementOrThrow()->click(); 437 | } 438 | 439 | public function getAttribute($attributeName): ?string 440 | { 441 | return $this->getElementOrThrow()->getAttribute($attributeName); 442 | } 443 | 444 | public function getCSSValue($cssPropertyName): string 445 | { 446 | return $this->getElementOrThrow()->getCSSValue($cssPropertyName); 447 | } 448 | 449 | public function getLocation(): \Facebook\WebDriver\WebDriverPoint 450 | { 451 | return $this->getElementOrThrow()->getLocation(); 452 | } 453 | 454 | public function getLocationOnScreenOnceScrolledIntoView(): \Facebook\WebDriver\WebDriverPoint 455 | { 456 | return $this->getElementOrThrow()->getLocationOnScreenOnceScrolledIntoView(); 457 | } 458 | 459 | public function getSize(): \Facebook\WebDriver\WebDriverDimension 460 | { 461 | return $this->getElementOrThrow()->getSize(); 462 | } 463 | 464 | public function getTagName(): string 465 | { 466 | return $this->getElementOrThrow()->getTagName(); 467 | } 468 | 469 | public function getText(): string 470 | { 471 | return $this->getElementOrThrow()->getText(); 472 | } 473 | 474 | public function isDisplayed(): bool 475 | { 476 | return $this->getElementOrThrow()->isDisplayed(); 477 | } 478 | 479 | public function isEnabled(): bool 480 | { 481 | return $this->getElementOrThrow()->isEnabled(); 482 | } 483 | 484 | public function isSelected(): bool 485 | { 486 | return $this->getElementOrThrow()->isSelected(); 487 | } 488 | 489 | public function sendKeys($value): WebDriverElement 490 | { 491 | return $this->getElementOrThrow()->sendKeys($value); 492 | } 493 | 494 | public function submit(): WebDriverElement 495 | { 496 | return $this->getElementOrThrow()->submit(); 497 | } 498 | 499 | public function getID(): string 500 | { 501 | return $this->getElementOrThrow()->getID(); 502 | } 503 | 504 | public function findElement(WebDriverBy $locator): WebDriverElement 505 | { 506 | return $this->getElementOrThrow()->findElement($locator); 507 | } 508 | 509 | public function findElements(WebDriverBy $locator): array 510 | { 511 | return $this->getElementOrThrow()->findElements($locator); 512 | } 513 | 514 | /** 515 | * @throws LogicException If the CssSelector Component is not available 516 | */ 517 | private function createCssSelectorConverter(): CssSelectorConverter 518 | { 519 | if (!class_exists(CssSelectorConverter::class)) { 520 | throw new LogicException('To filter with a CSS selector, install the CssSelector component ("composer require symfony/css-selector"). Or use filterXpath instead.'); 521 | } 522 | 523 | return new CssSelectorConverter(); 524 | } 525 | } 526 | -------------------------------------------------------------------------------- /src/DomCrawler/Field/ChoiceFormField.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | /* 4 | * This file is part of the Panther project. 5 | * 6 | * (c) Kévin Dunglas <dunglas@gmail.com> 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Symfony\Component\Panther\DomCrawler\Field; 15 | 16 | use Facebook\WebDriver\WebDriverSelect; 17 | use Facebook\WebDriver\WebDriverSelectInterface; 18 | use Symfony\Component\DomCrawler\Field\ChoiceFormField as BaseChoiceFormField; 19 | use Symfony\Component\Panther\Exception\InvalidArgumentException; 20 | use Symfony\Component\Panther\Exception\LogicException; 21 | use Symfony\Component\Panther\WebDriver\WebDriverCheckbox; 22 | 23 | /** 24 | * @author Kévin Dunglas <dunglas@gmail.com> 25 | */ 26 | final class ChoiceFormField extends BaseChoiceFormField 27 | { 28 | use FormFieldTrait; 29 | 30 | private string $type; 31 | private WebDriverSelectInterface $selector; 32 | 33 | public function hasValue(): bool 34 | { 35 | if (\count($this->selector->getAllSelectedOptions())) { 36 | return true; 37 | } 38 | 39 | return $this->element->isSelected(); 40 | } 41 | 42 | public function select($value): void 43 | { 44 | foreach ((array) $value as $v) { 45 | $this->selector->selectByValue($v); 46 | } 47 | } 48 | 49 | /** 50 | * Ticks a checkbox. 51 | * 52 | * @throws LogicException When the type provided is not correct 53 | */ 54 | public function tick(): void 55 | { 56 | if ('checkbox' !== $type = $this->element->getAttribute('type')) { 57 | throw new LogicException(\sprintf('You cannot tick "%s" as it is not a checkbox (%s).', $this->element->getAttribute('name'), $type)); 58 | } 59 | 60 | $this->setValue(true); 61 | } 62 | 63 | /** 64 | * Ticks a checkbox. 65 | * 66 | * @throws LogicException When the type provided is not correct 67 | */ 68 | public function untick(): void 69 | { 70 | if ('checkbox' !== $type = $this->element->getAttribute('type')) { 71 | throw new LogicException(\sprintf('You cannot tick "%s" as it is not a checkbox (%s).', $this->element->getAttribute('name'), $type)); 72 | } 73 | 74 | $this->setValue(false); 75 | } 76 | 77 | public function getValue(): array|string|null 78 | { 79 | $type = $this->element->getAttribute('type'); 80 | 81 | if (!$this->hasValue()) { 82 | return $this->isMultiple() && 'checkbox' !== $type ? [] : null; 83 | } 84 | 85 | if ($this->isMultiple()) { 86 | $value = []; 87 | foreach ($this->selector->getAllSelectedOptions() as $selectedOption) { 88 | if ($selectedOption->isSelected()) { 89 | $value[] = $selectedOption->getAttribute('value'); 90 | } 91 | } 92 | 93 | $count = \count($value); 94 | if (1 === $count && 'checkbox' === $type) { 95 | return current($value); 96 | } 97 | 98 | return $value; 99 | } 100 | 101 | if (\count($this->selector->getAllSelectedOptions())) { 102 | return $this->selector->getFirstSelectedOption()->getAttribute('value'); 103 | } 104 | 105 | return $this->element->getAttribute('value'); 106 | } 107 | 108 | /** 109 | * Sets the value of the field. 110 | * 111 | * @param string|array|bool $value The value of the field 112 | * 113 | * @throws InvalidArgumentException When value type provided is not correct 114 | */ 115 | public function setValue($value): void 116 | { 117 | if (\is_bool($value)) { 118 | if ('checkbox' !== $this->type) { 119 | throw new InvalidArgumentException(\sprintf('Invalid argument of type "%s"', \gettype($value))); 120 | } 121 | 122 | if ($value) { 123 | if (!$this->element->isSelected()) { 124 | $this->element->click(); 125 | } 126 | } elseif ($this->element->isSelected()) { 127 | $this->element->click(); 128 | } 129 | 130 | return; 131 | } 132 | 133 | foreach ((array) $value as $v) { 134 | $this->selector->selectByValue($v); 135 | } 136 | } 137 | 138 | public function addChoice(\DOMElement $node): void 139 | { 140 | throw $this->createNotSupportedException(__METHOD__); 141 | } 142 | 143 | /** 144 | * Returns the type of the choice field (radio, select, or checkbox). 145 | * 146 | * @return string The type 147 | */ 148 | public function getType(): string 149 | { 150 | return $this->type; 151 | } 152 | 153 | /** 154 | * Returns true if the field accepts multiple values. 155 | * 156 | * @return bool true if the field accepts multiple values, false otherwise 157 | */ 158 | public function isMultiple(): bool 159 | { 160 | return $this->selector->isMultiple(); 161 | } 162 | 163 | /** 164 | * Returns list of available field options. 165 | */ 166 | public function availableOptionValues(): array 167 | { 168 | $options = []; 169 | 170 | foreach ($this->selector->getOptions() as $option) { 171 | $options[] = $option->getAttribute('value'); 172 | } 173 | 174 | return $options; 175 | } 176 | 177 | /** 178 | * Disables the internal validation of the field. 179 | */ 180 | public function disableValidation(): static 181 | { 182 | throw $this->createNotSupportedException(__METHOD__); 183 | } 184 | 185 | /** 186 | * Initializes the form field. 187 | * 188 | * @throws LogicException When node type is incorrect 189 | */ 190 | protected function initialize(): void 191 | { 192 | $tagName = $this->element->getTagName(); 193 | if ('input' !== $tagName && 'select' !== $tagName) { 194 | throw new LogicException(\sprintf('A ChoiceFormField can only be created from an input or select tag (%s given).', $tagName)); 195 | } 196 | 197 | $type = strtolower((string) $this->element->getAttribute('type')); 198 | if ('input' === $tagName && 'checkbox' !== $type && 'radio' !== $type) { 199 | throw new LogicException(\sprintf('A ChoiceFormField can only be created from an input tag with a type of checkbox or radio (given type is %s).', $type)); 200 | } 201 | 202 | $this->type = 'select' === $tagName ? 'select' : $type; 203 | $this->selector = 'select' === $this->type ? new WebDriverSelect($this->element) : new WebDriverCheckbox($this->element); 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /src/DomCrawler/Field/FileFormField.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | /* 4 | * This file is part of the Panther project. 5 | * 6 | * (c) Kévin Dunglas <dunglas@gmail.com> 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Symfony\Component\Panther\DomCrawler\Field; 15 | 16 | use Symfony\Component\DomCrawler\Field\FileFormField as BaseFileFormField; 17 | use Symfony\Component\Panther\Exception\LogicException; 18 | 19 | /** 20 | * @author Robert Freigang <robertfreigang@gmx.de> 21 | */ 22 | final class FileFormField extends BaseFileFormField 23 | { 24 | use FormFieldTrait; 25 | 26 | public function getValue(): array|string|null 27 | { 28 | return $this->value; 29 | } 30 | 31 | public function setValue($value): void 32 | { 33 | $value = $this->sanitizeValue($value); 34 | 35 | if (null !== $value && is_readable($value)) { 36 | $error = \UPLOAD_ERR_OK; 37 | $size = filesize($value); 38 | $name = pathinfo($value, \PATHINFO_BASENAME); 39 | 40 | $this->setFilePath($value); 41 | $value = $this->element->getAttribute('value'); 42 | } else { 43 | $error = \UPLOAD_ERR_NO_FILE; 44 | $size = 0; 45 | $name = ''; 46 | $value = ''; 47 | } 48 | 49 | $this->value = ['name' => $name, 'type' => '', 'tmp_name' => $value, 'error' => $error, 'size' => $size]; 50 | } 51 | 52 | /** 53 | * Sets path to the file as string for simulating HTTP request. 54 | * 55 | * @param string $path The path to the file 56 | */ 57 | public function setFilePath(string $path): void 58 | { 59 | $this->element->sendKeys($this->sanitizeValue($path)); 60 | } 61 | 62 | /** 63 | * Initializes the form field. 64 | * 65 | * @throws LogicException When node type is incorrect 66 | */ 67 | protected function initialize(): void 68 | { 69 | $tagName = $this->element->getTagName(); 70 | if ('input' !== $tagName) { 71 | throw new LogicException(\sprintf('A FileFormField can only be created from an input tag (%s given).', $tagName)); 72 | } 73 | 74 | $type = strtolower($this->element->getAttribute('type')); 75 | if ('file' !== $type) { 76 | throw new LogicException(\sprintf('A FileFormField can only be created from an input tag with a type of file (given type is %s).', $type)); 77 | } 78 | 79 | $value = $this->element->getAttribute('value'); 80 | if ($value) { 81 | $this->setValueFromTmp($value); 82 | } else { 83 | $this->setValue(null); 84 | } 85 | } 86 | 87 | private function setValueFromTmp(string $tmpValue): void 88 | { 89 | $value = $tmpValue; 90 | $error = \UPLOAD_ERR_OK; 91 | // size not determinable 92 | $size = 0; 93 | // C:\fakepath\filename.extension 94 | $basename = pathinfo($value, \PATHINFO_BASENAME); 95 | $nameParts = explode('\\', $basename); 96 | $name = end($nameParts); 97 | 98 | $this->value = ['name' => $name, 'type' => '', 'tmp_name' => $value, 'error' => $error, 'size' => $size]; 99 | } 100 | 101 | private function sanitizeValue(?string $value): ?string 102 | { 103 | $realpathValue = \is_string($value) && $value ? realpath($value) : false; 104 | if (\is_string($realpathValue)) { 105 | $value = $realpathValue; 106 | } 107 | 108 | return $value; 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/DomCrawler/Field/FormFieldTrait.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | /* 4 | * This file is part of the Panther project. 5 | * 6 | * (c) Kévin Dunglas <dunglas@gmail.com> 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Symfony\Component\Panther\DomCrawler\Field; 15 | 16 | use Facebook\WebDriver\WebDriverElement; 17 | use Facebook\WebDriver\WebDriverKeys; 18 | use Symfony\Component\Panther\ExceptionThrower; 19 | 20 | /** 21 | * @internal 22 | * 23 | * @author Kévin Dunglas <dunglas@gmail.com> 24 | */ 25 | trait FormFieldTrait 26 | { 27 | use ExceptionThrower; 28 | 29 | private WebDriverElement $element; 30 | 31 | public function __construct(WebDriverElement $element) 32 | { 33 | $this->element = $element; 34 | $this->initialize(); 35 | } 36 | 37 | public function getLabel(): ?\DOMElement 38 | { 39 | throw $this->createNotSupportedException(__METHOD__); 40 | } 41 | 42 | public function getName(): string 43 | { 44 | return $this->element->getAttribute('name') ?? ''; 45 | } 46 | 47 | public function getValue(): array|string|null 48 | { 49 | return $this->element->getAttribute('value'); 50 | } 51 | 52 | public function isDisabled(): bool 53 | { 54 | return null !== $this->element->getAttribute('disabled'); 55 | } 56 | 57 | private function setTextValue(?string $value): void 58 | { 59 | // Ensure to clean field before sending keys. 60 | // Unable to use $this->element->clear(); because it triggers a change event on it's own which is unexpected behavior. 61 | 62 | $v = $this->getValue(); 63 | 64 | $existingValueLength = \strlen($v); 65 | $deleteKeys = str_repeat(WebDriverKeys::BACKSPACE.WebDriverKeys::DELETE, $existingValueLength); 66 | $this->element->sendKeys($deleteKeys.$value); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/DomCrawler/Field/InputFormField.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | /* 4 | * This file is part of the Panther project. 5 | * 6 | * (c) Kévin Dunglas <dunglas@gmail.com> 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Symfony\Component\Panther\DomCrawler\Field; 15 | 16 | use Symfony\Component\DomCrawler\Field\InputFormField as BaseInputFormField; 17 | use Symfony\Component\Panther\Exception\LogicException; 18 | 19 | /** 20 | * @author Kévin Dunglas <dunglas@gmail.com> 21 | */ 22 | final class InputFormField extends BaseInputFormField 23 | { 24 | use FormFieldTrait; 25 | 26 | public function setValue($value): void 27 | { 28 | if (\in_array($this->element->getAttribute('type'), ['text', 'email', 'number'], true)) { 29 | $this->setTextValue($value); 30 | 31 | return; 32 | } 33 | 34 | if (\is_bool($value)) { 35 | $this->element->click(); 36 | 37 | return; 38 | } 39 | 40 | $this->element->sendKeys($value); 41 | } 42 | 43 | /** 44 | * Initializes the form field. 45 | * 46 | * @throws LogicException When node type is incorrect 47 | */ 48 | protected function initialize(): void 49 | { 50 | $tagName = $this->element->getTagName(); 51 | if ('input' !== $tagName && 'button' !== $tagName) { 52 | throw new LogicException(\sprintf('An InputFormField can only be created from an input or button tag (%s given).', $tagName)); 53 | } 54 | 55 | $type = strtolower((string) $this->element->getAttribute('type')); 56 | if ('checkbox' === $type) { 57 | throw new LogicException('Checkboxes should be instances of ChoiceFormField.'); 58 | } 59 | 60 | if ('file' === $type) { 61 | throw new LogicException('File inputs should be instances of FileFormField.'); 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/DomCrawler/Field/TextareaFormField.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | /* 4 | * This file is part of the Panther project. 5 | * 6 | * (c) Kévin Dunglas <dunglas@gmail.com> 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Symfony\Component\Panther\DomCrawler\Field; 15 | 16 | use Symfony\Component\DomCrawler\Field\TextareaFormField as BaseTextareaFormField; 17 | use Symfony\Component\Panther\Exception\LogicException; 18 | 19 | /** 20 | * @author Kévin Dunglas <dunglas@gmail.com> 21 | */ 22 | final class TextareaFormField extends BaseTextareaFormField 23 | { 24 | use FormFieldTrait; 25 | 26 | public function setValue(?string $value): void 27 | { 28 | $this->setTextValue($value); 29 | } 30 | 31 | /** 32 | * Initializes the form field. 33 | * 34 | * @throws LogicException When node type is incorrect 35 | */ 36 | protected function initialize(): void 37 | { 38 | $tagName = $this->element->getTagName(); 39 | if ('textarea' !== $tagName) { 40 | throw new LogicException(\sprintf('A TextareaFormField can only be created from a textarea tag (%s given).', $tagName)); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/DomCrawler/Form.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | /* 4 | * This file is part of the Panther project. 5 | * 6 | * (c) Kévin Dunglas <dunglas@gmail.com> 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Symfony\Component\Panther\DomCrawler; 15 | 16 | use Facebook\WebDriver\Exception\NoSuchElementException; 17 | use Facebook\WebDriver\JavaScriptExecutor; 18 | use Facebook\WebDriver\Support\XPathEscaper; 19 | use Facebook\WebDriver\WebDriver; 20 | use Facebook\WebDriver\WebDriverBy; 21 | use Facebook\WebDriver\WebDriverElement; 22 | use Facebook\WebDriver\WebDriverSelect; 23 | use Facebook\WebDriver\WebDriverSelectInterface; 24 | use Symfony\Component\DomCrawler\Field\FormField; 25 | use Symfony\Component\DomCrawler\Form as BaseForm; 26 | use Symfony\Component\Panther\DomCrawler\Field\ChoiceFormField; 27 | use Symfony\Component\Panther\DomCrawler\Field\FileFormField; 28 | use Symfony\Component\Panther\DomCrawler\Field\InputFormField; 29 | use Symfony\Component\Panther\DomCrawler\Field\TextareaFormField; 30 | use Symfony\Component\Panther\Exception\LogicException; 31 | use Symfony\Component\Panther\Exception\RuntimeException; 32 | use Symfony\Component\Panther\ExceptionThrower; 33 | use Symfony\Component\Panther\WebDriver\WebDriverCheckbox; 34 | 35 | /** 36 | * @author Kévin Dunglas <dunglas@gmail.com> 37 | */ 38 | final class Form extends BaseForm 39 | { 40 | use ExceptionThrower; 41 | 42 | private WebDriverElement $button; 43 | private WebDriverElement $element; 44 | private WebDriver $webDriver; 45 | 46 | public function __construct(WebDriverElement $element, WebDriver $webDriver) 47 | { 48 | $this->webDriver = $webDriver; 49 | $this->setElement($element); 50 | 51 | $this->currentUri = $webDriver->getCurrentURL(); 52 | $this->method = null; 53 | } 54 | 55 | private function setElement(WebDriverElement $element): void 56 | { 57 | $this->button = $element; 58 | $tagName = $element->getTagName(); 59 | if ('button' === $tagName || ('input' === $tagName && \in_array(strtolower($element->getAttribute('type')), ['submit', 'button', 'image'], true))) { 60 | if (null !== $formId = $element->getAttribute('form')) { 61 | // if the node has the HTML5-compliant 'form' attribute, use it 62 | try { 63 | $form = $this->webDriver->findElement(WebDriverBy::id($formId)); 64 | } catch (NoSuchElementException $e) { 65 | throw new LogicException(\sprintf('The selected node has an invalid form attribute (%s).', $formId)); 66 | } 67 | 68 | $this->element = $form; 69 | 70 | return; 71 | } 72 | // we loop until we find a form ancestor 73 | do { 74 | try { 75 | $element = $element->findElement(WebDriverBy::xpath('..')); 76 | } catch (NoSuchElementException $e) { 77 | throw new LogicException('The selected node does not have a form ancestor.'); 78 | } 79 | } while ('form' !== $element->getTagName()); 80 | } elseif ('form' !== $tagName = $element->getTagName()) { 81 | throw new LogicException(\sprintf('Unable to submit on a "%s" tag.', $tagName)); 82 | } 83 | 84 | $this->element = $element; 85 | } 86 | 87 | public function getButton(): ?WebDriverElement 88 | { 89 | return $this->element === $this->button ? null : $this->button; 90 | } 91 | 92 | public function getElement(): WebDriverElement 93 | { 94 | return $this->element; 95 | } 96 | 97 | public function getFormNode(): \DOMElement 98 | { 99 | throw $this->createNotSupportedException(__METHOD__); 100 | } 101 | 102 | /** 103 | * Disables the internal validation of the field. 104 | */ 105 | public function setValues(array $values): static 106 | { 107 | foreach ($values as $name => $value) { 108 | $this->setValue($name, $value); 109 | } 110 | 111 | return $this; 112 | } 113 | 114 | /** 115 | * Gets the field values. 116 | * 117 | * The returned array does not include file fields (@see getFiles). 118 | * 119 | * @return array An array of field values 120 | */ 121 | public function getValues(): array 122 | { 123 | $values = []; 124 | foreach ($this->element->findElements(WebDriverBy::xpath('.//input[@name] | .//textarea[@name] | .//select[@name] | .//button[@name]')) as $element) { 125 | $name = $element->getAttribute('name'); 126 | $type = $element->getAttribute('type'); 127 | 128 | if ('file' === $type) { 129 | continue; 130 | } 131 | 132 | $value = $this->getValue($element); 133 | 134 | $isArrayElement = \is_array($value) && '[]' === substr($name, -2); 135 | if ($isArrayElement) { 136 | // compatibility with the DomCrawler API 137 | $name = substr($name, 0, -2); 138 | } 139 | 140 | if ('checkbox' === $type) { 141 | if (!$value) { 142 | // Remove non-checked checkboxes 143 | continue; 144 | } 145 | 146 | // Flatten non array-checkboxes 147 | if (\is_array($value) && !$isArrayElement && 1 === \count($value)) { 148 | $value = $value[0]; 149 | } 150 | } 151 | 152 | $values[$name] = $value; 153 | } 154 | 155 | return $values; 156 | } 157 | 158 | public function getFiles(): array 159 | { 160 | if (!\in_array($this->getMethod(), ['POST', 'PUT', 'DELETE', 'PATCH'], true)) { 161 | return []; 162 | } 163 | 164 | $files = []; 165 | 166 | foreach ($this->all() as $field) { 167 | if ($field->isDisabled()) { 168 | continue; 169 | } 170 | 171 | if ($field instanceof FileFormField) { 172 | $files[$field->getName()] = $field->getValue(); 173 | } 174 | } 175 | 176 | return $files; 177 | } 178 | 179 | public function getMethod(): string 180 | { 181 | if (null !== $this->method) { 182 | return $this->method; 183 | } 184 | 185 | // If the form was created from a button rather than the form node, check for HTML5 method override 186 | if ($this->button !== $this->element && null !== $this->button->getAttribute('formmethod')) { 187 | return strtoupper($this->button->getAttribute('formmethod')); 188 | } 189 | 190 | return $this->element->getAttribute('method') ? strtoupper($this->element->getAttribute('method')) : 'GET'; 191 | } 192 | 193 | public function has($name): bool 194 | { 195 | try { 196 | $this->getFormElement($name); 197 | } catch (NoSuchElementException $e) { 198 | return false; 199 | } 200 | 201 | return true; 202 | } 203 | 204 | public function remove($name): void 205 | { 206 | throw $this->createNotSupportedException(__METHOD__); 207 | } 208 | 209 | public function set(FormField $field): void 210 | { 211 | $this->setValue($field->getName(), $field->getValue()); 212 | } 213 | 214 | public function get($name): FormField 215 | { 216 | return $this->getFormField($this->getFormElement($name)); 217 | } 218 | 219 | public function all(): array 220 | { 221 | $fields = []; 222 | foreach ($this->getAllElements() as $element) { 223 | $fields[] = $this->getFormField($element); 224 | } 225 | 226 | return $fields; 227 | } 228 | 229 | public function offsetExists($name): bool 230 | { 231 | return $this->has($name); 232 | } 233 | 234 | /** 235 | * Gets the value of a field. 236 | * 237 | * @param string $name 238 | */ 239 | public function offsetGet($name): FormField 240 | { 241 | return $this->get($name); 242 | } 243 | 244 | public function offsetSet($name, $value): void 245 | { 246 | $this->setValue($name, $value); 247 | } 248 | 249 | public function offsetUnset($name): void 250 | { 251 | throw $this->createNotSupportedException(__METHOD__); 252 | } 253 | 254 | protected function getRawUri(): string 255 | { 256 | // If the form was created from a button rather than the form node, check for HTML5 action overrides 257 | if ($this->element !== $this->button && null !== $this->button->getAttribute('formaction')) { 258 | return $this->button->getAttribute('formaction'); 259 | } 260 | 261 | return (string) $this->element->getAttribute('action'); 262 | } 263 | 264 | /** 265 | * @throws NoSuchElementException 266 | */ 267 | private function getFormElement(string $name): WebDriverElement 268 | { 269 | return $this->element->findElement(WebDriverBy::xpath( 270 | \sprintf('.//input[@name=%1$s] | .//textarea[@name=%1$s] | .//select[@name=%1$s] | .//button[@name=%1$s] | .//input[@name=%2$s] | .//textarea[@name=%2$s] | .//select[@name=%2$s] | .//button[@name=%2$s]', XPathEscaper::escapeQuotes($name), XPathEscaper::escapeQuotes($name.'[]')) 271 | )); 272 | } 273 | 274 | private function getFormField(WebDriverElement $element): FormField 275 | { 276 | $tagName = $element->getTagName(); 277 | 278 | if ('textarea' === $tagName) { 279 | return new TextareaFormField($element); 280 | } 281 | 282 | $type = $element->getAttribute('type'); 283 | if ('select' === $tagName || ('input' === $tagName && ('radio' === $type || 'checkbox' === $type))) { 284 | return new ChoiceFormField($element); 285 | } 286 | 287 | if ('input' === $tagName && 'file' === $type) { 288 | return new FileFormField($element); 289 | } 290 | 291 | return new InputFormField($element); 292 | } 293 | 294 | /** 295 | * @return WebDriverElement[] 296 | */ 297 | private function getAllElements(): array 298 | { 299 | return $this->element->findElements(WebDriverBy::xpath('.//input[@name] | .//textarea[@name] | .//select[@name] | .//button[@name]')); 300 | } 301 | 302 | private function getWebDriverSelect(WebDriverElement $element): ?WebDriverSelectInterface 303 | { 304 | $type = $element->getAttribute('type'); 305 | 306 | $tagName = $element->getTagName(); 307 | $select = 'select' === $tagName; 308 | 309 | if (!$select && ('input' !== $tagName || ('radio' !== $type && 'checkbox' !== $type))) { 310 | return null; 311 | } 312 | 313 | return $select ? new WebDriverSelect($element) : new WebDriverCheckbox($element); 314 | } 315 | 316 | /** 317 | * @return string|array 318 | */ 319 | private function getValue(WebDriverElement $element) 320 | { 321 | if (null === $webDriverSelect = $this->getWebDriverSelect($element)) { 322 | if (!$this->webDriver instanceof JavaScriptExecutor) { 323 | throw new RuntimeException('To retrieve this value, the browser must support JavaScript.'); 324 | } 325 | 326 | return $this->webDriver->executeScript('return arguments[0].value', [$element]); 327 | } 328 | 329 | if (!$webDriverSelect->isMultiple()) { 330 | $selectedOption = $webDriverSelect->getFirstSelectedOption(); 331 | 332 | return $selectedOption->getAttribute('value') ?? $selectedOption->getText(); 333 | } 334 | 335 | $values = []; 336 | foreach ($webDriverSelect->getAllSelectedOptions() as $selectedOption) { 337 | $values[] = $selectedOption->getAttribute('value') ?? $selectedOption->getText(); 338 | } 339 | 340 | return $values; 341 | } 342 | 343 | private function setValue(string $name, $value): void 344 | { 345 | try { 346 | $element = $this->element->findElement(WebDriverBy::name($name)); 347 | } catch (NoSuchElementException $e) { 348 | if (!\is_array($value)) { 349 | throw $e; 350 | } 351 | 352 | // Compatibility with the DomCrawler API 353 | $element = $this->element->findElement(WebDriverBy::name($name.'[]')); 354 | } 355 | 356 | if (null === $webDriverSelect = $this->getWebDriverSelect($element)) { 357 | $element->clear(); 358 | $element->sendKeys($value); 359 | 360 | return; 361 | } 362 | 363 | if (!\is_array($value)) { 364 | $webDriverSelect->selectByValue((string) $value); 365 | 366 | return; 367 | } 368 | 369 | foreach ($value as $v) { 370 | $webDriverSelect->selectByValue((string) $v); 371 | } 372 | } 373 | } 374 | -------------------------------------------------------------------------------- /src/DomCrawler/Image.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | /* 4 | * This file is part of the Panther project. 5 | * 6 | * (c) Kévin Dunglas <dunglas@gmail.com> 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Symfony\Component\Panther\DomCrawler; 15 | 16 | use Facebook\WebDriver\WebDriverElement; 17 | use Symfony\Component\DomCrawler\Image as BaseImage; 18 | use Symfony\Component\Panther\Exception\LogicException; 19 | use Symfony\Component\Panther\ExceptionThrower; 20 | 21 | /** 22 | * @author Kévin Dunglas <dunglas@gmail.com> 23 | */ 24 | final class Image extends BaseImage 25 | { 26 | use ExceptionThrower; 27 | 28 | private WebDriverElement $element; 29 | 30 | public function __construct(WebDriverElement $element) 31 | { 32 | if ('img' !== $tagName = $element->getTagName()) { 33 | throw new LogicException(\sprintf('Unable to visualize a "%s" tag.', $tagName)); 34 | } 35 | 36 | $this->element = $element; 37 | $this->method = 'GET'; 38 | $this->currentUri = null; 39 | } 40 | 41 | public function getNode(): \DOMElement 42 | { 43 | throw $this->createNotSupportedException(__METHOD__); 44 | } 45 | 46 | protected function setNode(\DOMElement $node): void 47 | { 48 | throw $this->createNotSupportedException(__METHOD__); 49 | } 50 | 51 | protected function getRawUri(): string 52 | { 53 | return (string) $this->element->getAttribute('src'); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/DomCrawler/Link.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | /* 4 | * This file is part of the Panther project. 5 | * 6 | * (c) Kévin Dunglas <dunglas@gmail.com> 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Symfony\Component\Panther\DomCrawler; 15 | 16 | use Facebook\WebDriver\WebDriverElement; 17 | use Symfony\Component\DomCrawler\Link as BaseLink; 18 | use Symfony\Component\Panther\Exception\LogicException; 19 | use Symfony\Component\Panther\ExceptionThrower; 20 | 21 | /** 22 | * @author Kévin Dunglas <dunglas@gmail.com> 23 | */ 24 | final class Link extends BaseLink 25 | { 26 | use ExceptionThrower; 27 | 28 | private WebDriverElement $element; 29 | 30 | public function __construct(WebDriverElement $element, string $currentUri) 31 | { 32 | $tagName = $element->getTagName(); 33 | if ('a' !== $tagName && 'area' !== $tagName && 'link' !== $tagName) { 34 | throw new LogicException(\sprintf('Unable to navigate from a "%s" tag.', $tagName)); 35 | } 36 | 37 | $this->element = $element; 38 | $this->method = 'GET'; 39 | $this->currentUri = $currentUri; 40 | } 41 | 42 | public function getElement(): WebDriverElement 43 | { 44 | return $this->element; 45 | } 46 | 47 | public function getNode(): \DOMElement 48 | { 49 | throw $this->createNotSupportedException(__METHOD__); 50 | } 51 | 52 | protected function setNode(\DOMElement $node): void 53 | { 54 | throw $this->createNotSupportedException(__METHOD__); 55 | } 56 | 57 | protected function getRawUri(): string 58 | { 59 | return (string) $this->element->getAttribute('href'); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Exception/ExceptionInterface.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | /* 4 | * This file is part of the Panther project. 5 | * 6 | * (c) Kévin Dunglas <dunglas@gmail.com> 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Symfony\Component\Panther\Exception; 15 | 16 | /** 17 | * Base ExceptionInterface for the Panther component. 18 | */ 19 | interface ExceptionInterface extends \Throwable 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /src/Exception/InvalidArgumentException.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | /* 4 | * This file is part of the Panther project. 5 | * 6 | * (c) Kévin Dunglas <dunglas@gmail.com> 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Symfony\Component\Panther\Exception; 15 | 16 | /** 17 | * Base InvalidArgumentException for Panther component. 18 | * 19 | * @author Oskar Stark <oskarstark@googlemail.com> 20 | */ 21 | class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface 22 | { 23 | } 24 | -------------------------------------------------------------------------------- /src/Exception/LogicException.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | /* 4 | * This file is part of the Panther project. 5 | * 6 | * (c) Kévin Dunglas <dunglas@gmail.com> 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Symfony\Component\Panther\Exception; 15 | 16 | /** 17 | * Base LogicException for Panther component. 18 | */ 19 | class LogicException extends \LogicException implements ExceptionInterface 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /src/Exception/RuntimeException.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | /* 4 | * This file is part of the Panther project. 5 | * 6 | * (c) Kévin Dunglas <dunglas@gmail.com> 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Symfony\Component\Panther\Exception; 15 | 16 | /** 17 | * Base RuntimeException for Panther component. 18 | */ 19 | class RuntimeException extends \RuntimeException implements ExceptionInterface 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /src/ExceptionThrower.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | /* 4 | * This file is part of the Panther project. 5 | * 6 | * (c) Kévin Dunglas <dunglas@gmail.com> 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Symfony\Component\Panther; 15 | 16 | /** 17 | * @internal 18 | * 19 | * @author Kévin Dunglas <dunglas@gmail.com> 20 | */ 21 | trait ExceptionThrower 22 | { 23 | private function createNotSupportedException(string $method): \InvalidArgumentException 24 | { 25 | return new \InvalidArgumentException(\sprintf('The "%s" method is not supported when using WebDriver.', $method)); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/PantherTestCase.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | /* 4 | * This file is part of the Panther project. 5 | * 6 | * (c) Kévin Dunglas <dunglas@gmail.com> 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Symfony\Component\Panther; 15 | 16 | use PHPUnit\Framework\TestCase; 17 | use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; 18 | 19 | if (class_exists(WebTestCase::class)) { 20 | abstract class PantherTestCase extends WebTestCase 21 | { 22 | use WebTestAssertionsTrait; 23 | 24 | public const CHROME = 'chrome'; 25 | public const FIREFOX = 'firefox'; 26 | public const SELENIUM = 'selenium'; 27 | 28 | protected function tearDown(): void 29 | { 30 | $this->doTearDown(); 31 | } 32 | 33 | private function doTearDown(): void 34 | { 35 | parent::tearDown(); 36 | $this->takeScreenshotIfTestFailed(); 37 | self::getClient(null); 38 | } 39 | } 40 | } else { 41 | // Without Symfony 42 | abstract class PantherTestCase extends TestCase 43 | { 44 | use PantherTestCaseTrait; 45 | 46 | public const CHROME = 'chrome'; 47 | public const FIREFOX = 'firefox'; 48 | public const SELENIUM = 'selenium'; 49 | 50 | protected function tearDown(): void 51 | { 52 | parent::tearDown(); 53 | $this->takeScreenshotIfTestFailed(); 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/PantherTestCaseTrait.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | /* 4 | * This file is part of the Panther project. 5 | * 6 | * (c) Kévin Dunglas <dunglas@gmail.com> 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Symfony\Component\Panther; 15 | 16 | use PHPUnit\Runner\BaseTestRunner; 17 | use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; 18 | use Symfony\Component\BrowserKit\HttpBrowser as HttpBrowserClient; 19 | use Symfony\Component\HttpClient\HttpClient; 20 | use Symfony\Component\Panther\Client as PantherClient; 21 | use Symfony\Component\Panther\Exception\RuntimeException; 22 | use Symfony\Component\Panther\ProcessManager\ChromeManager; 23 | use Symfony\Component\Panther\ProcessManager\FirefoxManager; 24 | use Symfony\Component\Panther\ProcessManager\WebServerManager; 25 | 26 | /** 27 | * Eases conditional class definition. 28 | * 29 | * @author Kévin Dunglas <dunglas@gmail.com> 30 | */ 31 | trait PantherTestCaseTrait 32 | { 33 | public static bool $stopServerOnTeardown = true; 34 | 35 | protected static ?string $webServerDir = null; 36 | 37 | protected static ?WebServerManager $webServerManager = null; 38 | 39 | protected static ?string $baseUri = null; 40 | 41 | protected static ?HttpBrowserClient $httpBrowserClient = null; 42 | 43 | /** 44 | * @var PantherClient|null The primary Panther client instance created 45 | */ 46 | protected static ?PantherClient $pantherClient = null; 47 | 48 | /** 49 | * @var PantherClient[] All Panther clients, the first one is the primary one (aka self::$pantherClient) 50 | */ 51 | protected static array $pantherClients = []; 52 | 53 | protected static array $defaultOptions = [ 54 | 'webServerDir' => __DIR__.'/../../../../public', // the Flex directory structure 55 | 'hostname' => '127.0.0.1', 56 | 'port' => 9080, 57 | 'router' => '', 58 | 'external_base_uri' => null, 59 | 'readinessPath' => '', 60 | 'env' => [], 61 | ]; 62 | 63 | public static function tearDownAfterClass(): void 64 | { 65 | if (self::$stopServerOnTeardown) { 66 | static::stopWebServer(); 67 | } 68 | } 69 | 70 | public static function stopWebServer(): void 71 | { 72 | if (null !== self::$webServerManager) { 73 | self::$webServerManager->quit(); 74 | self::$webServerManager = null; 75 | } 76 | 77 | if (null !== self::$pantherClient) { 78 | foreach (self::$pantherClients as $pantherClient) { 79 | // Stop ChromeDriver only when all sessions are already closed 80 | $pantherClient->quit(false); 81 | } 82 | 83 | self::$pantherClient->getBrowserManager()->quit(); 84 | self::$pantherClient = null; 85 | self::$pantherClients = []; 86 | } 87 | 88 | if (null !== self::$httpBrowserClient) { 89 | self::$httpBrowserClient = null; 90 | } 91 | 92 | self::$baseUri = null; 93 | } 94 | 95 | /** 96 | * @param array $options see {@see $defaultOptions} 97 | */ 98 | public static function startWebServer(array $options = []): void 99 | { 100 | if (null !== static::$webServerManager) { 101 | return; 102 | } 103 | 104 | if ($externalBaseUri = $options['external_base_uri'] ?? self::$defaultOptions['external_base_uri'] ?? $_SERVER['PANTHER_EXTERNAL_BASE_URI'] ?? $_SERVER['SYMFONY_PROJECT_DEFAULT_ROUTE_URL'] ?? null) { 105 | self::$baseUri = $externalBaseUri; 106 | 107 | return; 108 | } 109 | 110 | $options = [ 111 | 'webServerDir' => self::getWebServerDir($options), 112 | 'hostname' => $options['hostname'] ?? self::$defaultOptions['hostname'], 113 | 'port' => (int) ($options['port'] ?? $_SERVER['PANTHER_WEB_SERVER_PORT'] ?? self::$defaultOptions['port']), 114 | 'router' => $options['router'] ?? $_SERVER['PANTHER_WEB_SERVER_ROUTER'] ?? self::$defaultOptions['router'], 115 | 'readinessPath' => $options['readinessPath'] ?? $_SERVER['PANTHER_READINESS_PATH'] ?? self::$defaultOptions['readinessPath'], 116 | 'env' => (array) ($options['env'] ?? self::$defaultOptions['env']), 117 | ]; 118 | 119 | self::$webServerManager = new WebServerManager(...array_values($options)); 120 | self::$webServerManager->start(); 121 | 122 | self::$baseUri = \sprintf('http://%s:%s', $options['hostname'], $options['port']); 123 | } 124 | 125 | public static function isWebServerStarted(): bool 126 | { 127 | return self::$webServerManager && self::$webServerManager->isStarted(); 128 | } 129 | 130 | public function takeScreenshotIfTestFailed(): void 131 | { 132 | if (class_exists(BaseTestRunner::class) && method_exists($this, 'getStatus')) { 133 | /** 134 | * PHPUnit <10 TestCase. 135 | */ 136 | $status = $this->getStatus(); 137 | $isError = BaseTestRunner::STATUS_FAILURE === $status; 138 | $isFailure = BaseTestRunner::STATUS_ERROR === $status; 139 | } elseif (method_exists($this, 'status')) { 140 | /** 141 | * PHPUnit 10 TestCase. 142 | */ 143 | $status = $this->status(); 144 | $isError = $status->isError(); 145 | $isFailure = $status->isFailure(); 146 | } else { 147 | /* 148 | * Symfony WebTestCase. 149 | */ 150 | return; 151 | } 152 | if ($isError || $isFailure) { 153 | $type = $isError ? 'error' : 'failure'; 154 | ServerExtensionLegacy::takeScreenshots($type, $this->toString()); 155 | } 156 | } 157 | 158 | /** 159 | * Creates the primary browser. 160 | * 161 | * @param array $options see {@see $defaultOptions} 162 | */ 163 | protected static function createPantherClient(array $options = [], array $kernelOptions = [], array $managerOptions = []): PantherClient 164 | { 165 | $browser = ($options['browser'] ?? self::$defaultOptions['browser'] ?? null); 166 | $callGetClient = method_exists(self::class, 'getClient') && (new \ReflectionMethod(self::class, 'getClient'))->isStatic(); 167 | if (null !== self::$pantherClient) { 168 | $browserManager = self::$pantherClient->getBrowserManager(); 169 | if ( 170 | (PantherTestCase::CHROME === $browser && $browserManager instanceof ChromeManager) 171 | || (PantherTestCase::FIREFOX === $browser && $browserManager instanceof FirefoxManager) 172 | ) { 173 | ServerExtension::registerClient(self::$pantherClient); 174 | 175 | /* @phpstan-ignore-next-line */ 176 | return $callGetClient ? self::getClient(self::$pantherClient) : self::$pantherClient; 177 | } 178 | } 179 | 180 | self::startWebServer($options); 181 | 182 | $browserArguments = $options['browser_arguments'] ?? null; 183 | if (null !== $browserArguments && !\is_array($browserArguments)) { 184 | throw new \TypeError(\sprintf('Expected key "browser_arguments" to be an array or null, "%s" given.', get_debug_type($browserArguments))); 185 | } 186 | 187 | if (PantherTestCase::FIREFOX === $browser) { 188 | self::$pantherClients[0] = self::$pantherClient = PantherClient::createFirefoxClient(null, $browserArguments, $managerOptions, self::$baseUri); 189 | } elseif (PantherTestCase::SELENIUM === $browser) { 190 | self::$pantherClients[0] = self::$pantherClient = PantherClient::createSeleniumClient($managerOptions['host'], $managerOptions['capabilities'] ?? null, self::$baseUri, $options); 191 | } else { 192 | try { 193 | self::$pantherClients[0] = self::$pantherClient = PantherClient::createChromeClient(null, $browserArguments, $managerOptions, self::$baseUri); 194 | } catch (RuntimeException $e) { 195 | if (PantherTestCase::CHROME === $browser) { 196 | throw $e; 197 | } 198 | self::$pantherClients[0] = self::$pantherClient = PantherClient::createFirefoxClient(null, $browserArguments, $managerOptions, self::$baseUri); 199 | } 200 | 201 | if (null === $browser) { 202 | self::$defaultOptions['browser'] = self::$pantherClient->getBrowserManager() instanceof ChromeManager ? PantherTestCase::CHROME : PantherTestCase::FIREFOX; 203 | } 204 | } 205 | 206 | if (is_a(self::class, KernelTestCase::class, true)) { 207 | static::bootKernel($kernelOptions); // @phpstan-ignore-line 208 | } 209 | 210 | ServerExtension::registerClient(self::$pantherClient); 211 | 212 | /* @phpstan-ignore-next-line */ 213 | return $callGetClient ? self::getClient(self::$pantherClient) : self::$pantherClient; 214 | } 215 | 216 | /** 217 | * Creates an additional browser. Convenient to test apps leveraging Mercure or WebSocket (e.g. a chat). 218 | */ 219 | protected static function createAdditionalPantherClient(): PantherClient 220 | { 221 | if (null === self::$pantherClient) { 222 | return self::createPantherClient(); 223 | } 224 | 225 | self::$pantherClients[] = self::$pantherClient = new PantherClient(self::$pantherClient->getBrowserManager(), self::$baseUri); 226 | 227 | ServerExtension::registerClient(self::$pantherClient); 228 | 229 | return self::$pantherClient; 230 | } 231 | 232 | /** 233 | * @param array $options see {@see $defaultOptions} 234 | */ 235 | protected static function createHttpBrowserClient(array $options = [], array $kernelOptions = []): HttpBrowserClient 236 | { 237 | self::startWebServer($options); 238 | 239 | if (null === self::$httpBrowserClient) { 240 | $httpClientOptions = $options['http_client_options'] ?? []; 241 | if (!\is_array($httpClientOptions)) { 242 | throw new \TypeError(\sprintf('Expected key "http_client_options" to be an array, "%s" given.', get_debug_type($httpClientOptions))); 243 | } 244 | 245 | // The ScopingHttpClient can't be used cause the HttpBrowser only supports absolute URLs, 246 | // https://github.com/symfony/symfony/pull/35177 247 | self::$httpBrowserClient = new HttpBrowserClient(HttpClient::create($httpClientOptions)); 248 | } 249 | 250 | if (is_a(self::class, KernelTestCase::class, true)) { 251 | static::bootKernel($kernelOptions); // @phpstan-ignore-line 252 | } 253 | 254 | $urlComponents = parse_url(self::$baseUri); 255 | self::$httpBrowserClient->setServerParameter('HTTP_HOST', \sprintf('%s:%s', $urlComponents['host'], $urlComponents['port'])); 256 | if ('https' === $urlComponents['scheme']) { 257 | self::$httpBrowserClient->setServerParameter('HTTPS', 'true'); 258 | } 259 | 260 | // @phpstan-ignore-next-line 261 | return method_exists(self::class, 'getClient') && (new \ReflectionMethod(self::class, 'getClient'))->isStatic() ? 262 | self::getClient(self::$httpBrowserClient) : self::$httpBrowserClient; 263 | } 264 | 265 | private static function getWebServerDir(array $options): string 266 | { 267 | if (isset($options['webServerDir'])) { 268 | return $options['webServerDir']; 269 | } 270 | 271 | if (null !== static::$webServerDir) { 272 | return static::$webServerDir; 273 | } 274 | 275 | if (!isset($_SERVER['PANTHER_WEB_SERVER_DIR'])) { 276 | return self::$defaultOptions['webServerDir']; 277 | } 278 | 279 | if (str_starts_with($_SERVER['PANTHER_WEB_SERVER_DIR'], './')) { 280 | return getcwd().substr($_SERVER['PANTHER_WEB_SERVER_DIR'], 1); 281 | } 282 | 283 | return $_SERVER['PANTHER_WEB_SERVER_DIR']; 284 | } 285 | } 286 | -------------------------------------------------------------------------------- /src/ProcessManager/BrowserManagerInterface.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | /* 4 | * This file is part of the Panther project. 5 | * 6 | * (c) Kévin Dunglas <dunglas@gmail.com> 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Symfony\Component\Panther\ProcessManager; 15 | 16 | use Facebook\WebDriver\WebDriver; 17 | use Symfony\Component\Panther\Exception\RuntimeException; 18 | 19 | /** 20 | * A browser manager (for instance using ChromeDriver or GeckoDriver). 21 | * 22 | * @author Kévin Dunglas <dunglas@gmail.com> 23 | */ 24 | interface BrowserManagerInterface 25 | { 26 | /** 27 | * @throws RuntimeException 28 | */ 29 | public function start(): WebDriver; 30 | 31 | public function quit(): void; 32 | } 33 | -------------------------------------------------------------------------------- /src/ProcessManager/ChromeManager.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | /* 4 | * This file is part of the Panther project. 5 | * 6 | * (c) Kévin Dunglas <dunglas@gmail.com> 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Symfony\Component\Panther\ProcessManager; 15 | 16 | use Facebook\WebDriver\Chrome\ChromeOptions; 17 | use Facebook\WebDriver\Remote\DesiredCapabilities; 18 | use Facebook\WebDriver\Remote\RemoteWebDriver; 19 | use Facebook\WebDriver\WebDriver; 20 | use Symfony\Component\Panther\Exception\RuntimeException; 21 | use Symfony\Component\Process\ExecutableFinder; 22 | use Symfony\Component\Process\Process; 23 | 24 | /** 25 | * @author Kévin Dunglas <dunglas@gmail.com> 26 | */ 27 | final class ChromeManager implements BrowserManagerInterface 28 | { 29 | use WebServerReadinessProbeTrait; 30 | 31 | private Process $process; 32 | private array $arguments; 33 | private array $options; 34 | 35 | /** 36 | * @throws RuntimeException 37 | */ 38 | public function __construct(?string $chromeDriverBinary = null, ?array $arguments = null, array $options = []) 39 | { 40 | $this->options = $options ? array_merge($this->getDefaultOptions(), $options) : $this->getDefaultOptions(); 41 | $this->process = $this->createProcess($chromeDriverBinary ?: $this->findChromeDriverBinary()); 42 | $this->arguments = $arguments ?? $this->getDefaultArguments(); 43 | } 44 | 45 | /** 46 | * @throws RuntimeException 47 | */ 48 | public function start(): WebDriver 49 | { 50 | $url = $this->options['scheme'].'://'.$this->options['host'].':'.$this->options['port']; 51 | if (!$this->process->isRunning()) { 52 | $this->checkPortAvailable($this->options['host'], $this->options['port']); 53 | $this->process->start(); 54 | $this->waitUntilReady($this->process, $url.$this->options['path'], 'chrome'); 55 | } 56 | 57 | $capabilities = DesiredCapabilities::chrome(); 58 | 59 | foreach ($this->options['capabilities'] as $capability => $value) { 60 | $capabilities->setCapability($capability, $value); 61 | } 62 | 63 | if ($this->arguments) { 64 | $chromeOptions = $capabilities->getCapability(ChromeOptions::CAPABILITY); 65 | if (null === $chromeOptions) { 66 | $chromeOptions = new ChromeOptions(); 67 | $capabilities->setCapability(ChromeOptions::CAPABILITY, $chromeOptions); 68 | } 69 | $chromeOptions->addArguments($this->arguments); 70 | 71 | if (isset($_SERVER['PANTHER_CHROME_BINARY'])) { 72 | $chromeOptions->setBinary($_SERVER['PANTHER_CHROME_BINARY']); 73 | } 74 | } 75 | 76 | return RemoteWebDriver::create($url, $capabilities, $this->options['connection_timeout_in_ms'] ?? null, $this->options['request_timeout_in_ms'] ?? null); 77 | } 78 | 79 | public function quit(): void 80 | { 81 | $this->process->stop(); 82 | } 83 | 84 | /** 85 | * @throws RuntimeException 86 | */ 87 | private function findChromeDriverBinary(): string 88 | { 89 | if ($binary = (new ExecutableFinder())->find('chromedriver', null, ['./drivers', './vendor/bin'])) { 90 | return $binary; 91 | } 92 | 93 | throw new RuntimeException('"chromedriver" binary not found. Install it using the package manager of your operating system or by running "composer require --dev dbrekelmans/bdi && vendor/bin/bdi detect drivers".'); 94 | } 95 | 96 | private function getDefaultArguments(): array 97 | { 98 | $args = []; 99 | 100 | // Enable the headless mode unless PANTHER_NO_HEADLESS is defined 101 | if (!filter_var($_SERVER['PANTHER_NO_HEADLESS'] ?? false, \FILTER_VALIDATE_BOOLEAN)) { 102 | $args[] = '--headless'; 103 | $args[] = '--window-size=1200,1100'; 104 | $args[] = '--disable-gpu'; 105 | } 106 | 107 | // Enable devtools for debugging 108 | if (filter_var($_SERVER['PANTHER_DEVTOOLS'] ?? true, \FILTER_VALIDATE_BOOLEAN)) { 109 | $args[] = '--auto-open-devtools-for-tabs'; 110 | } 111 | 112 | // Disable Chrome's sandbox if PANTHER_NO_SANDBOX is defined or if running in Travis 113 | if (filter_var($_SERVER['PANTHER_NO_SANDBOX'] ?? $_SERVER['HAS_JOSH_K_SEAL_OF_APPROVAL'] ?? false, \FILTER_VALIDATE_BOOLEAN)) { 114 | // Running in Travis, disabling the sandbox mode 115 | $args[] = '--no-sandbox'; 116 | } 117 | 118 | // Prefer reduced motion, see https://developer.mozilla.org/docs/Web/CSS/@media/prefers-reduced-motion 119 | if (!filter_var($_SERVER['PANTHER_NO_REDUCED_MOTION'] ?? false, \FILTER_VALIDATE_BOOLEAN)) { 120 | $args[] = '--force-prefers-reduced-motion'; 121 | } else { 122 | $args[] = '--force-prefers-no-reduced-motion'; 123 | } 124 | 125 | // Add custom arguments with PANTHER_CHROME_ARGUMENTS 126 | if ($_SERVER['PANTHER_CHROME_ARGUMENTS'] ?? false) { 127 | $arguments = explode(' ', $_SERVER['PANTHER_CHROME_ARGUMENTS']); 128 | $args = array_merge($args, $arguments); 129 | } 130 | 131 | return $args; 132 | } 133 | 134 | private function createProcess(string $chromeDriverBinary): Process 135 | { 136 | $command = array_merge( 137 | [$chromeDriverBinary, '--port='.$this->options['port']], 138 | $this->options['chromedriver_arguments'] 139 | ); 140 | 141 | return new Process($command, null, null, null, null); 142 | } 143 | 144 | private function getDefaultOptions(): array 145 | { 146 | return [ 147 | 'scheme' => 'http', 148 | 'host' => '127.0.0.1', 149 | 'port' => 9515, 150 | 'path' => '/status', 151 | 'chromedriver_arguments' => [], 152 | 'capabilities' => [], 153 | ]; 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/ProcessManager/FirefoxManager.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | /* 4 | * This file is part of the Panther project. 5 | * 6 | * (c) Kévin Dunglas <dunglas@gmail.com> 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Symfony\Component\Panther\ProcessManager; 15 | 16 | use Facebook\WebDriver\Firefox\FirefoxOptions; 17 | use Facebook\WebDriver\Remote\DesiredCapabilities; 18 | use Facebook\WebDriver\Remote\RemoteWebDriver; 19 | use Facebook\WebDriver\WebDriver; 20 | use Symfony\Component\Panther\Exception\RuntimeException; 21 | use Symfony\Component\Process\ExecutableFinder; 22 | use Symfony\Component\Process\Process; 23 | 24 | /** 25 | * @author Kévin Dunglas <dunglas@gmail.com> 26 | */ 27 | final class FirefoxManager implements BrowserManagerInterface 28 | { 29 | use WebServerReadinessProbeTrait; 30 | 31 | private Process $process; 32 | private array $arguments; 33 | private array $options; 34 | 35 | /** 36 | * @throws RuntimeException 37 | */ 38 | public function __construct(?string $geckodriverBinary = null, ?array $arguments = null, array $options = []) 39 | { 40 | $this->options = array_merge($this->getDefaultOptions(), $options); 41 | $this->process = new Process([$geckodriverBinary ?: $this->findGeckodriverBinary(), '--port='.$this->options['port']], null, null, null, null); 42 | $this->arguments = $arguments ?? $this->getDefaultArguments(); 43 | } 44 | 45 | /** 46 | * @throws RuntimeException 47 | */ 48 | public function start(): WebDriver 49 | { 50 | $url = $this->options['scheme'].'://'.$this->options['host'].':'.$this->options['port']; 51 | if (!$this->process->isRunning()) { 52 | $this->checkPortAvailable($this->options['host'], $this->options['port']); 53 | $this->process->start(); 54 | $this->waitUntilReady($this->process, $url.$this->options['path'], 'firefox'); 55 | } 56 | 57 | $firefoxOptions = []; 58 | if (isset($_SERVER['PANTHER_FIREFOX_BINARY'])) { 59 | $firefoxOptions['binary'] = $_SERVER['PANTHER_FIREFOX_BINARY']; 60 | } 61 | if ($this->arguments) { 62 | $firefoxOptions['args'] = $this->arguments; 63 | } 64 | 65 | $capabilities = DesiredCapabilities::firefox(); 66 | $capabilities->setCapability('moz:firefoxOptions', $firefoxOptions); 67 | 68 | // Prefer reduced motion, see https://developer.mozilla.org/fr/docs/Web/CSS/@media/prefers-reduced-motion 69 | /** @var FirefoxOptions|array $firefoxOptions */ 70 | $firefoxOptions = $capabilities->getCapability('moz:firefoxOptions') ?? []; 71 | $firefoxOptions = $firefoxOptions instanceof FirefoxOptions ? $firefoxOptions->toArray() : $firefoxOptions; 72 | if (!filter_var($_SERVER['PANTHER_NO_REDUCED_MOTION'] ?? false, \FILTER_VALIDATE_BOOLEAN)) { 73 | $firefoxOptions['prefs']['ui.prefersReducedMotion'] = 1; 74 | } else { 75 | $firefoxOptions['prefs']['ui.prefersReducedMotion'] = 0; 76 | } 77 | $capabilities->setCapability('moz:firefoxOptions', $firefoxOptions); 78 | 79 | foreach ($this->options['capabilities'] as $capability => $value) { 80 | $capabilities->setCapability($capability, $value); 81 | } 82 | 83 | return RemoteWebDriver::create($url, $capabilities, $this->options['connection_timeout_in_ms'] ?? null, $this->options['request_timeout_in_ms'] ?? null); 84 | } 85 | 86 | public function quit(): void 87 | { 88 | $this->process->stop(); 89 | } 90 | 91 | /** 92 | * @throws RuntimeException 93 | */ 94 | private function findGeckodriverBinary(): string 95 | { 96 | if ($binary = (new ExecutableFinder())->find('geckodriver', null, ['./drivers'])) { 97 | return $binary; 98 | } 99 | 100 | throw new RuntimeException('"geckodriver" binary not found. Install it using the package manager of your operating system or by running "composer require --dev dbrekelmans/bdi && vendor/bin/bdi detect drivers".'); 101 | } 102 | 103 | private function getDefaultArguments(): array 104 | { 105 | $args = []; 106 | 107 | // Enable the headless mode unless PANTHER_NO_HEADLESS is defined 108 | if (!filter_var($_SERVER['PANTHER_NO_HEADLESS'] ?? false, \FILTER_VALIDATE_BOOLEAN)) { 109 | $args[] = '--headless'; 110 | } 111 | 112 | // Enable devtools for debugging 113 | if (filter_var($_SERVER['PANTHER_DEVTOOLS'] ?? true, \FILTER_VALIDATE_BOOLEAN)) { 114 | $args[] = '--devtools'; 115 | } 116 | 117 | // Add custom arguments with PANTHER_FIREFOX_ARGUMENTS 118 | if ($_SERVER['PANTHER_FIREFOX_ARGUMENTS'] ?? false) { 119 | $arguments = explode(' ', $_SERVER['PANTHER_FIREFOX_ARGUMENTS']); 120 | $args = array_merge($args, $arguments); 121 | } 122 | 123 | return $args; 124 | } 125 | 126 | private function getDefaultOptions(): array 127 | { 128 | return [ 129 | 'scheme' => 'http', 130 | 'host' => '127.0.0.1', 131 | 'port' => 4444, 132 | 'path' => '/status', 133 | 'capabilities' => [], 134 | ]; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/ProcessManager/SeleniumManager.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | /* 4 | * This file is part of the Panther project. 5 | * 6 | * (c) Kévin Dunglas <dunglas@gmail.com> 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Symfony\Component\Panther\ProcessManager; 15 | 16 | use Facebook\WebDriver\Remote\DesiredCapabilities; 17 | use Facebook\WebDriver\Remote\RemoteWebDriver; 18 | use Facebook\WebDriver\WebDriver; 19 | use Facebook\WebDriver\WebDriverCapabilities; 20 | 21 | /** 22 | * @author Dmitry Kuzmin <rockwith@me.com> 23 | */ 24 | final class SeleniumManager implements BrowserManagerInterface 25 | { 26 | private ?string $host; 27 | private WebDriverCapabilities $capabilities; 28 | private ?array $options; 29 | 30 | public function __construct( 31 | ?string $host = 'http://127.0.0.1:4444/wd/hub', 32 | ?WebDriverCapabilities $capabilities = null, 33 | ?array $options = [], 34 | ) { 35 | $this->host = $host; 36 | $this->capabilities = $capabilities ?? DesiredCapabilities::chrome(); 37 | $this->options = $options; 38 | } 39 | 40 | public function start(): WebDriver 41 | { 42 | return RemoteWebDriver::create( 43 | $this->host, 44 | $this->capabilities, 45 | $this->options['connection_timeout_in_ms'] ?? null, $this->options['request_timeout_in_ms'] ?? null 46 | ); 47 | } 48 | 49 | public function quit(): void 50 | { 51 | // nothing 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/ProcessManager/WebServerManager.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | /* 4 | * This file is part of the Panther project. 5 | * 6 | * (c) Kévin Dunglas <dunglas@gmail.com> 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Symfony\Component\Panther\ProcessManager; 15 | 16 | use Symfony\Component\Panther\Exception\RuntimeException; 17 | use Symfony\Component\Process\PhpExecutableFinder; 18 | use Symfony\Component\Process\Process; 19 | 20 | /** 21 | * @author Kévin Dunglas <dunglas@gmail.com> 22 | */ 23 | final class WebServerManager 24 | { 25 | use WebServerReadinessProbeTrait; 26 | 27 | private string $hostname; 28 | private int $port; 29 | private string $readinessPath; 30 | private Process $process; 31 | 32 | /** 33 | * @throws RuntimeException 34 | */ 35 | public function __construct(string $documentRoot, string $hostname, int $port, string $router = '', string $readinessPath = '', ?array $env = null) 36 | { 37 | $this->hostname = $hostname; 38 | $this->port = $port; 39 | $this->readinessPath = $readinessPath; 40 | 41 | $finder = new PhpExecutableFinder(); 42 | if (false === $binary = $finder->find(false)) { 43 | throw new RuntimeException('Unable to find the PHP binary.'); 44 | } 45 | 46 | if (isset($_SERVER['PANTHER_APP_ENV'])) { 47 | if (null === $env) { 48 | $env = []; 49 | } 50 | $env['APP_ENV'] = $_SERVER['PANTHER_APP_ENV']; 51 | } 52 | 53 | $this->process = new Process( 54 | array_filter(array_merge( 55 | [$binary], 56 | $finder->findArguments(), 57 | [ 58 | '-dvariables_order=EGPCS', 59 | '-S', 60 | \sprintf('%s:%d', $this->hostname, $this->port), 61 | '-t', 62 | $documentRoot, 63 | $router, 64 | ] 65 | )), 66 | $documentRoot, 67 | $env, 68 | null, 69 | null 70 | ); 71 | $this->process->disableOutput(); 72 | } 73 | 74 | public function start(): void 75 | { 76 | $this->checkPortAvailable($this->hostname, $this->port); 77 | $this->process->start(); 78 | 79 | $url = "http://$this->hostname:$this->port"; 80 | 81 | if ($this->readinessPath) { 82 | $url .= $this->readinessPath; 83 | } 84 | 85 | $this->waitUntilReady($this->process, $url, 'web server', true); 86 | } 87 | 88 | /** 89 | * @throws RuntimeException 90 | */ 91 | public function quit(): void 92 | { 93 | $this->process->stop(); 94 | } 95 | 96 | public function isStarted(): bool 97 | { 98 | return $this->process->isStarted(); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/ProcessManager/WebServerReadinessProbeTrait.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | /* 4 | * This file is part of the Panther project. 5 | * 6 | * (c) Kévin Dunglas <dunglas@gmail.com> 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Symfony\Component\Panther\ProcessManager; 15 | 16 | use Symfony\Component\HttpClient\HttpClient; 17 | use Symfony\Component\Panther\Exception\RuntimeException; 18 | use Symfony\Component\Process\Process; 19 | use Symfony\Contracts\HttpClient\Exception\ExceptionInterface; 20 | 21 | /** 22 | * @internal 23 | * 24 | * @author Kévin Dunglas <dunglas@gmail.com> 25 | */ 26 | trait WebServerReadinessProbeTrait 27 | { 28 | /** 29 | * @throws RuntimeException 30 | */ 31 | private function checkPortAvailable(string $hostname, int $port, bool $throw = true): void 32 | { 33 | $currentState = error_reporting(); 34 | error_reporting(0); 35 | $resource = fsockopen($hostname, $port); 36 | error_reporting($currentState); 37 | if (\is_resource($resource)) { 38 | fclose($resource); 39 | if ($throw) { 40 | throw new RuntimeException(\sprintf('The port %d is already in use.', $port)); 41 | } 42 | } 43 | } 44 | 45 | public function waitUntilReady(Process $process, string $url, string $service, bool $allowNotOkStatusCode = false, int $timeout = 30): void 46 | { 47 | $client = HttpClient::create(['timeout' => $timeout]); 48 | 49 | $start = microtime(true); 50 | 51 | while (true) { 52 | $status = $process->getStatus(); 53 | if (Process::STATUS_TERMINATED === $status) { 54 | throw new RuntimeException(\sprintf('Could not start %s. Exit code: %d (%s). Error output: %s', $service, $process->getExitCode(), $process->getExitCodeText(), $process->getErrorOutput())); 55 | } 56 | 57 | if (Process::STATUS_STARTED !== $status) { 58 | if (microtime(true) - $start >= $timeout) { 59 | throw new RuntimeException("Could not start $service (or it crashed) after $timeout seconds."); 60 | } 61 | 62 | usleep(1000); 63 | 64 | continue; 65 | } 66 | 67 | $response = $client->request('GET', $url); 68 | $e = $statusCode = null; 69 | try { 70 | $statusCode = $response->getStatusCode(); 71 | if ($allowNotOkStatusCode || 200 === $statusCode) { 72 | return; 73 | } 74 | } catch (ExceptionInterface $e) { 75 | } 76 | 77 | if (microtime(true) - $start >= $timeout) { 78 | if ($e) { 79 | $message = $e->getMessage(); 80 | } else { 81 | $message = "Status code: $statusCode"; 82 | } 83 | throw new RuntimeException("Could not connect to $service after $timeout seconds ($message)."); 84 | } 85 | 86 | usleep(1000); 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/ServerExtension.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | /* 4 | * This file is part of the Panther project. 5 | * 6 | * (c) Kévin Dunglas <dunglas@gmail.com> 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Symfony\Component\Panther; 15 | 16 | use PHPUnit\Event\Test\Errored; 17 | use PHPUnit\Event\Test\ErroredSubscriber; 18 | use PHPUnit\Event\Test\Failed; 19 | use PHPUnit\Event\Test\FailedSubscriber; 20 | use PHPUnit\Event\Test\Finished as TestFinishedEvent; 21 | use PHPUnit\Event\Test\FinishedSubscriber as TestFinishedSubscriber; 22 | use PHPUnit\Event\Test\PreparationStarted as TestStartedEvent; 23 | use PHPUnit\Event\Test\PreparationStartedSubscriber as TestStartedSubscriber; 24 | use PHPUnit\Event\TestRunner\Finished as TestRunnerFinishedEvent; 25 | use PHPUnit\Event\TestRunner\FinishedSubscriber as TestRunnerFinishedSubscriber; 26 | use PHPUnit\Event\TestRunner\Started as TestRunnerStartedEvent; 27 | use PHPUnit\Event\TestRunner\StartedSubscriber as TestRunnerStartedSubscriber; 28 | use PHPUnit\Runner\AfterLastTestHook; 29 | use PHPUnit\Runner\AfterTestErrorHook; 30 | use PHPUnit\Runner\AfterTestFailureHook; 31 | use PHPUnit\Runner\AfterTestHook; 32 | use PHPUnit\Runner\BeforeFirstTestHook; 33 | use PHPUnit\Runner\BeforeTestHook; 34 | use PHPUnit\Runner\Extension\Extension; 35 | use PHPUnit\Runner\Extension\Facade; 36 | use PHPUnit\Runner\Extension\ParameterCollection; 37 | use PHPUnit\TextUI\Configuration\Configuration; 38 | 39 | /* 40 | * @author Dany Maillard <danymaillard93b@gmail.com> 41 | */ 42 | if (interface_exists(Extension::class)) { 43 | /** 44 | * PHPUnit >= 10. 45 | */ 46 | final class ServerExtension implements Extension 47 | { 48 | public function bootstrap(Configuration $configuration, Facade $facade, ParameterCollection $parameters): void 49 | { 50 | $extension = new ServerExtensionLegacy(); 51 | 52 | $facade->registerSubscriber(new class($extension) implements TestRunnerStartedSubscriber { 53 | public function __construct(private $extension) 54 | { 55 | } 56 | 57 | public function notify(TestRunnerStartedEvent $event): void 58 | { 59 | $this->extension->executeBeforeFirstTest(); 60 | } 61 | }); 62 | 63 | $facade->registerSubscriber(new class($extension) implements TestRunnerFinishedSubscriber { 64 | public function __construct(private $extension) 65 | { 66 | } 67 | 68 | public function notify(TestRunnerFinishedEvent $event): void 69 | { 70 | $this->extension->executeAfterLastTest(); 71 | } 72 | }); 73 | 74 | $facade->registerSubscriber(new class($extension) implements TestStartedSubscriber { 75 | public function __construct(private $extension) 76 | { 77 | } 78 | 79 | public function notify(TestStartedEvent $event): void 80 | { 81 | $this->extension->executeBeforeTest($event->test()->name()); 82 | } 83 | }); 84 | 85 | $facade->registerSubscriber(new class($extension) implements TestFinishedSubscriber { 86 | public function __construct(private $extension) 87 | { 88 | } 89 | 90 | public function notify(TestFinishedEvent $event): void 91 | { 92 | $this->extension->executeAfterTest($event->test()->name(), (float) $event->telemetryInfo()->time()->seconds()); 93 | } 94 | }); 95 | 96 | $facade->registerSubscriber(new class($extension) implements ErroredSubscriber { 97 | public function __construct(private $extension) 98 | { 99 | } 100 | 101 | public function notify(Errored $event): void 102 | { 103 | $this->extension->executeAfterTestError($event->test()->name(), $event->throwable()->message(), (float) $event->telemetryInfo()->time()->seconds()); 104 | } 105 | }); 106 | 107 | $facade->registerSubscriber(new class($extension) implements FailedSubscriber { 108 | public function __construct(private $extension) 109 | { 110 | } 111 | 112 | public function notify(Failed $event): void 113 | { 114 | $this->extension->executeAfterTestFailure($event->test()->name(), $event->throwable()->message(), (float) $event->telemetryInfo()->time()->seconds()); 115 | } 116 | }); 117 | } 118 | 119 | public static function registerClient(Client $client): void 120 | { 121 | ServerExtensionLegacy::registerClient($client); 122 | } 123 | } 124 | } elseif (interface_exists(BeforeFirstTestHook::class)) { 125 | /** 126 | * PHPUnit < 10. 127 | */ 128 | final class ServerExtension extends ServerExtensionLegacy implements BeforeFirstTestHook, BeforeTestHook, AfterTestHook, AfterLastTestHook, AfterTestErrorHook, AfterTestFailureHook 129 | { 130 | } 131 | } else { 132 | exit("Failed to initialize Symfony\Component\Panther\ServerExtension, undetectable or unsupported phpunit version."); 133 | } 134 | -------------------------------------------------------------------------------- /src/ServerExtensionLegacy.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | /* 4 | * This file is part of the Panther project. 5 | * 6 | * (c) Kévin Dunglas <dunglas@gmail.com> 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Symfony\Component\Panther; 15 | 16 | /** 17 | * @internal 18 | */ 19 | class ServerExtensionLegacy 20 | { 21 | use ServerTrait; 22 | 23 | private static bool $enabled = false; 24 | 25 | /** @var Client[] */ 26 | private static array $registeredClients = []; 27 | 28 | public static function registerClient(Client $client): void 29 | { 30 | if (self::$enabled && !\in_array($client, self::$registeredClients, true)) { 31 | self::$registeredClients[] = $client; 32 | } 33 | } 34 | 35 | public function executeBeforeFirstTest(): void 36 | { 37 | self::$enabled = true; 38 | $this->keepServerOnTeardown(); 39 | } 40 | 41 | public function executeBeforeTest(string $test): void 42 | { 43 | self::reset(); 44 | } 45 | 46 | public function executeAfterTest(string $test, float $time): void 47 | { 48 | self::reset(); 49 | } 50 | 51 | public function executeAfterLastTest(): void 52 | { 53 | $this->stopWebServer(); 54 | } 55 | 56 | public function executeAfterTestError(string $test, string $message, float $time): void 57 | { 58 | $this->pause(\sprintf('Error: %s', $message)); 59 | } 60 | 61 | public function executeAfterTestFailure(string $test, string $message, float $time): void 62 | { 63 | $this->pause(\sprintf('Failure: %s', $message)); 64 | } 65 | 66 | private static function reset(): void 67 | { 68 | self::$registeredClients = []; 69 | } 70 | 71 | public static function takeScreenshots(string $type, string $test): void 72 | { 73 | if (!self::$enabled || !($_SERVER['PANTHER_ERROR_SCREENSHOT_DIR'] ?? false)) { 74 | return; 75 | } 76 | 77 | foreach (self::$registeredClients as $i => $client) { 78 | $screenshotPath = \sprintf('%s/%s_%s_%s-%d.png', 79 | $_SERVER['PANTHER_ERROR_SCREENSHOT_DIR'], 80 | date('Y-m-d_H-i-s'), 81 | $type, 82 | strtr($test, ['\\' => '-', ':' => '_']), 83 | $i 84 | ); 85 | $client->takeScreenshot($screenshotPath); 86 | if ($_SERVER['PANTHER_ERROR_SCREENSHOT_ATTACH'] ?? false) { 87 | printf('[[ATTACHMENT|%s]]', $screenshotPath); 88 | } 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/ServerTrait.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | /* 4 | * This file is part of the Panther project. 5 | * 6 | * (c) Kévin Dunglas <dunglas@gmail.com> 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Symfony\Component\Panther; 15 | 16 | /** 17 | * @author Dany Maillard <danymaillard93b@gmail.com> 18 | * 19 | * @internal 20 | */ 21 | trait ServerTrait 22 | { 23 | public bool $testing = false; 24 | 25 | private function keepServerOnTeardown(): void 26 | { 27 | PantherTestCase::$stopServerOnTeardown = false; 28 | } 29 | 30 | private function stopWebServer(): void 31 | { 32 | PantherTestCase::stopWebServer(); 33 | } 34 | 35 | private function pause($message): void 36 | { 37 | if (\in_array('--debug', $_SERVER['argv'], true) 38 | && filter_var($_SERVER['PANTHER_NO_HEADLESS'] ?? false, \FILTER_VALIDATE_BOOLEAN) 39 | ) { 40 | echo "$message\n\nPress enter to continue..."; 41 | if (!$this->testing) { 42 | fgets(\STDIN); 43 | } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/WebDriver/PantherWebDriverExpectedCondition.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | /* 4 | * This file is part of the Panther project. 5 | * 6 | * (c) Kévin Dunglas <dunglas@gmail.com> 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Symfony\Component\Panther\WebDriver; 15 | 16 | use Facebook\WebDriver\Exception\StaleElementReferenceException; 17 | use Facebook\WebDriver\WebDriver; 18 | use Facebook\WebDriver\WebDriverBy; 19 | 20 | final class PantherWebDriverExpectedCondition 21 | { 22 | public static function elementTextNotContains(WebDriverBy $by, string $text): callable 23 | { 24 | return static function (WebDriver $driver) use ($by, $text) { 25 | try { 26 | $elementText = $driver->findElement($by)->getText(); 27 | 28 | return !str_contains($elementText, $text); 29 | } catch (StaleElementReferenceException $e) { 30 | return null; 31 | } 32 | } 33 | ; 34 | } 35 | 36 | public static function elementEnabled(WebDriverBy $by): callable 37 | { 38 | return static function (WebDriver $driver) use ($by) { 39 | try { 40 | return $driver->findElement($by)->isEnabled(); 41 | } catch (StaleElementReferenceException $e) { 42 | return null; 43 | } 44 | } 45 | ; 46 | } 47 | 48 | public static function elementDisabled(WebDriverBy $by): callable 49 | { 50 | return static function (WebDriver $driver) use ($by) { 51 | try { 52 | return !$driver->findElement($by)->isEnabled(); 53 | } catch (StaleElementReferenceException $e) { 54 | return null; 55 | } 56 | } 57 | ; 58 | } 59 | 60 | public static function elementAttributeContains(WebDriverBy $by, string $attribute, string $text): callable 61 | { 62 | return static function (WebDriver $driver) use ($by, $attribute, $text) { 63 | try { 64 | $attributeValue = $driver->findElement($by)->getAttribute($attribute); 65 | 66 | return null !== $attributeValue && str_contains($attributeValue, $text); 67 | } catch (StaleElementReferenceException $e) { 68 | return null; 69 | } 70 | } 71 | ; 72 | } 73 | 74 | public static function elementAttributeNotContains(WebDriverBy $by, string $attribute, string $text): callable 75 | { 76 | return static function (WebDriver $driver) use ($by, $attribute, $text) { 77 | try { 78 | $attributeValue = $driver->findElement($by)->getAttribute($attribute); 79 | 80 | return null !== $attributeValue && !str_contains($attributeValue, $text); 81 | } catch (StaleElementReferenceException $e) { 82 | return null; 83 | } 84 | } 85 | ; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/WebDriver/WebDriverCheckbox.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | /* 4 | * This file is part of the Panther project. 5 | * 6 | * (c) Kévin Dunglas <dunglas@gmail.com> 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Symfony\Component\Panther\WebDriver; 15 | 16 | use Facebook\WebDriver\Exception\NoSuchElementException; 17 | use Facebook\WebDriver\Exception\UnexpectedTagNameException; 18 | use Facebook\WebDriver\Exception\UnsupportedOperationException; 19 | use Facebook\WebDriver\Exception\WebDriverException; 20 | use Facebook\WebDriver\Support\XPathEscaper; 21 | use Facebook\WebDriver\WebDriverBy; 22 | use Facebook\WebDriver\WebDriverElement; 23 | use Facebook\WebDriver\WebDriverSelectInterface; 24 | 25 | /** 26 | * Provides helper methods for checkboxes and radio buttons. 27 | * 28 | * This class has been proposed to php-webdriver/php-webdriver and will be deleted from this project when it will me merged. 29 | * 30 | * @author Kévin Dunglas <dunglas@gmail.com> 31 | * 32 | * @internal 33 | * 34 | * @see https://github.com/php-webdriver/php-webdriver/pull/545 35 | */ 36 | class WebDriverCheckbox implements WebDriverSelectInterface 37 | { 38 | private WebDriverElement $element; 39 | private string $type; 40 | private string $name; 41 | 42 | public function __construct(WebDriverElement $element) 43 | { 44 | if ('input' !== $tagName = $element->getTagName()) { 45 | throw new UnexpectedTagNameException('input', $tagName); 46 | } 47 | 48 | $type = $element->getAttribute('type'); 49 | if ('checkbox' !== $type && 'radio' !== $type) { 50 | throw new WebDriverException('The input must be of type "checkbox" or "radio".'); 51 | } 52 | 53 | if (null === $name = $element->getAttribute('name')) { 54 | throw new WebDriverException('The input must have a "name" attribute.'); 55 | } 56 | 57 | $this->element = $element; 58 | $this->type = $type; 59 | $this->name = $name; 60 | } 61 | 62 | public function isMultiple(): bool 63 | { 64 | return 'checkbox' === $this->type; 65 | } 66 | 67 | public function getOptions(): array 68 | { 69 | return $this->getRelatedElements(); 70 | } 71 | 72 | public function getAllSelectedOptions(): array 73 | { 74 | $selectedOptions = []; 75 | foreach ($this->getRelatedElements() as $element) { 76 | if ($element->isSelected()) { 77 | $selectedOptions[] = $element; 78 | 79 | if (!$this->isMultiple()) { 80 | return $selectedOptions; 81 | } 82 | } 83 | } 84 | 85 | return $selectedOptions; 86 | } 87 | 88 | public function getFirstSelectedOption(): WebDriverElement 89 | { 90 | foreach ($this->getRelatedElements() as $element) { 91 | if ($element->isSelected()) { 92 | return $element; 93 | } 94 | } 95 | 96 | throw new NoSuchElementException('No options are selected'); 97 | } 98 | 99 | public function selectByIndex($index): void 100 | { 101 | $this->byIndex($index); 102 | } 103 | 104 | public function selectByValue($value): void 105 | { 106 | $this->byValue($value); 107 | } 108 | 109 | public function selectByVisibleText($text): void 110 | { 111 | $this->byVisibleText($text); 112 | } 113 | 114 | public function selectByVisiblePartialText($text): void 115 | { 116 | $this->byVisibleText($text, true); 117 | } 118 | 119 | public function deselectAll(): void 120 | { 121 | if (!$this->isMultiple()) { 122 | throw new UnsupportedOperationException('You may only deselect all options of checkboxes'); 123 | } 124 | 125 | foreach ($this->getRelatedElements() as $option) { 126 | $this->deselectOption($option); 127 | } 128 | } 129 | 130 | public function deselectByIndex($index): void 131 | { 132 | if (!$this->isMultiple()) { 133 | throw new UnsupportedOperationException('You may only deselect checkboxes'); 134 | } 135 | 136 | $this->byIndex($index, false); 137 | } 138 | 139 | public function deselectByValue($value): void 140 | { 141 | if (!$this->isMultiple()) { 142 | throw new UnsupportedOperationException('You may only deselect checkboxes'); 143 | } 144 | 145 | $this->byValue($value, false); 146 | } 147 | 148 | public function deselectByVisibleText($text): void 149 | { 150 | if (!$this->isMultiple()) { 151 | throw new UnsupportedOperationException('You may only deselect checkboxes'); 152 | } 153 | 154 | $this->byVisibleText($text, false, false); 155 | } 156 | 157 | public function deselectByVisiblePartialText($text): void 158 | { 159 | if (!$this->isMultiple()) { 160 | throw new UnsupportedOperationException('You may only deselect checkboxes'); 161 | } 162 | 163 | $this->byVisibleText($text, true, false); 164 | } 165 | 166 | private function byValue($value, $select = true): void 167 | { 168 | $matched = false; 169 | foreach ($this->getRelatedElements($value) as $element) { 170 | $select ? $this->selectOption($element) : $this->deselectOption($element); 171 | if (!$this->isMultiple()) { 172 | return; 173 | } 174 | 175 | $matched = true; 176 | } 177 | 178 | if (!$matched) { 179 | throw new NoSuchElementException(\sprintf('Cannot locate option with value: %s', $value)); 180 | } 181 | } 182 | 183 | private function byIndex($index, $select = true): void 184 | { 185 | $options = $this->getRelatedElements(); 186 | if (!isset($options[$index])) { 187 | throw new NoSuchElementException(\sprintf('Cannot locate option with index: %d', $index)); 188 | } 189 | 190 | $select ? $this->selectOption($options[$index]) : $this->deselectOption($options[$index]); 191 | } 192 | 193 | private function byVisibleText($text, $partial = false, $select = true): void 194 | { 195 | foreach ($this->getRelatedElements() as $element) { 196 | $normalizeFilter = \sprintf($partial ? 'contains(normalize-space(.), %s)' : 'normalize-space(.) = %s', XPathEscaper::escapeQuotes($text)); 197 | 198 | $xpath = 'ancestor::label'; 199 | $xpathNormalize = \sprintf('%s[%s]', $xpath, $normalizeFilter); 200 | if (null !== $id = $element->getAttribute('id')) { 201 | $idFilter = \sprintf('@for = %s', XPathEscaper::escapeQuotes($id)); 202 | 203 | $xpath .= \sprintf(' | //label[%s]', $idFilter); 204 | $xpathNormalize .= \sprintf(' | //label[%s and %s]', $idFilter, $normalizeFilter); 205 | } 206 | 207 | try { 208 | $element->findElement(WebDriverBy::xpath($xpathNormalize)); 209 | } catch (NoSuchElementException $e) { 210 | if ($partial) { 211 | continue; 212 | } 213 | 214 | try { 215 | // Since the mechanism of getting the text in xpath is not the same as 216 | // webdriver, use the expensive getText() to check if nothing is matched. 217 | if ($text !== $element->findElement(WebDriverBy::xpath($xpath))->getText()) { 218 | continue; 219 | } 220 | } catch (NoSuchElementException $e) { 221 | continue; 222 | } 223 | } 224 | 225 | $select ? $this->selectOption($element) : $this->deselectOption($element); 226 | if (!$this->isMultiple()) { 227 | return; 228 | } 229 | } 230 | } 231 | 232 | private function getRelatedElements($value = null): array 233 | { 234 | $valueSelector = null === $value ? '' : \sprintf(' and @value = %s', XPathEscaper::escapeQuotes($value)); 235 | if (null === $formId = $this->element->getAttribute('form')) { 236 | $form = $this->element->findElement(WebDriverBy::xpath('ancestor::form')); 237 | if ('' === $formId = (string) $form->getAttribute('id')) { 238 | return $form->findElements(WebDriverBy::xpath(\sprintf('.//input[@name = %s%s]', XPathEscaper::escapeQuotes($this->name), $valueSelector))); 239 | } 240 | } 241 | 242 | return $this->element->findElements(WebDriverBy::xpath( 243 | \sprintf('//form[@id = %1$s]//input[@name = %2$s%3$s] | //input[@form = %1$s and @name = %2$s%3$s]', XPathEscaper::escapeQuotes($formId), XPathEscaper::escapeQuotes($this->name), $valueSelector) 244 | )); 245 | } 246 | 247 | private function selectOption(WebDriverElement $option): void 248 | { 249 | if (!$option->isSelected()) { 250 | $option->click(); 251 | } 252 | } 253 | 254 | private function deselectOption(WebDriverElement $option): void 255 | { 256 | if ($option->isSelected()) { 257 | $option->click(); 258 | } 259 | } 260 | } 261 | -------------------------------------------------------------------------------- /src/WebDriver/WebDriverMouse.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | /* 4 | * This file is part of the Panther project. 5 | * 6 | * (c) Kévin Dunglas <dunglas@gmail.com> 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Symfony\Component\Panther\WebDriver; 15 | 16 | use Facebook\WebDriver\Interactions\Internal\WebDriverCoordinates; 17 | use Facebook\WebDriver\Internal\WebDriverLocatable; 18 | use Facebook\WebDriver\WebDriverMouse as BaseWebDriverMouse; 19 | use Symfony\Component\Panther\Client; 20 | use Symfony\Component\Panther\Exception\RuntimeException; 21 | 22 | /** 23 | * @author Dany Maillard <danymaillard93b@gmail.com> 24 | */ 25 | final class WebDriverMouse implements BaseWebDriverMouse 26 | { 27 | private BaseWebDriverMouse $mouse; 28 | private Client $client; 29 | 30 | public function __construct(BaseWebDriverMouse $mouse, Client $client) 31 | { 32 | $this->mouse = $mouse; 33 | $this->client = $client; 34 | } 35 | 36 | public function click(WebDriverCoordinates $where): self 37 | { 38 | $this->mouse->click($where); 39 | 40 | return $this; 41 | } 42 | 43 | public function clickTo($cssSelector): self 44 | { 45 | return $this->click($this->toCoordinates($cssSelector)); 46 | } 47 | 48 | public function contextClick(WebDriverCoordinates $where): self 49 | { 50 | $this->mouse->contextClick($where); 51 | 52 | return $this; 53 | } 54 | 55 | public function contextClickTo($cssSelector): self 56 | { 57 | return $this->contextClick($this->toCoordinates($cssSelector)); 58 | } 59 | 60 | public function doubleClick(WebDriverCoordinates $where): self 61 | { 62 | $this->mouse->doubleClick($where); 63 | 64 | return $this; 65 | } 66 | 67 | public function doubleClickTo($cssSelector): self 68 | { 69 | return $this->doubleClick($this->toCoordinates($cssSelector)); 70 | } 71 | 72 | public function mouseDown(WebDriverCoordinates $where): self 73 | { 74 | $this->mouse->mouseDown($where); 75 | 76 | return $this; 77 | } 78 | 79 | public function mouseDownTo($cssSelector): self 80 | { 81 | return $this->mouseDown($this->toCoordinates($cssSelector)); 82 | } 83 | 84 | public function mouseMove(WebDriverCoordinates $where, $xOffset = null, $yOffset = null): self 85 | { 86 | $this->mouse->mouseMove($where, $xOffset, $yOffset); 87 | 88 | return $this; 89 | } 90 | 91 | public function mouseMoveTo($cssSelector, $xOffset = null, $yOffset = null): self 92 | { 93 | return $this->mouseMove($this->toCoordinates($cssSelector), $xOffset, $yOffset); 94 | } 95 | 96 | public function mouseUp(WebDriverCoordinates $where): self 97 | { 98 | $this->mouse->mouseUp($where); 99 | 100 | return $this; 101 | } 102 | 103 | public function mouseUpTo($cssSelector): self 104 | { 105 | return $this->mouseUp($this->toCoordinates($cssSelector)); 106 | } 107 | 108 | private function toCoordinates($cssSelector): WebDriverCoordinates 109 | { 110 | $element = $this->client->getCrawler()->filter($cssSelector)->getElement(0); 111 | 112 | if (!$element instanceof WebDriverLocatable) { 113 | throw new RuntimeException(\sprintf('The element of "%s" CSS selector does not implement "%s".', $cssSelector, WebDriverLocatable::class)); 114 | } 115 | 116 | return $element->getCoordinates(); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/WebTestAssertionsTrait.php: -------------------------------------------------------------------------------- 1 | <?php 2 | 3 | /* 4 | * This file is part of the Panther project. 5 | * 6 | * (c) Kévin Dunglas <dunglas@gmail.com> 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace Symfony\Component\Panther; 15 | 16 | use Facebook\WebDriver\WebDriverElement; 17 | use Symfony\Bundle\FrameworkBundle\KernelBrowser; 18 | use Symfony\Bundle\FrameworkBundle\Test\WebTestAssertionsTrait as BaseWebTestAssertionsTrait; 19 | use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException; 20 | use Symfony\Component\Panther\Client as PantherClient; 21 | use Symfony\Component\Panther\Exception\LogicException; 22 | 23 | /** 24 | * Tweaks Symfony's WebTestAssertionsTrait to be compatible with Panther. 25 | * 26 | * @author Kévin Dunglas <dunglas@gmail.com> 27 | */ 28 | trait WebTestAssertionsTrait 29 | { 30 | use BaseWebTestAssertionsTrait { 31 | assertPageTitleSame as private baseAssertPageTitleSame; 32 | assertPageTitleContains as private baseAssertPageTitleContains; 33 | } 34 | use PantherTestCaseTrait; 35 | 36 | /** @TODO replace this after patching Symfony to allow xpath selectors */ 37 | public static function assertSelectorExists(string $selector, string $message = ''): void 38 | { 39 | $client = self::getClient(); 40 | 41 | if ($client instanceof PantherClient) { 42 | $by = $client::createWebDriverByFromLocator($selector); 43 | $elements = $client->findElements($by); 44 | self::assertNotEmpty($elements, $message); 45 | 46 | return; 47 | } 48 | 49 | self::assertNotEmpty($client->getCrawler()->filter($selector)); 50 | } 51 | 52 | /** @TODO replace this after patching Symfony to allow xpath selectors */ 53 | public static function assertSelectorNotExists(string $selector, string $message = ''): void 54 | { 55 | $client = self::getClient(); 56 | 57 | if ($client instanceof PantherClient) { 58 | $by = $client::createWebDriverByFromLocator($selector); 59 | $elements = $client->findElements($by); 60 | self::assertEmpty($elements, $message); 61 | 62 | return; 63 | } 64 | 65 | self::assertEmpty($client->getCrawler()->filter($selector)); 66 | } 67 | 68 | /** @TODO replace this after patching Symfony to allow xpath selectors */ 69 | public static function assertSelectorTextContains(string $selector, string $text, string $message = ''): void 70 | { 71 | self::assertStringContainsString($text, self::getText($selector), $message); 72 | } 73 | 74 | /** @TODO replace this after patching Symfony to allow xpath selectors */ 75 | public static function assertSelectorTextNotContains(string $selector, string $text, string $message = ''): void 76 | { 77 | self::assertStringNotContainsString($text, self::getText($selector), $message); 78 | } 79 | 80 | public static function assertPageTitleSame(string $expectedTitle, string $message = ''): void 81 | { 82 | $client = self::getClient(); 83 | if ($client instanceof PantherClient) { 84 | self::assertSame($expectedTitle, $client->getTitle()); 85 | 86 | return; 87 | } 88 | 89 | self::baseAssertPageTitleSame($expectedTitle, $message); 90 | } 91 | 92 | public static function assertPageTitleContains(string $expectedTitle, string $message = ''): void 93 | { 94 | $client = self::getClient(); 95 | if ($client instanceof PantherClient) { 96 | self::assertStringContainsString($expectedTitle, $client->getTitle()); 97 | 98 | return; 99 | } 100 | 101 | self::baseAssertPageTitleContains($expectedTitle, $message); 102 | } 103 | 104 | public static function assertSelectorWillExist(string $locator): void 105 | { 106 | /** @var PantherClient $client */ 107 | $client = self::getClient(); 108 | $client->waitFor($locator); 109 | self::assertSelectorExists($locator); 110 | } 111 | 112 | public static function assertSelectorWillNotExist(string $locator): void 113 | { 114 | /** @var PantherClient $client */ 115 | $client = self::getClient(); 116 | $client->waitForStaleness($locator); 117 | self::assertSelectorNotExists($locator); 118 | } 119 | 120 | public static function assertSelectorIsVisible(string $locator): void 121 | { 122 | $element = self::findElement($locator); 123 | self::assertTrue($element->isDisplayed(), 'Failed asserting that element is visible.'); 124 | } 125 | 126 | public static function assertSelectorWillBeVisible(string $locator): void 127 | { 128 | /** @var PantherClient $client */ 129 | $client = self::getClient(); 130 | $client->waitForVisibility($locator); 131 | self::assertSelectorIsVisible($locator); 132 | } 133 | 134 | public static function assertSelectorIsNotVisible(string $locator): void 135 | { 136 | $element = self::findElement($locator); 137 | self::assertFalse($element->isDisplayed(), 'Failed asserting that element is not visible.'); 138 | } 139 | 140 | public static function assertSelectorWillNotBeVisible(string $locator): void 141 | { 142 | /** @var PantherClient $client */ 143 | $client = self::getClient(); 144 | $client->waitForInvisibility($locator); 145 | self::assertSelectorIsNotVisible($locator); 146 | } 147 | 148 | public static function assertSelectorWillContain(string $locator, string $text): void 149 | { 150 | /** @var PantherClient $client */ 151 | $client = self::getClient(); 152 | $client->waitForElementToContain($locator, $text); 153 | self::assertSelectorTextContains($locator, $text); 154 | } 155 | 156 | public static function assertSelectorWillNotContain(string $locator, string $text): void 157 | { 158 | /** @var PantherClient $client */ 159 | $client = self::getClient(); 160 | $client->waitForElementToNotContain($locator, $text); 161 | self::assertSelectorTextNotContains($locator, $text); 162 | } 163 | 164 | public static function assertSelectorIsEnabled(string $locator): void 165 | { 166 | $element = self::findElement($locator); 167 | self::assertTrue($element->isEnabled(), 'Failed asserting that element is enabled.'); 168 | } 169 | 170 | public static function assertSelectorWillBeEnabled(string $locator): void 171 | { 172 | /** @var PantherClient $client */ 173 | $client = self::getClient(); 174 | $client->waitForEnabled($locator); 175 | self::assertSelectorAttributeContains($locator, 'disabled'); 176 | } 177 | 178 | public static function assertSelectorIsDisabled(string $locator): void 179 | { 180 | $element = self::findElement($locator); 181 | self::assertFalse($element->isEnabled(), 'Failed asserting that element is disabled.'); 182 | } 183 | 184 | public static function assertSelectorWillBeDisabled(string $locator): void 185 | { 186 | /** @var PantherClient $client */ 187 | $client = self::getClient(); 188 | $client->waitForDisabled($locator); 189 | self::assertSelectorAttributeContains($locator, 'disabled', 'true'); 190 | } 191 | 192 | public static function assertSelectorAttributeContains(string $locator, string $attribute, ?string $text = null): void 193 | { 194 | if (null === $text) { 195 | self::assertNull(self::getAttribute($locator, $attribute)); 196 | 197 | return; 198 | } 199 | 200 | self::assertStringContainsString($text, self::getAttribute($locator, $attribute)); 201 | } 202 | 203 | public static function assertSelectorAttributeWillContain(string $locator, string $attribute, string $text): void 204 | { 205 | /** @var PantherClient $client */ 206 | $client = self::getClient(); 207 | $client->waitForAttributeToContain($locator, $attribute, $text); 208 | self::assertSelectorAttributeContains($locator, $attribute, $text); 209 | } 210 | 211 | public static function assertSelectorAttributeNotContains(string $locator, string $attribute, string $text): void 212 | { 213 | self::assertStringNotContainsString($text, self::getAttribute($locator, $attribute)); 214 | } 215 | 216 | public static function assertSelectorAttributeWillNotContain(string $locator, string $attribute, string $text): void 217 | { 218 | /** @var PantherClient $client */ 219 | $client = self::getClient(); 220 | $client->waitForAttributeToNotContain($locator, $attribute, $text); 221 | self::assertSelectorAttributeNotContains($locator, $attribute, $text); 222 | } 223 | 224 | /** 225 | * @internal 226 | */ 227 | private static function getText(string $locator): string 228 | { 229 | $client = self::getClient(); 230 | if ($client instanceof PantherClient) { 231 | return self::findElement($locator)->getText(); 232 | } 233 | 234 | return $client->getCrawler()->filter($locator)->text(null, true); 235 | } 236 | 237 | /** 238 | * @internal 239 | */ 240 | private static function getAttribute(string $locator, string $attribute): ?string 241 | { 242 | $client = self::getClient(); 243 | if ($client instanceof PantherClient) { 244 | return self::findElement($locator)->getAttribute($attribute); 245 | } 246 | 247 | return $client->getCrawler()->filter($locator)->attr($attribute); 248 | } 249 | 250 | /** 251 | * @internal 252 | */ 253 | private static function findElement(string $locator): WebDriverElement 254 | { 255 | $client = self::getClient(); 256 | if (!$client instanceof PantherClient) { 257 | throw new LogicException(\sprintf('Using a client that is not an instance of "%s" is not supported.', PantherClient::class)); 258 | } 259 | 260 | $by = $client::createWebDriverByFromLocator($locator); 261 | 262 | return $client->findElement($by); 263 | } 264 | 265 | // Copied from WebTestCase to allow assertions to work with createClient 266 | 267 | /** 268 | * Creates a KernelBrowser. 269 | * 270 | * @param array $options An array of options to pass to the createKernel method 271 | * @param array $server An array of server parameters 272 | * 273 | * @return KernelBrowser A browser instance 274 | */ 275 | protected static function createClient(array $options = [], array $server = []): KernelBrowser 276 | { 277 | $kernel = static::bootKernel($options); 278 | 279 | try { 280 | /** @var KernelBrowser $client */ 281 | $client = $kernel->getContainer()->get('test.client'); 282 | } catch (ServiceNotFoundException $e) { 283 | if (class_exists(KernelBrowser::class)) { 284 | throw new LogicException('You cannot create the client used in functional tests if the "framework.test" config is not set to true.'); 285 | } 286 | throw new LogicException('You cannot create the client used in functional tests if the BrowserKit component is not available. Try running "composer require symfony/browser-kit"'); 287 | } 288 | 289 | $client->setServerParameters($server); 290 | 291 | /** @var KernelBrowser $wrapperClient */ 292 | $wrapperClient = self::getClient($client); 293 | 294 | return $wrapperClient; 295 | } 296 | } 297 | --------------------------------------------------------------------------------