The response has been limited to 50k tokens of the smallest files in the repo. You can remove this limitation by removing the max tokens filter.
├── .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 | ![CI](https://github.com/symfony/panther/workflows/CI/badge.svg)
 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 | 


--------------------------------------------------------------------------------