đã tạo được test case của createUser
This commit is contained in:
Vendored
+21
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) Taylor Otwell
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
Vendored
+32
@@ -0,0 +1,32 @@
|
||||
<p align="center"><img width="309" height="86" src="/art/logo.svg" alt="Logo Laravel Dusk"></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/laravel/dusk/actions"><img src="https://github.com/laravel/dusk/workflows/tests/badge.svg" alt="Build Status"></a>
|
||||
<a href="https://packagist.org/packages/laravel/dusk"><img src="https://img.shields.io/packagist/dt/laravel/dusk" alt="Total Downloads"></a>
|
||||
<a href="https://packagist.org/packages/laravel/dusk"><img src="https://img.shields.io/packagist/v/laravel/dusk" alt="Latest Stable Version"></a>
|
||||
<a href="https://packagist.org/packages/laravel/dusk"><img src="https://img.shields.io/packagist/l/laravel/dusk" alt="License"></a>
|
||||
</p>
|
||||
|
||||
## Introduction
|
||||
|
||||
Laravel Dusk provides an expressive, easy-to-use browser automation and testing API. By default, Dusk does not require you to install JDK or Selenium on your machine. Instead, Dusk uses a standalone Chromedriver. However, you are free to utilize any other Selenium driver you wish.
|
||||
|
||||
## Official Documentation
|
||||
|
||||
Documentation for Dusk can be found on the [Laravel website](https://laravel.com/docs/dusk).
|
||||
|
||||
## Contributing
|
||||
|
||||
Thank you for considering contributing to Dusk! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
|
||||
|
||||
## Security Vulnerabilities
|
||||
|
||||
Please review [our security policy](https://github.com/laravel/dusk/security/policy) on how to report security vulnerabilities.
|
||||
|
||||
## License
|
||||
|
||||
Laravel Dusk is open-sourced software licensed under the [MIT license](LICENSE.md).
|
||||
Vendored
+19
@@ -0,0 +1,19 @@
|
||||
<svg width="440" height="64" viewBox="0 0 440 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.laravel { fill: #1B1B18 } @media (prefers-color-scheme:dark) { .laravel { fill: #fff } }
|
||||
.dusk { fill: #868682 } @media (prefers-color-scheme:dark) { .dusk { fill: #A1A09A } }
|
||||
</style>
|
||||
<rect width="64" height="64" fill="#B13BC7"/>
|
||||
<path d="M28.7056 35.2944C22.434 29.0229 20.9407 19.6901 24.3752 12C22.1353 13.0453 19.9701 14.4638 18.1036 16.3304C9.96547 24.4685 9.96547 37.7583 18.1036 45.8965C26.2417 54.0345 39.5315 54.0345 47.6696 45.8965C49.5362 44.0299 50.9547 41.9393 52 39.6248C44.3845 43.0592 35.0518 41.6407 28.7056 35.2944Z" fill="#FDFDFC"/>
|
||||
<path class="laravel" d="M103.659 7.625H96V55.5535H118.046V48.5012H103.659V7.625Z"/>
|
||||
<path class="laravel" d="M145.087 27.9603C144.109 26.3627 142.722 25.1079 140.924 24.1946C139.126 23.2817 137.316 22.8251 135.496 22.8251C133.141 22.8251 130.988 23.2703 129.035 24.1602C127.08 25.0501 125.404 26.2723 124.006 27.8235C122.607 29.3757 121.519 31.1675 120.742 33.1978C119.964 35.2297 119.576 37.3643 119.576 39.6003C119.576 41.8831 119.964 44.0283 120.742 46.0362C121.519 48.0455 122.607 49.8258 124.006 51.377C125.404 52.9292 127.08 54.15 129.035 55.0399C130.988 55.9298 133.141 56.375 135.496 56.375C137.316 56.375 139.126 55.9183 140.924 55.0059C142.722 54.0936 144.11 52.8383 145.087 51.2402V55.5539H152.346V23.6471H145.087V27.9603ZM144.421 43.5027C143.977 44.7355 143.355 45.8081 142.556 46.7209C141.756 47.6342 140.791 48.3644 139.659 48.912C138.526 49.46 137.271 49.7335 135.895 49.7335C134.518 49.7335 133.275 49.46 132.165 48.912C131.054 48.364 130.1 47.6342 129.301 46.7209C128.502 45.8081 127.891 44.7355 127.469 43.5027C127.047 42.2705 126.837 40.9693 126.837 39.6003C126.837 38.2307 127.047 36.9296 127.469 35.6973C127.89 34.465 128.502 33.3929 129.301 32.4796C130.1 31.5672 131.055 30.8361 132.165 30.2886C133.275 29.7406 134.518 29.467 135.895 29.467C137.271 29.467 138.526 29.7406 139.659 30.2886C140.791 30.8366 141.756 31.5672 142.556 32.4796C143.355 33.3929 143.977 34.465 144.421 35.6973C144.864 36.9296 145.087 38.2307 145.087 39.6003C145.087 40.9693 144.864 42.2705 144.421 43.5027Z"/>
|
||||
<path class="laravel" d="M204.099 27.9603C203.121 26.3627 201.734 25.1079 199.936 24.1946C198.138 23.2817 196.328 22.8251 194.508 22.8251C192.153 22.8251 190.001 23.2703 188.047 24.1602C186.093 25.0501 184.417 26.2723 183.018 27.8235C181.619 29.3757 180.531 31.1675 179.754 33.1978C178.977 35.2297 178.589 37.3643 178.589 39.6003C178.589 41.8831 178.977 44.0283 179.754 46.0362C180.531 48.0455 181.619 49.8258 183.018 51.377C184.417 52.9292 186.092 54.15 188.047 55.0399C190 55.9298 192.153 56.375 194.508 56.375C196.328 56.375 198.138 55.9183 199.936 55.0059C201.734 54.0936 203.122 52.8383 204.099 51.2402V55.5539H211.359V23.6471H204.099V27.9603ZM203.433 43.5027C202.988 44.7355 202.367 45.8081 201.568 46.7209C200.768 47.6342 199.803 48.3644 198.67 48.912C197.538 49.46 196.283 49.7335 194.907 49.7335C193.53 49.7335 192.286 49.46 191.177 48.912C190.066 48.364 189.112 47.6342 188.313 46.7209C187.513 45.8081 186.903 44.7355 186.481 43.5027C186.058 42.2705 185.849 40.9693 185.849 39.6003C185.849 38.2307 186.058 36.9296 186.481 35.6973C186.902 34.465 187.513 33.3929 188.313 32.4796C189.112 31.5672 190.067 30.8361 191.177 30.2886C192.286 29.7406 193.53 29.467 194.907 29.467C196.283 29.467 197.538 29.7406 198.67 30.2886C199.803 30.8366 200.768 31.5672 201.568 32.4796C202.367 33.3929 202.988 34.465 203.433 35.6973C203.876 36.9296 204.099 38.2307 204.099 39.6003C204.099 40.9693 203.876 42.2705 203.433 43.5027Z"/>
|
||||
<path class="laravel" d="M291 7.625H283.741V55.5535H291V7.625Z"/>
|
||||
<path class="laravel" d="M158.075 55.5535H165.335V30.9901H177.79V23.6471H158.075V55.5535Z"/>
|
||||
<path class="laravel" d="M240.465 23.6471L231.34 48.0804L222.216 23.6471H214.862L226.778 55.5535H235.902L247.818 23.6471H240.465Z"/>
|
||||
<path class="laravel" d="M263.652 22.8265C254.762 22.8265 247.725 30.3369 247.725 39.6007C247.725 49.8416 254.536 56.375 264.582 56.375C270.205 56.375 273.795 54.1648 278.181 49.3514L273.276 45.4504C273.273 45.4542 269.574 50.4469 264.051 50.4469C257.631 50.4469 254.928 45.1262 254.928 42.3733H279.013C280.278 31.8326 273.537 22.8265 263.652 22.8265ZM254.947 36.8278C255.003 36.2138 255.839 28.7541 263.594 28.7541C271.349 28.7541 272.291 36.2128 272.345 36.8278H254.947Z"/>
|
||||
<path class="dusk" d="M411.686 55.6851V7.68506H416.726V55.6851H411.686ZM416.096 40.8801L432.476 23.5551H438.776L424.916 38.2341L424.412 38.2971L415.718 47.2431L416.096 40.8801ZM426.176 36.5331L439.595 55.6851H433.673L421.577 38.0451L426.176 36.5331Z"/>
|
||||
<path class="dusk" d="M393.623 56.3151C389.675 56.3151 386.525 55.4121 384.173 53.6061C381.821 51.7581 380.477 49.1541 380.141 45.7941H384.992C385.286 47.8521 386.189 49.4271 387.701 50.5191C389.213 51.611 391.229 52.1571 393.749 52.1571C395.975 52.1571 397.718 51.737 398.978 50.897C400.238 50.057 400.868 48.8601 400.868 47.306C400.868 46.2141 400.511 45.2691 399.797 44.4711C399.083 43.6311 397.655 42.9381 395.513 42.3921L390.347 41.069C387.407 40.313 385.202 39.1581 383.732 37.604C382.262 36.008 381.527 34.1181 381.527 31.9341C381.527 29.1621 382.556 26.9781 384.614 25.382C386.672 23.744 389.465 22.925 392.993 22.925C396.479 22.925 399.314 23.765 401.498 25.445C403.682 27.125 404.921 29.456 405.215 32.438H400.364C400.07 30.716 399.272 29.393 397.97 28.469C396.668 27.545 394.946 27.083 392.804 27.083C390.746 27.083 389.15 27.482 388.016 28.28C386.882 29.036 386.315 30.128 386.315 31.556C386.315 32.648 386.714 33.593 387.512 34.391C388.352 35.189 389.738 35.8611 391.67 36.4071L396.71 37.793C399.692 38.591 401.939 39.8091 403.451 41.4471C404.963 43.0851 405.719 45.0591 405.719 47.3691C405.719 50.1831 404.648 52.3881 402.506 53.9841C400.364 55.5381 397.403 56.3151 393.623 56.3151Z"/>
|
||||
<path class="dusk" d="M358.207 56.3151C356.107 56.3151 354.238 55.8951 352.6 55.0551C350.962 54.1731 349.681 52.9551 348.757 51.4011C347.833 49.8051 347.371 47.9781 347.371 45.9201V23.5551H352.411V44.5971C352.411 47.0331 352.999 48.8601 354.175 50.0781C355.393 51.2961 357.115 51.9051 359.341 51.9051C361.357 51.9051 363.142 51.4431 364.696 50.5191C366.25 49.5951 367.468 48.3141 368.35 46.6761C369.274 44.9961 369.736 43.0851 369.736 40.9431L370.555 48.5661C369.505 50.9601 367.867 52.8501 365.641 54.2361C363.415 55.6221 360.937 56.3151 358.207 56.3151ZM370.051 55.6851V48.1251H369.736V23.5551H374.776V55.6851H370.051Z"/>
|
||||
<path class="dusk" d="M306.087 55.6851V51.1491H318.246C322.026 51.1491 325.281 50.3931 328.011 48.8811C330.741 47.3271 332.841 45.1641 334.311 42.3921C335.781 39.6201 336.516 34.7671 336.516 31.1131C336.516 27.5011 335.781 23.3511 334.311 20.6631C332.841 17.9751 330.741 15.8961 328.011 14.4261C325.323 12.9561 322.068 12.2211 318.246 12.2211H306.087V7.68506H318.246C322.992 7.68506 327.129 8.63006 330.657 10.5201C334.185 12.3681 336.915 14.9931 338.847 18.3951C340.821 21.7551 341.808 26.7031 341.808 31.2391C341.808 35.7751 340.821 41.4051 338.847 44.8491C336.873 48.2511 334.122 50.9181 330.594 52.8501C327.108 54.7401 323.013 55.6851 318.309 55.6851H306.087ZM303 55.6851V7.68506H308.166V55.6851H303Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.0 KiB |
BIN
Binary file not shown.
Vendored
+2
File diff suppressed because one or more lines are too long
Vendored
+86
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"name": "laravel/dusk",
|
||||
"description": "Laravel Dusk provides simple end-to-end testing and browser automation.",
|
||||
"keywords": [
|
||||
"laravel",
|
||||
"testing",
|
||||
"webdriver"
|
||||
],
|
||||
"license": "MIT",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Taylor Otwell",
|
||||
"email": "taylor@laravel.com"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": "^8.1",
|
||||
"ext-json": "*",
|
||||
"ext-zip": "*",
|
||||
"guzzlehttp/guzzle": "^7.5",
|
||||
"illuminate/console": "^10.0|^11.0|^12.0|^13.0",
|
||||
"illuminate/support": "^10.0|^11.0|^12.0|^13.0",
|
||||
"php-webdriver/webdriver": "^1.15.2",
|
||||
"symfony/console": "^6.2|^7.0|^8.0",
|
||||
"symfony/finder": "^6.2|^7.0|^8.0",
|
||||
"symfony/process": "^6.2|^7.0|^8.0",
|
||||
"vlucas/phpdotenv": "^5.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"laravel/framework": "^10.0|^11.0|^12.0|^13.0",
|
||||
"mockery/mockery": "^1.6",
|
||||
"orchestra/testbench-core": "^8.19|^9.17|^10.8|^11.0",
|
||||
"phpstan/phpstan": "^1.10",
|
||||
"phpunit/phpunit": "^10.1|^11.0|^12.0.1",
|
||||
"psy/psysh": "^0.11.12|^0.12",
|
||||
"symfony/yaml": "^6.2|^7.0|^8.0"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-pcntl": "Used to gracefully terminate Dusk when tests are running."
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Laravel\\Dusk\\": "src/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Laravel\\Dusk\\Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Laravel\\Dusk\\DuskServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"audit": {
|
||||
"block-insecure": false
|
||||
},
|
||||
"sort-packages": true
|
||||
},
|
||||
"scripts": {
|
||||
"post-autoload-dump": [
|
||||
"@clear",
|
||||
"@prepare"
|
||||
],
|
||||
"clear": "@php vendor/bin/testbench package:purge-skeleton --ansi",
|
||||
"prepare": "@php vendor/bin/testbench package:discover --ansi",
|
||||
"build": "@php vendor/bin/testbench workbench:build --ansi",
|
||||
"serve": [
|
||||
"@build",
|
||||
"@php vendor/bin/testbench serve"
|
||||
],
|
||||
"lint": [
|
||||
"@php vendor/bin/phpstan analyse"
|
||||
],
|
||||
"test": [
|
||||
"@build",
|
||||
"@php vendor/bin/phpunit"
|
||||
]
|
||||
},
|
||||
"minimum-stability": "dev",
|
||||
"prefer-stable": true
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit backupGlobals="false"
|
||||
beStrictAboutTestsThatDoNotTestAnything="false"
|
||||
colors="true"
|
||||
processIsolation="false"
|
||||
stopOnError="false"
|
||||
stopOnFailure="false"
|
||||
cacheDirectory=".phpunit.cache"
|
||||
backupStaticProperties="false">
|
||||
<testsuites>
|
||||
<testsuite name="Browser Test Suite">
|
||||
<directory suffix="Test.php">./tests/Browser</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
</phpunit>
|
||||
Vendored
+850
@@ -0,0 +1,850 @@
|
||||
<?php
|
||||
|
||||
namespace Laravel\Dusk;
|
||||
|
||||
use BadMethodCallException;
|
||||
use Closure;
|
||||
use Facebook\WebDriver\Remote\WebDriverBrowserType;
|
||||
use Facebook\WebDriver\WebDriverBy;
|
||||
use Facebook\WebDriver\WebDriverDimension;
|
||||
use Facebook\WebDriver\WebDriverPoint;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Support\Traits\Macroable;
|
||||
|
||||
class Browser
|
||||
{
|
||||
use Concerns\InteractsWithAuthentication,
|
||||
Concerns\InteractsWithCookies,
|
||||
Concerns\InteractsWithElements,
|
||||
Concerns\InteractsWithJavascript,
|
||||
Concerns\InteractsWithMouse,
|
||||
Concerns\MakesAssertions,
|
||||
Concerns\MakesUrlAssertions,
|
||||
Concerns\WaitsForElements,
|
||||
Macroable {
|
||||
__call as macroCall;
|
||||
}
|
||||
|
||||
/**
|
||||
* The base URL for all URLs.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public static $baseUrl;
|
||||
|
||||
/**
|
||||
* The directory that will contain any screenshots.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public static $storeScreenshotsAt;
|
||||
|
||||
/**
|
||||
* The common screen sizes to use for responsive screenshots.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
public static $responsiveScreenSizes = [
|
||||
'xs' => [
|
||||
'width' => 360,
|
||||
'height' => 640,
|
||||
],
|
||||
'sm' => [
|
||||
'width' => 640,
|
||||
'height' => 360,
|
||||
],
|
||||
'md' => [
|
||||
'width' => 768,
|
||||
'height' => 1024,
|
||||
],
|
||||
'lg' => [
|
||||
'width' => 1024,
|
||||
'height' => 768,
|
||||
],
|
||||
'xl' => [
|
||||
'width' => 1280,
|
||||
'height' => 1024,
|
||||
],
|
||||
'2xl' => [
|
||||
'width' => 1536,
|
||||
'height' => 864,
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* The directory that will contain any console logs.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public static $storeConsoleLogAt;
|
||||
|
||||
/**
|
||||
* The directory where source code snapshots will be stored.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public static $storeSourceAt;
|
||||
|
||||
/**
|
||||
* Console log messages to ignore when storing console logs.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
public static $ignoreConsoleMessages = [
|
||||
'favicon.ico',
|
||||
];
|
||||
|
||||
/**
|
||||
* The browsers that support retrieving logs.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
public static $supportsRemoteLogs = [
|
||||
WebDriverBrowserType::CHROME,
|
||||
WebDriverBrowserType::PHANTOMJS,
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the callback which resolves the default user to authenticate.
|
||||
*
|
||||
* @var \Closure
|
||||
*/
|
||||
public static $userResolver;
|
||||
|
||||
/**
|
||||
* The default wait time in seconds.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
public static $waitSeconds = 5;
|
||||
|
||||
/**
|
||||
* The RemoteWebDriver instance.
|
||||
*
|
||||
* @var \Facebook\WebDriver\Remote\RemoteWebDriver
|
||||
*/
|
||||
public $driver;
|
||||
|
||||
/**
|
||||
* The element resolver instance.
|
||||
*
|
||||
* @var \Laravel\Dusk\ElementResolver
|
||||
*/
|
||||
public $resolver;
|
||||
|
||||
/**
|
||||
* The page object currently being viewed.
|
||||
*
|
||||
* @var mixed
|
||||
*/
|
||||
public $page;
|
||||
|
||||
/**
|
||||
* The component object currently being viewed.
|
||||
*
|
||||
* @var mixed
|
||||
*/
|
||||
public $component;
|
||||
|
||||
/**
|
||||
* Indicates that the browser should be resized to fit the entire "body" before screenshotting failures.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $fitOnFailure = true;
|
||||
|
||||
/**
|
||||
* Create a browser instance.
|
||||
*
|
||||
* @param \Facebook\WebDriver\Remote\RemoteWebDriver $driver
|
||||
* @param \Laravel\Dusk\ElementResolver|null $resolver
|
||||
* @return void
|
||||
*/
|
||||
public function __construct($driver, $resolver = null)
|
||||
{
|
||||
$this->driver = $driver;
|
||||
|
||||
$this->resolver = $resolver ?: new ElementResolver($driver);
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse to the given URL.
|
||||
*
|
||||
* @param string|Page $url
|
||||
* @return $this
|
||||
*/
|
||||
public function visit($url)
|
||||
{
|
||||
// First, if the URL is an object it means we are actually dealing with a page
|
||||
// and we need to create this page then get the URL from the page object as
|
||||
// it contains the URL. Once that is done, we will be ready to format it.
|
||||
if (is_object($url)) {
|
||||
$page = $url;
|
||||
|
||||
$url = $page->url();
|
||||
}
|
||||
|
||||
// If the URL does not start with http or https, then we will prepend the base
|
||||
// URL onto the URL and navigate to the URL. This will actually navigate to
|
||||
// the URL in the browser. Then we will be ready to make assertions, etc.
|
||||
if (! Str::startsWith($url, ['http://', 'https://'])) {
|
||||
$url = static::$baseUrl.'/'.ltrim($url, '/');
|
||||
}
|
||||
|
||||
$this->driver->navigate()->to($url);
|
||||
|
||||
// If the page variable was set, we will call the "on" method which will set a
|
||||
// page instance variable and call an assert method on the page so that the
|
||||
// page can have the chance to verify that we are within the right pages.
|
||||
if (isset($page)) {
|
||||
$this->on($page);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse to the given route.
|
||||
*
|
||||
* @param string $route
|
||||
* @param array $parameters
|
||||
* @return $this
|
||||
*/
|
||||
public function visitRoute($route, $parameters = [])
|
||||
{
|
||||
return $this->visit(route($route, $parameters));
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse to the "about:blank" page.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function blank()
|
||||
{
|
||||
$this->driver->navigate()->to('about:blank');
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current page object.
|
||||
*
|
||||
* @param mixed $page
|
||||
* @return $this
|
||||
*/
|
||||
public function on($page)
|
||||
{
|
||||
$this->onWithoutAssert($page);
|
||||
|
||||
$page->assert($this);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current page object without executing the assertions.
|
||||
*
|
||||
* @param mixed $page
|
||||
* @return $this
|
||||
*/
|
||||
public function onWithoutAssert($page)
|
||||
{
|
||||
$this->page = $page;
|
||||
|
||||
// Here we will set the page elements on the resolver instance, which will allow
|
||||
// the developer to access short-cuts for CSS selectors on the page which can
|
||||
// allow for more expressive navigation and interaction with all the pages.
|
||||
$this->resolver->pageElements(array_merge(
|
||||
$page::siteElements(), $page->elements()
|
||||
));
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the page.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function refresh()
|
||||
{
|
||||
$this->driver->navigate()->refresh();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the previous page.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function back()
|
||||
{
|
||||
$this->driver->navigate()->back();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the next page.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function forward()
|
||||
{
|
||||
$this->driver->navigate()->forward();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximize the browser window.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function maximize()
|
||||
{
|
||||
$this->driver->manage()->window()->maximize();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize the browser window.
|
||||
*
|
||||
* @param int $width
|
||||
* @param int $height
|
||||
* @return $this
|
||||
*/
|
||||
public function resize($width, $height)
|
||||
{
|
||||
$this->driver->manage()->window()->setSize(
|
||||
new WebDriverDimension($width, $height)
|
||||
);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make the browser window as large as the content.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function fitContent()
|
||||
{
|
||||
$this->driver->switchTo()->defaultContent();
|
||||
|
||||
$html = $this->driver->findElement(WebDriverBy::tagName('html'));
|
||||
|
||||
if (! empty($html) && $html->getSize()->getWidth() > 0 && $html->getSize()->getHeight() > 0) {
|
||||
$this->resize($html->getSize()->getWidth(), $html->getSize()->getHeight());
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable fit on failures.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function disableFitOnFailure()
|
||||
{
|
||||
$this->fitOnFailure = false;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable fit on failures.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function enableFitOnFailure()
|
||||
{
|
||||
$this->fitOnFailure = true;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the browser window.
|
||||
*
|
||||
* @param int $x
|
||||
* @param int $y
|
||||
* @return $this
|
||||
*/
|
||||
public function move($x, $y)
|
||||
{
|
||||
$this->driver->manage()->window()->setPosition(
|
||||
new WebDriverPoint($x, $y)
|
||||
);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll element into view at the given selector.
|
||||
*
|
||||
* @param string $selector
|
||||
* @return $this
|
||||
*/
|
||||
public function scrollIntoView($selector)
|
||||
{
|
||||
$selector = addslashes($this->resolver->format($selector));
|
||||
|
||||
$this->driver->executeScript("document.querySelector(\"$selector\").scrollIntoView();");
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll screen to element at the given selector.
|
||||
*
|
||||
* @param string $selector
|
||||
* @return $this
|
||||
*/
|
||||
public function scrollTo($selector)
|
||||
{
|
||||
$this->ensurejQueryIsAvailable();
|
||||
|
||||
$selector = addslashes($this->resolver->format($selector));
|
||||
|
||||
$this->driver->executeScript("jQuery(\"html, body\").animate({scrollTop: jQuery(\"$selector\").offset().top}, 0);");
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a screenshot and store it with the given name.
|
||||
*
|
||||
* @param string $name
|
||||
* @return $this
|
||||
*/
|
||||
public function screenshot($name)
|
||||
{
|
||||
$filePath = sprintf('%s/%s.png', rtrim(static::$storeScreenshotsAt, '/'), $name);
|
||||
|
||||
$directoryPath = dirname($filePath);
|
||||
|
||||
if (! is_dir($directoryPath)) {
|
||||
mkdir($directoryPath, 0777, true);
|
||||
}
|
||||
|
||||
$this->driver->takeScreenshot($filePath);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a series of screenshots at different browser sizes to emulate different devices.
|
||||
*
|
||||
* @param string $name
|
||||
* @return $this
|
||||
*/
|
||||
public function responsiveScreenshots($name)
|
||||
{
|
||||
if (substr($name, -1) !== '/') {
|
||||
$name .= '-';
|
||||
}
|
||||
|
||||
foreach (static::$responsiveScreenSizes as $device => $size) {
|
||||
$this->resize($size['width'], $size['height'])
|
||||
->screenshot("$name$device");
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a screenshot of a specific element and store it with the given name.
|
||||
*
|
||||
* @param string $selector
|
||||
* @param string $name
|
||||
* @return $this
|
||||
*/
|
||||
public function screenshotElement($selector, $name)
|
||||
{
|
||||
$filePath = sprintf('%s/%s.png', rtrim(static::$storeScreenshotsAt, '/'), $name);
|
||||
|
||||
$directoryPath = dirname($filePath);
|
||||
|
||||
if (! is_dir($directoryPath)) {
|
||||
mkdir($directoryPath, 0777, true);
|
||||
}
|
||||
|
||||
$this->scrollIntoView($selector)
|
||||
->driver->findElement(WebDriverBy::cssSelector($this->resolver->format($selector)))
|
||||
->takeElementScreenshot($filePath);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the console output with the given name.
|
||||
*
|
||||
* @param string $name
|
||||
* @return $this
|
||||
*/
|
||||
public function storeConsoleLog($name)
|
||||
{
|
||||
if (in_array($this->driver->getCapabilities()->getBrowserName(), static::$supportsRemoteLogs)) {
|
||||
$console = collect($this->driver->manage()->getLog('browser'))
|
||||
->reject(fn ($entry) => Str::contains($entry['message'] ?? '', static::$ignoreConsoleMessages))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if (! empty($console)) {
|
||||
$filePath = sprintf('%s/%s.log', rtrim(static::$storeConsoleLogAt, '/'), $name);
|
||||
|
||||
$directoryPath = dirname($filePath);
|
||||
|
||||
if (! is_dir($directoryPath)) {
|
||||
mkdir($directoryPath, 0777, true);
|
||||
}
|
||||
|
||||
file_put_contents(
|
||||
$filePath, json_encode($console, JSON_PRETTY_PRINT)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a snapshot of the page's current source code with the given name.
|
||||
*
|
||||
* @param string $name
|
||||
* @return $this
|
||||
*/
|
||||
public function storeSource($name)
|
||||
{
|
||||
$source = $this->driver->getPageSource();
|
||||
|
||||
if (! empty($source)) {
|
||||
$filePath = sprintf('%s/%s.txt', rtrim(static::$storeSourceAt, '/'), $name);
|
||||
|
||||
$directoryPath = dirname($filePath);
|
||||
|
||||
if (! is_dir($directoryPath)) {
|
||||
mkdir($directoryPath, 0777, true);
|
||||
}
|
||||
|
||||
file_put_contents($filePath, $source);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to a specified frame in the browser and execute the given callback.
|
||||
*
|
||||
* @param string $selector
|
||||
* @param \Closure $callback
|
||||
* @return $this
|
||||
*/
|
||||
public function withinFrame($selector, Closure $callback)
|
||||
{
|
||||
$this->driver->switchTo()->frame($this->resolver->findOrFail($selector));
|
||||
|
||||
$callback($this);
|
||||
|
||||
$this->driver->switchTo()->defaultContent();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a Closure with a scoped browser instance.
|
||||
*
|
||||
* @param string|\Laravel\Dusk\Component $selector
|
||||
* @param \Closure $callback
|
||||
* @return $this
|
||||
*/
|
||||
public function within($selector, Closure $callback)
|
||||
{
|
||||
return $this->with($selector, $callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a Closure with a scoped browser instance.
|
||||
*
|
||||
* @param string|\Laravel\Dusk\Component $selector
|
||||
* @param \Closure $callback
|
||||
* @return $this
|
||||
*/
|
||||
public function with($selector, Closure $callback)
|
||||
{
|
||||
$browser = new static(
|
||||
$this->driver, new ElementResolver($this->driver, $this->resolver->format($selector))
|
||||
);
|
||||
|
||||
if ($this->page) {
|
||||
$browser->onWithoutAssert($this->page);
|
||||
}
|
||||
|
||||
if ($selector instanceof Component) {
|
||||
$browser->onComponent($selector, $this->resolver);
|
||||
}
|
||||
|
||||
call_user_func($callback, $browser);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a Closure outside of the current browser scope.
|
||||
*
|
||||
* @param string|\Laravel\Dusk\Component $selector
|
||||
* @param \Closure $callback
|
||||
* @return $this
|
||||
*/
|
||||
public function elsewhere($selector, Closure $callback)
|
||||
{
|
||||
$browser = new static(
|
||||
$this->driver, new ElementResolver($this->driver, 'body '.$selector)
|
||||
);
|
||||
|
||||
if ($this->page) {
|
||||
$browser->onWithoutAssert($this->page);
|
||||
}
|
||||
|
||||
if ($selector instanceof Component) {
|
||||
$browser->onComponent($selector, $this->resolver);
|
||||
}
|
||||
|
||||
call_user_func($callback, $browser);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a Closure outside of the current browser scope when the selector is available.
|
||||
*
|
||||
* @param string $selector
|
||||
* @param \Closure $callback
|
||||
* @param int|null $seconds
|
||||
* @return $this
|
||||
*/
|
||||
public function elsewhereWhenAvailable($selector, Closure $callback, $seconds = null)
|
||||
{
|
||||
return $this->elsewhere('', function ($browser) use ($selector, $callback, $seconds) {
|
||||
$browser->whenAvailable($selector, $callback, $seconds);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a browser scoped to the given component.
|
||||
*
|
||||
* @param \Laravel\Dusk\Component $component
|
||||
* @return \Laravel\Dusk\Browser
|
||||
*/
|
||||
public function component(Component $component)
|
||||
{
|
||||
$browser = new static(
|
||||
$this->driver, new ElementResolver($this->driver, $this->resolver->format($component))
|
||||
);
|
||||
|
||||
if ($this->page) {
|
||||
$browser->onWithoutAssert($this->page);
|
||||
}
|
||||
|
||||
$browser->onComponent($component, $this->resolver);
|
||||
|
||||
return $browser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current component state.
|
||||
*
|
||||
* @param \Laravel\Dusk\Component $component
|
||||
* @param \Laravel\Dusk\ElementResolver $parentResolver
|
||||
* @return void
|
||||
*/
|
||||
public function onComponent($component, $parentResolver)
|
||||
{
|
||||
$this->component = $component;
|
||||
|
||||
// Here we will set the component elements on the resolver instance, which will allow
|
||||
// the developer to access short-cuts for CSS selectors on the component which can
|
||||
// allow for more expressive navigation and interaction with all the components.
|
||||
$this->resolver->pageElements(
|
||||
$component->elements() + $parentResolver->elements
|
||||
);
|
||||
|
||||
$component->assert($this);
|
||||
|
||||
$this->resolver->prefix = $this->resolver->format(
|
||||
$component->selector()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure that jQuery is available on the page.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function ensurejQueryIsAvailable()
|
||||
{
|
||||
if ($this->driver->executeScript('return window.jQuery == null')) {
|
||||
$this->driver->executeScript(file_get_contents(__DIR__.'/../bin/jquery.js'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause for the given amount of milliseconds.
|
||||
*
|
||||
* @param int $milliseconds
|
||||
* @return $this
|
||||
*/
|
||||
public function pause($milliseconds)
|
||||
{
|
||||
usleep($milliseconds * 1000);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause for the given amount of milliseconds if the given condition is true.
|
||||
*
|
||||
* @param bool $boolean
|
||||
* @param int $milliseconds
|
||||
* @return $this
|
||||
*/
|
||||
public function pauseIf($boolean, $milliseconds)
|
||||
{
|
||||
if ($boolean) {
|
||||
return $this->pause($milliseconds);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause for the given amount of milliseconds unless the given condition is true.
|
||||
*
|
||||
* @param bool $boolean
|
||||
* @param int $milliseconds
|
||||
* @return $this
|
||||
*/
|
||||
public function pauseUnless($boolean, $milliseconds)
|
||||
{
|
||||
if (! $boolean) {
|
||||
return $this->pause($milliseconds);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the browser.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function quit()
|
||||
{
|
||||
$this->driver->quit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tap the browser into a callback.
|
||||
*
|
||||
* @param \Closure $callback
|
||||
* @return $this
|
||||
*/
|
||||
public function tap($callback)
|
||||
{
|
||||
$callback($this);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dump the content from the last response.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function dump()
|
||||
{
|
||||
dump($this->driver->getPageSource());
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dump and die the content from the last response.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function dd()
|
||||
{
|
||||
dump($this->driver->getPageSource());
|
||||
|
||||
$this->quit();
|
||||
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause execution of test and open Laravel Tinker (PsySH) REPL.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function tinker()
|
||||
{
|
||||
\Psy\debug([
|
||||
'browser' => $this,
|
||||
'driver' => $this->driver,
|
||||
'resolver' => $this->resolver,
|
||||
'page' => $this->page,
|
||||
], $this);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop running tests but leave the browser open.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function stop()
|
||||
{
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamically call a method on the browser.
|
||||
*
|
||||
* @param string $method
|
||||
* @param array $parameters
|
||||
* @return mixed
|
||||
*
|
||||
* @throws \BadMethodCallException
|
||||
*/
|
||||
public function __call($method, $parameters)
|
||||
{
|
||||
if (static::hasMacro($method)) {
|
||||
return $this->macroCall($method, $parameters);
|
||||
}
|
||||
|
||||
if ($this->component && method_exists($this->component, $method)) {
|
||||
array_unshift($parameters, $this);
|
||||
|
||||
$this->component->{$method}(...$parameters);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
if ($this->page && method_exists($this->page, $method)) {
|
||||
array_unshift($parameters, $this);
|
||||
|
||||
$this->page->{$method}(...$parameters);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
throw new BadMethodCallException("Call to undefined method [{$method}].");
|
||||
}
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace Laravel\Dusk\Chrome;
|
||||
|
||||
use Laravel\Dusk\OperatingSystem;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
class ChromeProcess
|
||||
{
|
||||
/**
|
||||
* The path to the Chromedriver.
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
protected $driver;
|
||||
|
||||
/**
|
||||
* Create a new ChromeProcess instance.
|
||||
*
|
||||
* @param string|null $driver
|
||||
* @return void
|
||||
*/
|
||||
public function __construct($driver = null)
|
||||
{
|
||||
$this->driver = $driver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the process to run Chromedriver.
|
||||
*
|
||||
* @param array $arguments
|
||||
* @return \Symfony\Component\Process\Process
|
||||
*
|
||||
* @throws \RuntimeException
|
||||
*/
|
||||
public function toProcess(array $arguments = [])
|
||||
{
|
||||
if ($this->driver) {
|
||||
$driver = $this->driver;
|
||||
} else {
|
||||
$filenames = [
|
||||
'linux' => 'chromedriver-linux',
|
||||
'mac' => 'chromedriver-mac',
|
||||
'mac-intel' => 'chromedriver-mac-intel',
|
||||
'mac-arm' => 'chromedriver-mac-arm',
|
||||
'win' => 'chromedriver-win.exe',
|
||||
];
|
||||
|
||||
$driver = __DIR__.'/../../bin'.DIRECTORY_SEPARATOR.$filenames[$this->operatingSystemId()];
|
||||
}
|
||||
|
||||
$this->driver = realpath($driver);
|
||||
|
||||
if ($this->driver === false) {
|
||||
throw new RuntimeException(
|
||||
"Invalid path to Chromedriver [{$driver}]. Make sure to install the Chromedriver first by running the dusk:chrome-driver command."
|
||||
);
|
||||
}
|
||||
|
||||
return $this->process($arguments);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Chromedriver with Symfony Process.
|
||||
*
|
||||
* @param array $arguments
|
||||
* @return \Symfony\Component\Process\Process
|
||||
*/
|
||||
protected function process(array $arguments = [])
|
||||
{
|
||||
return new Process(
|
||||
array_merge([$this->driver], $arguments), null, $this->chromeEnvironment()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Chromedriver environment variables.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function chromeEnvironment()
|
||||
{
|
||||
if ($this->onMac() || $this->onWindows()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return ['DISPLAY' => $_ENV['DISPLAY'] ?? ':0'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if Dusk is running on Windows or Windows Subsystem for Linux.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function onWindows()
|
||||
{
|
||||
return OperatingSystem::onWindows();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if Dusk is running on Mac.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function onMac()
|
||||
{
|
||||
return OperatingSystem::onMac();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine OS ID.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function operatingSystemId()
|
||||
{
|
||||
return OperatingSystem::id();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace Laravel\Dusk\Chrome;
|
||||
|
||||
trait SupportsChrome
|
||||
{
|
||||
/**
|
||||
* The path to the custom Chromedriver binary.
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
protected static $chromeDriver;
|
||||
|
||||
/**
|
||||
* The Chromedriver process instance.
|
||||
*
|
||||
* @var \Symfony\Component\Process\Process
|
||||
*/
|
||||
protected static $chromeProcess;
|
||||
|
||||
/**
|
||||
* Start the Chromedriver process.
|
||||
*
|
||||
* @param array $arguments
|
||||
* @return void
|
||||
*
|
||||
* @throws \RuntimeException
|
||||
*/
|
||||
public static function startChromeDriver(array $arguments = [])
|
||||
{
|
||||
static::$chromeProcess = static::buildChromeProcess($arguments);
|
||||
|
||||
static::$chromeProcess->start();
|
||||
|
||||
static::afterClass(function () {
|
||||
static::stopChromeDriver();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the Chromedriver process.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function stopChromeDriver()
|
||||
{
|
||||
if (static::$chromeProcess) {
|
||||
static::$chromeProcess->stop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the process to run the Chromedriver.
|
||||
*
|
||||
* @param array $arguments
|
||||
* @return \Symfony\Component\Process\Process
|
||||
*
|
||||
* @throws \RuntimeException
|
||||
*/
|
||||
protected static function buildChromeProcess(array $arguments = [])
|
||||
{
|
||||
return (new ChromeProcess(static::$chromeDriver))->toProcess($arguments);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the path to the custom Chromedriver.
|
||||
*
|
||||
* @param string $path
|
||||
* @return void
|
||||
*/
|
||||
public static function useChromedriver($path)
|
||||
{
|
||||
static::$chromeDriver = $path;
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace Laravel\Dusk;
|
||||
|
||||
abstract class Component
|
||||
{
|
||||
/**
|
||||
* Get the root selector associated with this component.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
abstract public function selector();
|
||||
|
||||
/**
|
||||
* Assert that the current page contains this component.
|
||||
*
|
||||
* @param \Laravel\Dusk\Browser $browser
|
||||
* @return void
|
||||
*/
|
||||
public function assert(Browser $browser)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the element shortcuts for the page.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function elements()
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow this class to be used in place of a selector string.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function __toString()
|
||||
{
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace Laravel\Dusk\Concerns;
|
||||
|
||||
use Laravel\Dusk\Browser;
|
||||
use PHPUnit\Framework\Assert as PHPUnit;
|
||||
|
||||
trait InteractsWithAuthentication
|
||||
{
|
||||
/**
|
||||
* Log into the application as the default user.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function login()
|
||||
{
|
||||
return $this->loginAs(call_user_func(Browser::$userResolver));
|
||||
}
|
||||
|
||||
/**
|
||||
* Log into the application using a given user ID or email.
|
||||
*
|
||||
* @param object|string $userId
|
||||
* @param string|null $guard
|
||||
* @return $this
|
||||
*/
|
||||
public function loginAs($userId, $guard = null)
|
||||
{
|
||||
$userId = is_object($userId) && method_exists($userId, 'getKey') ? $userId->getKey() : $userId;
|
||||
|
||||
return $this->visit(rtrim(route('dusk.login', ['userId' => $userId, 'guard' => $guard], $this->shouldUseAbsoluteRouteForAuthentication())));
|
||||
}
|
||||
|
||||
/**
|
||||
* Log out of the application.
|
||||
*
|
||||
* @param string|null $guard
|
||||
* @return $this
|
||||
*/
|
||||
public function logout($guard = null)
|
||||
{
|
||||
return $this->visit(rtrim(route('dusk.logout', ['guard' => $guard], $this->shouldUseAbsoluteRouteForAuthentication()), '/'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ID and the class name of the authenticated user.
|
||||
*
|
||||
* @param string|null $guard
|
||||
* @return array
|
||||
*/
|
||||
protected function currentUserInfo($guard = null)
|
||||
{
|
||||
$response = $this->visit(route('dusk.user', ['guard' => $guard], $this->shouldUseAbsoluteRouteForAuthentication()));
|
||||
|
||||
return json_decode(strip_tags($response->driver->getPageSource()), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the user is authenticated.
|
||||
*
|
||||
* @param string|null $guard
|
||||
* @return $this
|
||||
*/
|
||||
public function assertAuthenticated($guard = null)
|
||||
{
|
||||
$currentUrl = $this->driver->getCurrentURL();
|
||||
|
||||
PHPUnit::assertNotEmpty($this->currentUserInfo($guard), 'The user is not authenticated.');
|
||||
|
||||
return $this->visit($currentUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the user is not authenticated.
|
||||
*
|
||||
* @param string|null $guard
|
||||
* @return $this
|
||||
*/
|
||||
public function assertGuest($guard = null)
|
||||
{
|
||||
$currentUrl = $this->driver->getCurrentURL();
|
||||
|
||||
PHPUnit::assertEmpty(
|
||||
$this->currentUserInfo($guard), 'The user is unexpectedly authenticated.'
|
||||
);
|
||||
|
||||
return $this->visit($currentUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the user is authenticated as the given user.
|
||||
*
|
||||
* @param mixed $user
|
||||
* @param string|null $guard
|
||||
* @return $this
|
||||
*/
|
||||
public function assertAuthenticatedAs($user, $guard = null)
|
||||
{
|
||||
$currentUrl = $this->driver->getCurrentURL();
|
||||
|
||||
$expected = [
|
||||
'id' => $user->getAuthIdentifier(),
|
||||
'className' => get_class($user),
|
||||
];
|
||||
|
||||
PHPUnit::assertSame(
|
||||
$expected, $this->currentUserInfo($guard),
|
||||
'The currently authenticated user is not who was expected.'
|
||||
);
|
||||
|
||||
return $this->visit($currentUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if route() should use an absolute path.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private function shouldUseAbsoluteRouteForAuthentication()
|
||||
{
|
||||
return config('dusk.domain') !== null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace Laravel\Dusk\Concerns;
|
||||
|
||||
use DateTimeInterface;
|
||||
use Facebook\WebDriver\Exception\NoSuchCookieException;
|
||||
use Illuminate\Cookie\CookieValuePrefix;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
|
||||
trait InteractsWithCookies
|
||||
{
|
||||
/**
|
||||
* Get or set an encrypted cookie's value.
|
||||
*
|
||||
* @param string $name
|
||||
* @param string|null $value
|
||||
* @param int|DateTimeInterface|null $expiry
|
||||
* @param array $options
|
||||
* @return $this|string|null
|
||||
*/
|
||||
public function cookie($name, $value = null, $expiry = null, array $options = [])
|
||||
{
|
||||
if (! is_null($value)) {
|
||||
return $this->addCookie($name, $value, $expiry, $options);
|
||||
}
|
||||
|
||||
try {
|
||||
$cookie = $this->driver->manage()->getCookieNamed($name);
|
||||
} catch (NoSuchCookieException $e) {
|
||||
$cookie = null;
|
||||
}
|
||||
|
||||
if ($cookie) {
|
||||
$decryptedValue = decrypt(rawurldecode($cookie['value']), $unserialize = false);
|
||||
|
||||
$hasValuePrefix = strpos($decryptedValue, CookieValuePrefix::create($name, Crypt::getKey())) === 0;
|
||||
|
||||
return $hasValuePrefix ? CookieValuePrefix::remove($decryptedValue) : $decryptedValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or set an unencrypted cookie's value.
|
||||
*
|
||||
* @param string $name
|
||||
* @param string|null $value
|
||||
* @param int|DateTimeInterface|null $expiry
|
||||
* @param array $options
|
||||
* @return $this|string|null
|
||||
*/
|
||||
public function plainCookie($name, $value = null, $expiry = null, array $options = [])
|
||||
{
|
||||
if (! is_null($value)) {
|
||||
return $this->addCookie($name, $value, $expiry, $options, false);
|
||||
}
|
||||
|
||||
try {
|
||||
$cookie = $this->driver->manage()->getCookieNamed($name);
|
||||
} catch (NoSuchCookieException $e) {
|
||||
$cookie = null;
|
||||
}
|
||||
|
||||
if ($cookie) {
|
||||
return rawurldecode($cookie['value']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the given cookie.
|
||||
*
|
||||
* @param string $name
|
||||
* @param string $value
|
||||
* @param int|DateTimeInterface|null $expiry
|
||||
* @param array $options
|
||||
* @param bool $encrypt
|
||||
* @return $this
|
||||
*/
|
||||
public function addCookie($name, $value, $expiry = null, array $options = [], $encrypt = true)
|
||||
{
|
||||
if ($encrypt) {
|
||||
$prefix = CookieValuePrefix::create($name, Crypt::getKey());
|
||||
|
||||
$value = encrypt($prefix.$value, $serialize = false);
|
||||
}
|
||||
|
||||
if ($expiry instanceof DateTimeInterface) {
|
||||
$expiry = $expiry->getTimestamp();
|
||||
}
|
||||
|
||||
$this->driver->manage()->addCookie(
|
||||
array_merge($options, compact('expiry', 'name', 'value'))
|
||||
);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the given cookie.
|
||||
*
|
||||
* @param string $name
|
||||
* @return $this
|
||||
*/
|
||||
public function deleteCookie($name)
|
||||
{
|
||||
$this->driver->manage()->deleteCookieNamed($name);
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
<?php
|
||||
|
||||
namespace Laravel\Dusk\Concerns;
|
||||
|
||||
use Facebook\WebDriver\Interactions\WebDriverActions;
|
||||
use Facebook\WebDriver\Remote\LocalFileDetector;
|
||||
use Facebook\WebDriver\WebDriverBy;
|
||||
use Facebook\WebDriver\WebDriverSelect;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
trait InteractsWithElements
|
||||
{
|
||||
use InteractsWithKeyboard;
|
||||
|
||||
/**
|
||||
* Get all of the elements matching the given selector.
|
||||
*
|
||||
* @param string $selector
|
||||
* @return \Facebook\WebDriver\Remote\RemoteWebElement[]
|
||||
*/
|
||||
public function elements($selector)
|
||||
{
|
||||
return $this->resolver->all($selector);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the element matching the given selector.
|
||||
*
|
||||
* @param string $selector
|
||||
* @return \Facebook\WebDriver\Remote\RemoteWebElement|null
|
||||
*/
|
||||
public function element($selector)
|
||||
{
|
||||
return $this->resolver->find($selector);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the link with the given text.
|
||||
*
|
||||
* @param string $link
|
||||
* @param string $element
|
||||
* @return $this
|
||||
*/
|
||||
public function clickLink($link, $element = 'a')
|
||||
{
|
||||
$this->ensurejQueryIsAvailable();
|
||||
|
||||
$selector = addslashes(trim($this->resolver->format("{$element}")));
|
||||
|
||||
$link = str_replace("'", "\\\\'", $link);
|
||||
|
||||
$this->driver->executeScript("jQuery.find(`{$selector}:contains('{$link}'):visible`)[0].click();");
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Directly get or set the value attribute of an input field.
|
||||
*
|
||||
* @param string $selector
|
||||
* @param string|null $value
|
||||
* @return $this
|
||||
*/
|
||||
public function value($selector, $value = null)
|
||||
{
|
||||
if (is_null($value)) {
|
||||
return $this->resolver->findOrFail($selector)->getAttribute('value');
|
||||
}
|
||||
|
||||
$selector = $this->resolver->format($selector);
|
||||
|
||||
$this->driver->executeScript(
|
||||
'document.querySelector('.json_encode($selector).').value = '.json_encode($value).';'
|
||||
);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the text of the element matching the given selector.
|
||||
*
|
||||
* @param string $selector
|
||||
* @return string
|
||||
*/
|
||||
public function text($selector)
|
||||
{
|
||||
return $this->resolver->findOrFail($selector)->getText();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the given attribute from the element matching the given selector.
|
||||
*
|
||||
* @param string $selector
|
||||
* @param string $attribute
|
||||
* @return string
|
||||
*/
|
||||
public function attribute($selector, $attribute)
|
||||
{
|
||||
return $this->resolver->findOrFail($selector)->getAttribute($attribute);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the given keys to the element matching the given selector.
|
||||
*
|
||||
* @param string $selector
|
||||
* @param mixed $keys
|
||||
* @return $this
|
||||
*/
|
||||
public function keys($selector, ...$keys)
|
||||
{
|
||||
$this->resolver->findOrFail($selector)->sendKeys($this->parseKeys($keys));
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type the given value in the given field.
|
||||
*
|
||||
* @param string $field
|
||||
* @param string $value
|
||||
* @return $this
|
||||
*/
|
||||
public function type($field, $value)
|
||||
{
|
||||
$this->resolver->resolveForTyping($field)->clear()->sendKeys($value);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type the given value in the given field slowly.
|
||||
*
|
||||
* @param string $field
|
||||
* @param string $value
|
||||
* @param int $pause
|
||||
* @return $this
|
||||
*/
|
||||
public function typeSlowly($field, $value, $pause = 100)
|
||||
{
|
||||
$this->clear($field)->appendSlowly($field, $value, $pause);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type the given value in the given field without clearing it.
|
||||
*
|
||||
* @param string $field
|
||||
* @param string $value
|
||||
* @return $this
|
||||
*/
|
||||
public function append($field, $value)
|
||||
{
|
||||
$this->resolver->resolveForTyping($field)->sendKeys($value);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type the given value in the given field slowly without clearing it.
|
||||
*
|
||||
* @param string $field
|
||||
* @param string $value
|
||||
* @param int $pause
|
||||
* @return $this
|
||||
*/
|
||||
public function appendSlowly($field, $value, $pause = 100)
|
||||
{
|
||||
$characters = preg_split('//u', $value, -1, PREG_SPLIT_NO_EMPTY);
|
||||
|
||||
if (is_array($characters)) {
|
||||
foreach ($characters as $character) {
|
||||
$this->append($field, $character)->pause($pause);
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the given field.
|
||||
*
|
||||
* @param string $field
|
||||
* @return $this
|
||||
*/
|
||||
public function clear($field)
|
||||
{
|
||||
$this->resolver->resolveForTyping($field)->clear();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the given value or random value of a drop-down field.
|
||||
*
|
||||
* @param string $field
|
||||
* @param string|array|null $value
|
||||
* @return $this
|
||||
*/
|
||||
public function select($field, $value = null)
|
||||
{
|
||||
$element = $this->resolver->resolveForSelection($field);
|
||||
|
||||
$options = $element->findElements(WebDriverBy::cssSelector('option:not([disabled])'));
|
||||
|
||||
$select = $element->getTagName() === 'select' ? new WebDriverSelect($element) : null;
|
||||
|
||||
$isMultiple = false;
|
||||
|
||||
if (! is_null($select)) {
|
||||
if ($isMultiple = $select->isMultiple()) {
|
||||
$select->deselectAll();
|
||||
}
|
||||
}
|
||||
|
||||
if (func_num_args() === 1) {
|
||||
$options[array_rand($options)]->click();
|
||||
} else {
|
||||
$value = collect(Arr::wrap($value))->transform(function ($value) {
|
||||
if (is_bool($value)) {
|
||||
return $value ? '1' : '0';
|
||||
}
|
||||
|
||||
return (string) $value;
|
||||
})->all();
|
||||
|
||||
foreach ($options as $option) {
|
||||
if (in_array((string) $option->getAttribute('value'), $value)) {
|
||||
$option->click();
|
||||
|
||||
if (! $isMultiple) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the given value of a radio button field.
|
||||
*
|
||||
* @param string $field
|
||||
* @param string $value
|
||||
* @return $this
|
||||
*/
|
||||
public function radio($field, $value)
|
||||
{
|
||||
$this->resolver->resolveForRadioSelection($field, $value)->click();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the given checkbox.
|
||||
*
|
||||
* @param string $field
|
||||
* @param string|null $value
|
||||
* @return $this
|
||||
*/
|
||||
public function check($field, $value = null)
|
||||
{
|
||||
$element = $this->resolver->resolveForChecking($field, $value);
|
||||
|
||||
if (! $element->isSelected()) {
|
||||
$element->click();
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uncheck the given checkbox.
|
||||
*
|
||||
* @param string $field
|
||||
* @param string|null $value
|
||||
* @return $this
|
||||
*/
|
||||
public function uncheck($field, $value = null)
|
||||
{
|
||||
$element = $this->resolver->resolveForChecking($field, $value);
|
||||
|
||||
if ($element->isSelected()) {
|
||||
$element->click();
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the given file to the field.
|
||||
*
|
||||
* @param string $field
|
||||
* @param string $path
|
||||
* @return $this
|
||||
*/
|
||||
public function attach($field, $path)
|
||||
{
|
||||
$element = $this->resolver->resolveForAttachment($field);
|
||||
|
||||
$element->setFileDetector(new LocalFileDetector)->sendKeys($path);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Press the button with the given text or name.
|
||||
*
|
||||
* @param string $button
|
||||
* @return $this
|
||||
*/
|
||||
public function press($button)
|
||||
{
|
||||
$this->resolver->resolveForButtonPress($button)->click();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Press the button with the given text or name.
|
||||
*
|
||||
* @param string $button
|
||||
* @param int $seconds
|
||||
* @return $this
|
||||
*/
|
||||
public function pressAndWaitFor($button, $seconds = 5)
|
||||
{
|
||||
$element = $this->resolver->resolveForButtonPress($button);
|
||||
|
||||
$element->click();
|
||||
|
||||
return $this->waitUsing($seconds, 100, function () use ($element) {
|
||||
return $element->isEnabled();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag an element to another element using selectors.
|
||||
*
|
||||
* @param string $from
|
||||
* @param string $to
|
||||
* @return $this
|
||||
*/
|
||||
public function drag($from, $to)
|
||||
{
|
||||
(new WebDriverActions($this->driver))->dragAndDrop(
|
||||
$this->resolver->findOrFail($from), $this->resolver->findOrFail($to)
|
||||
)->perform();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag an element up.
|
||||
*
|
||||
* @param string $selector
|
||||
* @param int $offset
|
||||
* @return $this
|
||||
*/
|
||||
public function dragUp($selector, $offset)
|
||||
{
|
||||
return $this->dragOffset($selector, 0, -$offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag an element down.
|
||||
*
|
||||
* @param string $selector
|
||||
* @param int $offset
|
||||
* @return $this
|
||||
*/
|
||||
public function dragDown($selector, $offset)
|
||||
{
|
||||
return $this->dragOffset($selector, 0, $offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag an element to the left.
|
||||
*
|
||||
* @param string $selector
|
||||
* @param int $offset
|
||||
* @return $this
|
||||
*/
|
||||
public function dragLeft($selector, $offset)
|
||||
{
|
||||
return $this->dragOffset($selector, -$offset, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag an element to the right.
|
||||
*
|
||||
* @param string $selector
|
||||
* @param int $offset
|
||||
* @return $this
|
||||
*/
|
||||
public function dragRight($selector, $offset)
|
||||
{
|
||||
return $this->dragOffset($selector, $offset, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag an element by the given offset.
|
||||
*
|
||||
* @param string $selector
|
||||
* @param int $x
|
||||
* @param int $y
|
||||
* @return $this
|
||||
*/
|
||||
public function dragOffset($selector, $x = 0, $y = 0)
|
||||
{
|
||||
(new WebDriverActions($this->driver))->dragAndDropBy(
|
||||
$this->resolver->findOrFail($selector), $x, $y
|
||||
)->perform();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept a JavaScript dialog.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function acceptDialog()
|
||||
{
|
||||
$this->driver->switchTo()->alert()->accept();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type the given value in an open JavaScript prompt dialog.
|
||||
*
|
||||
* @param string $value
|
||||
* @return $this
|
||||
*/
|
||||
public function typeInDialog($value)
|
||||
{
|
||||
$this->driver->switchTo()->alert()->sendKeys($value);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss a JavaScript dialog.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function dismissDialog()
|
||||
{
|
||||
$this->driver->switchTo()->alert()->dismiss();
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace Laravel\Dusk\Concerns;
|
||||
|
||||
trait InteractsWithJavascript
|
||||
{
|
||||
/**
|
||||
* Execute JavaScript within the browser.
|
||||
*
|
||||
* @param string|array $scripts
|
||||
* @return array
|
||||
*/
|
||||
public function script($scripts)
|
||||
{
|
||||
return collect((array) $scripts)->map(function ($script) {
|
||||
return $this->driver->executeScript($script);
|
||||
})->all();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace Laravel\Dusk\Concerns;
|
||||
|
||||
use Facebook\WebDriver\WebDriverKeys;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Dusk\Keyboard;
|
||||
|
||||
trait InteractsWithKeyboard
|
||||
{
|
||||
/**
|
||||
* Execute the given callback while interacting with the keyboard.
|
||||
*
|
||||
* @param callable(\Laravel\Dusk\Keyboard):void $callback
|
||||
* @return $this
|
||||
*/
|
||||
public function withKeyboard(callable $callback)
|
||||
{
|
||||
return tap($this, fn () => $callback(new Keyboard($this)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the keys before sending to the keyboard.
|
||||
*
|
||||
* @param array $keys
|
||||
* @return array
|
||||
*/
|
||||
protected function parseKeys($keys)
|
||||
{
|
||||
return collect($keys)->map(function ($key) {
|
||||
if (is_string($key) && Str::startsWith($key, '{') && Str::endsWith($key, '}')) {
|
||||
$key = constant(WebDriverKeys::class.'::'.strtoupper(trim($key, '{}')));
|
||||
}
|
||||
|
||||
if (is_array($key) && Str::startsWith($key[0], '{')) {
|
||||
$key[0] = constant(WebDriverKeys::class.'::'.strtoupper(trim($key[0], '{}')));
|
||||
}
|
||||
|
||||
return $key;
|
||||
})->all();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
|
||||
namespace Laravel\Dusk\Concerns;
|
||||
|
||||
use Facebook\WebDriver\Exception\ElementClickInterceptedException;
|
||||
use Facebook\WebDriver\Exception\NoSuchElementException;
|
||||
use Facebook\WebDriver\Interactions\WebDriverActions;
|
||||
use Facebook\WebDriver\WebDriverBy;
|
||||
use Facebook\WebDriver\WebDriverKeys;
|
||||
use Laravel\Dusk\Keyboard;
|
||||
use Laravel\Dusk\OperatingSystem;
|
||||
|
||||
trait InteractsWithMouse
|
||||
{
|
||||
/**
|
||||
* Move the mouse by offset X and Y.
|
||||
*
|
||||
* @param int $xOffset
|
||||
* @param int $yOffset
|
||||
* @return $this
|
||||
*/
|
||||
public function moveMouse($xOffset, $yOffset)
|
||||
{
|
||||
(new WebDriverActions($this->driver))->moveByOffset(
|
||||
$xOffset, $yOffset
|
||||
)->perform();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the mouse over the given selector.
|
||||
*
|
||||
* @param string $selector
|
||||
* @return $this
|
||||
*/
|
||||
public function mouseover($selector)
|
||||
{
|
||||
$element = $this->resolver->findOrFail($selector);
|
||||
|
||||
$this->driver->getMouse()->mouseMove($element->getCoordinates());
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the element at the given selector.
|
||||
*
|
||||
* @param string|null $selector
|
||||
* @return $this
|
||||
*/
|
||||
public function click($selector = null)
|
||||
{
|
||||
if (is_null($selector)) {
|
||||
(new WebDriverActions($this->driver))->click()->perform();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
foreach ($this->resolver->all($selector) as $element) {
|
||||
try {
|
||||
$element->click();
|
||||
|
||||
return $this;
|
||||
} catch (ElementClickInterceptedException $e) {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
throw $e ?? new NoSuchElementException("Unable to locate element with selector [{$selector}].");
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the topmost element at the given pair of coordinates.
|
||||
*
|
||||
* @param int $x
|
||||
* @param int $y
|
||||
* @return $this
|
||||
*/
|
||||
public function clickAtPoint($x, $y)
|
||||
{
|
||||
$this->driver->executeScript("document.elementFromPoint({$x}, {$y}).click()");
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the element at the given XPath expression.
|
||||
*
|
||||
* @param string $expression
|
||||
* @return $this
|
||||
*/
|
||||
public function clickAtXPath($expression)
|
||||
{
|
||||
$this->driver
|
||||
->findElement(WebDriverBy::xpath($expression))
|
||||
->click();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a mouse click and hold the mouse button down at the given selector.
|
||||
*
|
||||
* @param string|null $selector
|
||||
* @return $this
|
||||
*/
|
||||
public function clickAndHold($selector = null)
|
||||
{
|
||||
if (is_null($selector)) {
|
||||
(new WebDriverActions($this->driver))->clickAndHold()->perform();
|
||||
} else {
|
||||
(new WebDriverActions($this->driver))->clickAndHold(
|
||||
$this->resolver->findOrFail($selector)
|
||||
)->perform();
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Double click the element at the given selector.
|
||||
*
|
||||
* @param string|null $selector
|
||||
* @return $this
|
||||
*/
|
||||
public function doubleClick($selector = null)
|
||||
{
|
||||
if (is_null($selector)) {
|
||||
(new WebDriverActions($this->driver))->doubleClick()->perform();
|
||||
} else {
|
||||
(new WebDriverActions($this->driver))->doubleClick(
|
||||
$this->resolver->findOrFail($selector)
|
||||
)->perform();
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Right click the element at the given selector.
|
||||
*
|
||||
* @param string|null $selector
|
||||
* @return $this
|
||||
*/
|
||||
public function rightClick($selector = null)
|
||||
{
|
||||
if (is_null($selector)) {
|
||||
(new WebDriverActions($this->driver))->contextClick()->perform();
|
||||
} else {
|
||||
(new WebDriverActions($this->driver))->contextClick(
|
||||
$this->resolver->findOrFail($selector)
|
||||
)->perform();
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Control click the element at the given selector.
|
||||
*
|
||||
* @param string|null $selector
|
||||
* @return $this
|
||||
*/
|
||||
public function controlClick($selector = null)
|
||||
{
|
||||
return $this->withKeyboard(function (Keyboard $keyboard) use ($selector) {
|
||||
$key = OperatingSystem::onMac() ? WebDriverKeys::META : WebDriverKeys::CONTROL;
|
||||
|
||||
$keyboard->press($key);
|
||||
$this->click($selector);
|
||||
$keyboard->release($key);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the currently clicked mouse button.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function releaseMouse()
|
||||
{
|
||||
(new WebDriverActions($this->driver))->release()->perform();
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
+1222
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,390 @@
|
||||
<?php
|
||||
|
||||
namespace Laravel\Dusk\Concerns;
|
||||
|
||||
use Illuminate\Support\Arr;
|
||||
use PHPUnit\Framework\Assert as PHPUnit;
|
||||
use PHPUnit\Framework\Constraint\RegularExpression;
|
||||
|
||||
trait MakesUrlAssertions
|
||||
{
|
||||
/**
|
||||
* Assert that the current URL (without the query string) matches the given string.
|
||||
*
|
||||
* @param string $url
|
||||
* @return $this
|
||||
*/
|
||||
public function assertUrlIs($url)
|
||||
{
|
||||
$pattern = str_replace('\*', '.*', preg_quote($url, '/'));
|
||||
|
||||
$segments = parse_url($this->driver->getCurrentURL());
|
||||
|
||||
$currentUrl = sprintf(
|
||||
'%s://%s%s%s',
|
||||
$segments['scheme'],
|
||||
$segments['host'],
|
||||
Arr::get($segments, 'port', '') ? ':'.$segments['port'] : '',
|
||||
Arr::get($segments, 'path', '')
|
||||
);
|
||||
|
||||
PHPUnit::assertThat(
|
||||
$currentUrl, new RegularExpression('/^'.$pattern.'$/u'),
|
||||
"Actual URL [{$this->driver->getCurrentURL()}] does not equal expected URL [{$url}]."
|
||||
);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the current URL scheme matches the given scheme.
|
||||
*
|
||||
* @param string $scheme
|
||||
* @return $this
|
||||
*/
|
||||
public function assertSchemeIs($scheme)
|
||||
{
|
||||
$pattern = str_replace('\*', '.*', preg_quote($scheme, '/'));
|
||||
|
||||
$actual = parse_url($this->driver->getCurrentURL(), PHP_URL_SCHEME) ?? '';
|
||||
|
||||
PHPUnit::assertThat(
|
||||
$actual, new RegularExpression('/^'.$pattern.'$/u'),
|
||||
"Actual scheme [{$actual}] does not equal expected scheme [{$pattern}]."
|
||||
);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the current URL scheme does not match the given scheme.
|
||||
*
|
||||
* @param string $scheme
|
||||
* @return $this
|
||||
*/
|
||||
public function assertSchemeIsNot($scheme)
|
||||
{
|
||||
$actual = parse_url($this->driver->getCurrentURL(), PHP_URL_SCHEME) ?? '';
|
||||
|
||||
PHPUnit::assertNotEquals(
|
||||
$scheme, $actual,
|
||||
"Scheme [{$scheme}] should not equal the actual value."
|
||||
);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the current URL host matches the given host.
|
||||
*
|
||||
* @param string $host
|
||||
* @return $this
|
||||
*/
|
||||
public function assertHostIs($host)
|
||||
{
|
||||
$pattern = str_replace('\*', '.*', preg_quote($host, '/'));
|
||||
|
||||
$actual = parse_url($this->driver->getCurrentURL(), PHP_URL_HOST) ?? '';
|
||||
|
||||
PHPUnit::assertThat(
|
||||
$actual, new RegularExpression('/^'.$pattern.'$/u'),
|
||||
"Actual host [{$actual}] does not equal expected host [{$pattern}]."
|
||||
);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the current URL host does not match the given host.
|
||||
*
|
||||
* @param string $host
|
||||
* @return $this
|
||||
*/
|
||||
public function assertHostIsNot($host)
|
||||
{
|
||||
$actual = parse_url($this->driver->getCurrentURL(), PHP_URL_HOST) ?? '';
|
||||
|
||||
PHPUnit::assertNotEquals(
|
||||
$host, $actual,
|
||||
"Host [{$host}] should not equal the actual value."
|
||||
);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the current URL port matches the given port.
|
||||
*
|
||||
* @param string $port
|
||||
* @return $this
|
||||
*/
|
||||
public function assertPortIs($port)
|
||||
{
|
||||
$pattern = str_replace('\*', '.*', preg_quote($port, '/'));
|
||||
|
||||
$actual = (string) parse_url($this->driver->getCurrentURL(), PHP_URL_PORT) ?? '';
|
||||
|
||||
PHPUnit::assertThat(
|
||||
$actual, new RegularExpression('/^'.$pattern.'$/u'),
|
||||
"Actual port [{$actual}] does not equal expected port [{$pattern}]."
|
||||
);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the current URL port does not match the given port.
|
||||
*
|
||||
* @param string $port
|
||||
* @return $this
|
||||
*/
|
||||
public function assertPortIsNot($port)
|
||||
{
|
||||
$actual = parse_url($this->driver->getCurrentURL(), PHP_URL_PORT) ?? '';
|
||||
|
||||
PHPUnit::assertNotEquals(
|
||||
$port, $actual,
|
||||
"Port [{$port}] should not equal the actual value."
|
||||
);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the current URL path begins with the given path.
|
||||
*
|
||||
* @param string $path
|
||||
* @return $this
|
||||
*/
|
||||
public function assertPathBeginsWith($path)
|
||||
{
|
||||
$actualPath = parse_url($this->driver->getCurrentURL(), PHP_URL_PATH) ?? '';
|
||||
|
||||
PHPUnit::assertStringStartsWith(
|
||||
$path, $actualPath,
|
||||
"Actual path [{$actualPath}] does not begin with expected path [{$path}]."
|
||||
);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the current URL path ends with the given path.
|
||||
*
|
||||
* @param string $path
|
||||
* @return $this
|
||||
*/
|
||||
public function assertPathEndsWith($path)
|
||||
{
|
||||
$actualPath = parse_url($this->driver->getCurrentURL(), PHP_URL_PATH) ?? '';
|
||||
|
||||
PHPUnit::assertStringEndsWith(
|
||||
$path, $actualPath,
|
||||
"Actual path [{$actualPath}] does not end with expected path [{$path}]."
|
||||
);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the current URL path contains the given path.
|
||||
*
|
||||
* @param string $path
|
||||
* @return $this
|
||||
*/
|
||||
public function assertPathContains($path)
|
||||
{
|
||||
$actualPath = parse_url($this->driver->getCurrentURL(), PHP_URL_PATH) ?? '';
|
||||
|
||||
PHPUnit::assertStringContainsString(
|
||||
$path, $actualPath,
|
||||
"Actual path [{$actualPath}] does not contain the expected string [{$path}]."
|
||||
);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the current path matches the given path.
|
||||
*
|
||||
* @param string $path
|
||||
* @return $this
|
||||
*/
|
||||
public function assertPathIs($path)
|
||||
{
|
||||
$pattern = str_replace('\*', '.*', preg_quote($path, '/'));
|
||||
|
||||
$actualPath = parse_url($this->driver->getCurrentURL(), PHP_URL_PATH) ?? '';
|
||||
|
||||
PHPUnit::assertThat(
|
||||
$actualPath, new RegularExpression('/^'.$pattern.'$/u'),
|
||||
"Actual path [{$actualPath}] does not equal expected path [{$path}]."
|
||||
);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the current path does not match the given path.
|
||||
*
|
||||
* @param string $path
|
||||
* @return $this
|
||||
*/
|
||||
public function assertPathIsNot($path)
|
||||
{
|
||||
$actualPath = parse_url($this->driver->getCurrentURL(), PHP_URL_PATH) ?? '';
|
||||
|
||||
PHPUnit::assertNotEquals(
|
||||
$path, $actualPath,
|
||||
"Path [{$path}] should not equal the actual value."
|
||||
);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the current URL matches the given named route's URL.
|
||||
*
|
||||
* @param string $route
|
||||
* @param array $parameters
|
||||
* @return $this
|
||||
*/
|
||||
public function assertRouteIs($route, $parameters = [])
|
||||
{
|
||||
return $this->assertPathIs(route($route, $parameters, false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the given query string parameter is present and has a given value.
|
||||
*
|
||||
* @param string $name
|
||||
* @param string|null $value
|
||||
* @return $this
|
||||
*/
|
||||
public function assertQueryStringHas($name, $value = null)
|
||||
{
|
||||
$output = $this->assertHasQueryStringParameter($name);
|
||||
|
||||
if (is_null($value)) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
$parsedOutputName = is_array($output[$name]) ? implode(',', $output[$name]) : $output[$name];
|
||||
|
||||
$parsedValue = is_array($value) ? implode(',', $value) : $value;
|
||||
|
||||
PHPUnit::assertEquals(
|
||||
$value, $output[$name],
|
||||
"Query string parameter [{$name}] had value [{$parsedOutputName}], but expected [{$parsedValue}]."
|
||||
);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the given query string parameter is missing.
|
||||
*
|
||||
* @param string $name
|
||||
* @return $this
|
||||
*/
|
||||
public function assertQueryStringMissing($name)
|
||||
{
|
||||
$parsedUrl = parse_url($this->driver->getCurrentURL());
|
||||
|
||||
if (! array_key_exists('query', $parsedUrl)) {
|
||||
PHPUnit::assertTrue(true);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
parse_str($parsedUrl['query'], $output);
|
||||
|
||||
PHPUnit::assertArrayNotHasKey(
|
||||
$name, $output,
|
||||
"Found unexpected query string parameter [{$name}] in [".$this->driver->getCurrentURL().'].'
|
||||
);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the URL's current hash fragment matches the given fragment.
|
||||
*
|
||||
* @param string $fragment
|
||||
* @return $this
|
||||
*/
|
||||
public function assertFragmentIs($fragment)
|
||||
{
|
||||
$pattern = preg_quote($fragment, '/');
|
||||
|
||||
$actualFragment = (string) parse_url($this->driver->executeScript('return window.location.href;'), PHP_URL_FRAGMENT);
|
||||
|
||||
PHPUnit::assertThat(
|
||||
$actualFragment, new RegularExpression('/^'.str_replace('\*', '.*', $pattern).'$/u'),
|
||||
"Actual fragment [{$actualFragment}] does not equal expected fragment [{$fragment}]."
|
||||
);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the URL's current hash fragment begins with the given fragment.
|
||||
*
|
||||
* @param string $fragment
|
||||
* @return $this
|
||||
*/
|
||||
public function assertFragmentBeginsWith($fragment)
|
||||
{
|
||||
$actualFragment = (string) parse_url($this->driver->executeScript('return window.location.href;'), PHP_URL_FRAGMENT);
|
||||
|
||||
PHPUnit::assertStringStartsWith(
|
||||
$fragment, $actualFragment,
|
||||
"Actual fragment [$actualFragment] does not begin with expected fragment [$fragment]."
|
||||
);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the URL's current hash fragment does not match the given fragment.
|
||||
*
|
||||
* @param string $fragment
|
||||
* @return $this
|
||||
*/
|
||||
public function assertFragmentIsNot($fragment)
|
||||
{
|
||||
$actualFragment = (string) parse_url($this->driver->executeScript('return window.location.href;'), PHP_URL_FRAGMENT);
|
||||
|
||||
PHPUnit::assertNotEquals(
|
||||
$fragment, $actualFragment,
|
||||
"Fragment [{$fragment}] should not equal the actual value."
|
||||
);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the given query string parameter is present.
|
||||
*
|
||||
* @param string $name
|
||||
* @return array
|
||||
*/
|
||||
protected function assertHasQueryStringParameter($name)
|
||||
{
|
||||
$parsedUrl = parse_url($this->driver->getCurrentURL());
|
||||
|
||||
PHPUnit::assertArrayHasKey(
|
||||
'query', $parsedUrl,
|
||||
'Did not see expected query string in ['.$this->driver->getCurrentURL().'].'
|
||||
);
|
||||
|
||||
parse_str($parsedUrl['query'], $output);
|
||||
|
||||
PHPUnit::assertArrayHasKey(
|
||||
$name, $output,
|
||||
"Did not see expected query string parameter [{$name}] in [".$this->driver->getCurrentURL().'].'
|
||||
);
|
||||
|
||||
return $output;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
<?php
|
||||
|
||||
namespace Laravel\Dusk\Concerns;
|
||||
|
||||
use Closure;
|
||||
use Exception;
|
||||
use Illuminate\Support\Collection;
|
||||
use Laravel\Dusk\Browser;
|
||||
use PHPUnit\Framework\Attributes\AfterClass;
|
||||
use PHPUnit\Runner\Version;
|
||||
use ReflectionFunction;
|
||||
use Throwable;
|
||||
|
||||
trait ProvidesBrowser
|
||||
{
|
||||
/**
|
||||
* All of the active browser instances.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected static $browsers = [];
|
||||
|
||||
/**
|
||||
* The callbacks that should be run on class tear down.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected static $afterClassCallbacks = [];
|
||||
|
||||
/**
|
||||
* Tear down the Dusk test case class.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
#[AfterClass]
|
||||
public static function tearDownDuskClass()
|
||||
{
|
||||
static::closeAll();
|
||||
|
||||
foreach (static::$afterClassCallbacks as $callback) {
|
||||
$callback();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an "after class" tear down callback.
|
||||
*
|
||||
* @param \Closure $callback
|
||||
* @return void
|
||||
*/
|
||||
public static function afterClass(Closure $callback)
|
||||
{
|
||||
static::$afterClassCallbacks[] = $callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new browser instance.
|
||||
*
|
||||
* @param \Closure $callback
|
||||
* @return \Laravel\Dusk\Browser|void
|
||||
*
|
||||
* @throws \Exception
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function browse(Closure $callback)
|
||||
{
|
||||
$browsers = $this->createBrowsersFor($callback);
|
||||
|
||||
try {
|
||||
$callback(...$browsers->all());
|
||||
} catch (Exception $e) {
|
||||
$this->captureFailuresFor($browsers);
|
||||
$this->storeSourceLogsFor($browsers);
|
||||
|
||||
throw $e;
|
||||
} catch (Throwable $e) {
|
||||
$this->captureFailuresFor($browsers);
|
||||
$this->storeSourceLogsFor($browsers);
|
||||
|
||||
throw $e;
|
||||
} finally {
|
||||
$this->storeConsoleLogsFor($browsers);
|
||||
|
||||
static::$browsers = $this->closeAllButPrimary($browsers);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the browser instances needed for the given callback.
|
||||
*
|
||||
* @param \Closure $callback
|
||||
* @return array
|
||||
*
|
||||
* @throws \ReflectionException
|
||||
*/
|
||||
protected function createBrowsersFor(Closure $callback)
|
||||
{
|
||||
if (count(static::$browsers) === 0) {
|
||||
static::$browsers = collect([$this->newBrowser($this->createWebDriver())]);
|
||||
}
|
||||
|
||||
$additional = $this->browsersNeededFor($callback) - 1;
|
||||
|
||||
for ($i = 0; $i < $additional; $i++) {
|
||||
static::$browsers->push($this->newBrowser($this->createWebDriver()));
|
||||
}
|
||||
|
||||
return static::$browsers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new Browser instance.
|
||||
*
|
||||
* @param \Facebook\WebDriver\Remote\RemoteWebDriver $driver
|
||||
* @return \Laravel\Dusk\Browser
|
||||
*/
|
||||
protected function newBrowser($driver)
|
||||
{
|
||||
return new Browser($driver);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of browsers needed for a given callback.
|
||||
*
|
||||
* @param \Closure $callback
|
||||
* @return int
|
||||
*
|
||||
* @throws \ReflectionException
|
||||
*/
|
||||
protected function browsersNeededFor(Closure $callback)
|
||||
{
|
||||
return (new ReflectionFunction($callback))->getNumberOfParameters();
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture failure screenshots for each browser.
|
||||
*
|
||||
* @param \Illuminate\Support\Collection $browsers
|
||||
* @return void
|
||||
*/
|
||||
protected function captureFailuresFor($browsers)
|
||||
{
|
||||
$browsers->each(function ($browser, $key) {
|
||||
if (property_exists($browser, 'fitOnFailure') && $browser->fitOnFailure) {
|
||||
$browser->fitContent();
|
||||
}
|
||||
|
||||
$name = $this->getCallerName();
|
||||
|
||||
$browser->screenshot('failure-'.$name.'-'.$key);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the console output for the given browsers.
|
||||
*
|
||||
* @param \Illuminate\Support\Collection $browsers
|
||||
* @return void
|
||||
*/
|
||||
protected function storeConsoleLogsFor($browsers)
|
||||
{
|
||||
$browsers->each(function ($browser, $key) {
|
||||
$name = $this->getCallerName();
|
||||
|
||||
$browser->storeConsoleLog($name.'-'.$key);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the source code for the given browsers (if necessary).
|
||||
*
|
||||
* @param \Illuminate\Support\Collection $browsers
|
||||
* @return void
|
||||
*/
|
||||
protected function storeSourceLogsFor($browsers)
|
||||
{
|
||||
$browsers->each(function ($browser, $key) {
|
||||
if (property_exists($browser, 'madeSourceAssertion') &&
|
||||
$browser->madeSourceAssertion) {
|
||||
$browser->storeSource($this->getCallerName().'-'.$key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Close all of the browsers except the primary (first) one.
|
||||
*
|
||||
* @param \Illuminate\Support\Collection $browsers
|
||||
* @return \Illuminate\Support\Collection
|
||||
*/
|
||||
protected function closeAllButPrimary($browsers)
|
||||
{
|
||||
$browsers->slice(1)->each->quit();
|
||||
|
||||
return $browsers->take(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close all of the active browsers.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function closeAll()
|
||||
{
|
||||
Collection::make(static::$browsers)->each->quit();
|
||||
|
||||
static::$browsers = collect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the remote web driver instance.
|
||||
*
|
||||
* @return \Facebook\WebDriver\Remote\RemoteWebDriver
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
protected function createWebDriver()
|
||||
{
|
||||
return retry(5, function () {
|
||||
return $this->driver();
|
||||
}, 50);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the browser caller name.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function getCallerName()
|
||||
{
|
||||
$name = version_compare(Version::id(), '10', '>=')
|
||||
? $this->name()
|
||||
: $this->getName(false); // @phpstan-ignore-line
|
||||
|
||||
$parts = array_filter([
|
||||
str_replace('\\', '_', get_class($this)),
|
||||
$name,
|
||||
str_replace(['\\', DIRECTORY_SEPARATOR, ' '], ['', '', '_'], $this->dataName()),
|
||||
], fn ($part) => $part !== '');
|
||||
|
||||
return substr(implode('_', $parts), -140);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the RemoteWebDriver instance.
|
||||
*
|
||||
* @return \Facebook\WebDriver\Remote\RemoteWebDriver
|
||||
*/
|
||||
abstract protected function driver();
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
<?php
|
||||
|
||||
namespace Laravel\Dusk\Concerns;
|
||||
|
||||
use Closure;
|
||||
use Exception;
|
||||
use Facebook\WebDriver\Exception\NoSuchElementException;
|
||||
use Facebook\WebDriver\Exception\ScriptTimeoutException;
|
||||
use Facebook\WebDriver\Exception\TimeoutException;
|
||||
use Facebook\WebDriver\WebDriverExpectedCondition;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
trait WaitsForElements
|
||||
{
|
||||
/**
|
||||
* Execute the given callback in a scoped browser once the selector is available.
|
||||
*
|
||||
* @param string $selector
|
||||
* @param \Closure $callback
|
||||
* @param int|null $seconds
|
||||
* @return $this
|
||||
*
|
||||
* @throws \Facebook\WebDriver\Exception\TimeoutException
|
||||
*/
|
||||
public function whenAvailable($selector, Closure $callback, $seconds = null)
|
||||
{
|
||||
return $this->waitFor($selector, $seconds)->with($selector, $callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the given selector to become visible.
|
||||
*
|
||||
* @param string $selector
|
||||
* @param int|null $seconds
|
||||
* @return $this
|
||||
*
|
||||
* @throws \Facebook\WebDriver\Exception\TimeoutException
|
||||
*/
|
||||
public function waitFor($selector, $seconds = null)
|
||||
{
|
||||
$message = $this->formatTimeOutMessage('Waited %s seconds for selector', $selector);
|
||||
|
||||
return $this->waitUsing($seconds, 100, function () use ($selector) {
|
||||
return $this->resolver->findOrFail($selector)->isDisplayed();
|
||||
}, $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the given selector to be removed.
|
||||
*
|
||||
* @param string $selector
|
||||
* @param int|null $seconds
|
||||
* @return $this
|
||||
*
|
||||
* @throws \Facebook\WebDriver\Exception\TimeoutException
|
||||
*/
|
||||
public function waitUntilMissing($selector, $seconds = null)
|
||||
{
|
||||
$message = $this->formatTimeOutMessage('Waited %s seconds for removal of selector', $selector);
|
||||
|
||||
return $this->waitUsing($seconds, 100, function () use ($selector) {
|
||||
try {
|
||||
$missing = ! $this->resolver->findOrFail($selector)->isDisplayed();
|
||||
} catch (NoSuchElementException $e) {
|
||||
$missing = true;
|
||||
}
|
||||
|
||||
return $missing;
|
||||
}, $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the given text to be removed.
|
||||
*
|
||||
* @param string $text
|
||||
* @param int|null $seconds
|
||||
* @return $this
|
||||
*
|
||||
* @throws \Facebook\WebDriver\Exception\TimeoutException
|
||||
*/
|
||||
public function waitUntilMissingText($text, $seconds = null)
|
||||
{
|
||||
$text = Arr::wrap($text);
|
||||
|
||||
$message = $this->formatTimeOutMessage('Waited %s seconds for removal of text', implode("', '", $text));
|
||||
|
||||
return $this->waitUsing($seconds, 100, function () use ($text) {
|
||||
return ! Str::contains($this->resolver->findOrFail('')->getText(), $text);
|
||||
}, $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the given text to become visible.
|
||||
*
|
||||
* @param array|string $text
|
||||
* @param int|null $seconds
|
||||
* @param bool $ignoreCase
|
||||
* @return $this
|
||||
*
|
||||
* @throws \Facebook\WebDriver\Exception\TimeoutException
|
||||
*/
|
||||
public function waitForText($text, $seconds = null, $ignoreCase = false)
|
||||
{
|
||||
$text = Arr::wrap($text);
|
||||
|
||||
$message = $this->formatTimeOutMessage('Waited %s seconds for text', implode("', '", $text));
|
||||
|
||||
return $this->waitUsing($seconds, 100, function () use ($text, $ignoreCase) {
|
||||
return Str::contains($this->resolver->findOrFail('')->getText(), $text, $ignoreCase);
|
||||
}, $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the given text to become visible inside the given selector.
|
||||
*
|
||||
* @param string $selector
|
||||
* @param array|string $text
|
||||
* @param int|null $seconds
|
||||
* @param bool $ignoreCase
|
||||
* @return $this
|
||||
*
|
||||
* @throws \Facebook\WebDriver\Exception\TimeoutException
|
||||
*/
|
||||
public function waitForTextIn($selector, $text, $seconds = null, $ignoreCase = false)
|
||||
{
|
||||
$message = 'Waited %s seconds for text "'.$this->escapePercentCharacters($text).'" in selector '.$selector;
|
||||
|
||||
return $this->waitUsing($seconds, 100, function () use ($selector, $text, $ignoreCase) {
|
||||
return $this->assertSeeIn($selector, $text, $ignoreCase);
|
||||
}, $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the given link to become visible.
|
||||
*
|
||||
* @param string $link
|
||||
* @param int|null $seconds
|
||||
* @return $this
|
||||
*
|
||||
* @throws \Facebook\WebDriver\Exception\TimeoutException
|
||||
*/
|
||||
public function waitForLink($link, $seconds = null)
|
||||
{
|
||||
$message = $this->formatTimeOutMessage('Waited %s seconds for link', $link);
|
||||
|
||||
return $this->waitUsing($seconds, 100, function () use ($link) {
|
||||
return $this->seeLink($link);
|
||||
}, $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for an input field to become visible.
|
||||
*
|
||||
* @param string $field
|
||||
* @param int|null $seconds
|
||||
* @return $this
|
||||
*/
|
||||
public function waitForInput($field, $seconds = null)
|
||||
{
|
||||
return $this->waitFor("input[name='{$field}'], textarea[name='{$field}'], select[name='{$field}']", $seconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the given location.
|
||||
*
|
||||
* @param string $path
|
||||
* @param int|null $seconds
|
||||
* @return $this
|
||||
*
|
||||
* @throws \Facebook\WebDriver\Exception\TimeoutException
|
||||
*/
|
||||
public function waitForLocation($path, $seconds = null)
|
||||
{
|
||||
$message = $this->formatTimeOutMessage('Waited %s seconds for location', $path);
|
||||
|
||||
return Str::startsWith($path, ['http://', 'https://'])
|
||||
? $this->waitUntil('`${location.protocol}//${location.host}${location.pathname}` == \''.$path.'\'', $seconds, $message)
|
||||
: $this->waitUntil("window.location.pathname == '{$path}'", $seconds, $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the given location using a named route.
|
||||
*
|
||||
* @param string $route
|
||||
* @param array $parameters
|
||||
* @param int|null $seconds
|
||||
* @return $this
|
||||
*
|
||||
* @throws \Facebook\WebDriver\Exception\TimeoutException
|
||||
*/
|
||||
public function waitForRoute($route, $parameters = [], $seconds = null)
|
||||
{
|
||||
return $this->waitForLocation(route($route, $parameters, false), $seconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until an element is enabled.
|
||||
*
|
||||
* @param string $selector
|
||||
* @param int|null $seconds
|
||||
* @return $this
|
||||
*/
|
||||
public function waitUntilEnabled($selector, $seconds = null)
|
||||
{
|
||||
$message = $this->formatTimeOutMessage('Waited %s seconds for element to be enabled', $selector);
|
||||
|
||||
$this->waitUsing($seconds, 100, function () use ($selector) {
|
||||
return $this->resolver->findOrFail($selector)->isEnabled();
|
||||
}, $message);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until an element is disabled.
|
||||
*
|
||||
* @param string $selector
|
||||
* @param int|null $seconds
|
||||
* @return $this
|
||||
*/
|
||||
public function waitUntilDisabled($selector, $seconds = null)
|
||||
{
|
||||
$message = $this->formatTimeOutMessage('Waited %s seconds for element to be disabled', $selector);
|
||||
|
||||
$this->waitUsing($seconds, 100, function () use ($selector) {
|
||||
return ! $this->resolver->findOrFail($selector)->isEnabled();
|
||||
}, $message);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until the given script returns true.
|
||||
*
|
||||
* @param string $script
|
||||
* @param int|null $seconds
|
||||
* @param string|null $message
|
||||
* @return $this
|
||||
*
|
||||
* @throws \Facebook\WebDriver\Exception\TimeoutException
|
||||
*/
|
||||
public function waitUntil($script, $seconds = null, $message = null)
|
||||
{
|
||||
if (! Str::startsWith($script, 'return ')) {
|
||||
$script = 'return '.$script;
|
||||
}
|
||||
|
||||
if (! Str::endsWith($script, ';')) {
|
||||
$script = $script.';';
|
||||
}
|
||||
|
||||
return $this->waitUsing($seconds, 100, function () use ($script) {
|
||||
return $this->driver->executeScript($script);
|
||||
}, $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until the Vue component's attribute at the given key has the given value.
|
||||
*
|
||||
* @param string $key
|
||||
* @param string $value
|
||||
* @param string|null $componentSelector
|
||||
* @param int|null $seconds
|
||||
* @return $this
|
||||
*/
|
||||
public function waitUntilVue($key, $value, $componentSelector = null, $seconds = null)
|
||||
{
|
||||
$this->waitUsing($seconds, 100, function () use ($key, $value, $componentSelector) {
|
||||
return $value == $this->vueAttribute($componentSelector, $key);
|
||||
});
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until the Vue component's attribute at the given key does not have the given value.
|
||||
*
|
||||
* @param string $key
|
||||
* @param string $value
|
||||
* @param string|null $componentSelector
|
||||
* @param int|null $seconds
|
||||
* @return $this
|
||||
*/
|
||||
public function waitUntilVueIsNot($key, $value, $componentSelector = null, $seconds = null)
|
||||
{
|
||||
$this->waitUsing($seconds, 100, function () use ($key, $value, $componentSelector) {
|
||||
return $value != $this->vueAttribute($componentSelector, $key);
|
||||
});
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a JavaScript dialog to open.
|
||||
*
|
||||
* @param int|null $seconds
|
||||
* @return $this
|
||||
*/
|
||||
public function waitForDialog($seconds = null)
|
||||
{
|
||||
$seconds = is_null($seconds) ? static::$waitSeconds : $seconds;
|
||||
|
||||
$this->driver->wait($seconds, 100)->until(
|
||||
WebDriverExpectedCondition::alertIsPresent(), "Waited {$seconds} seconds for dialog."
|
||||
);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the current page to reload.
|
||||
*
|
||||
* @param \Closure|null $callback
|
||||
* @param int|null $seconds
|
||||
* @return $this
|
||||
*
|
||||
* @throws \Facebook\WebDriver\Exception\TimeoutException
|
||||
*/
|
||||
public function waitForReload($callback = null, $seconds = null)
|
||||
{
|
||||
$token = Str::random();
|
||||
|
||||
$this->driver->executeScript("window['{$token}'] = {};");
|
||||
|
||||
if ($callback) {
|
||||
$callback($this);
|
||||
}
|
||||
|
||||
return $this->waitUsing($seconds, 100, function () use ($token) {
|
||||
return $this->driver->executeScript("return typeof window['{$token}'] === 'undefined';");
|
||||
}, 'Waited %s seconds for page reload.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Click an element and wait for the page to reload.
|
||||
*
|
||||
* @param string|null $selector
|
||||
* @param int|null $seconds
|
||||
* @return $this
|
||||
*/
|
||||
public function clickAndWaitForReload($selector = null, $seconds = null)
|
||||
{
|
||||
return $this->waitForReload(function ($browser) use ($selector) {
|
||||
$browser->click($selector);
|
||||
}, $seconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the given event type to occur on a target.
|
||||
*
|
||||
* @param string $type
|
||||
* @param string|null $target
|
||||
* @param int|null $seconds
|
||||
* @return $this
|
||||
*
|
||||
* @throws \Facebook\WebDriver\Exception\TimeoutException
|
||||
*/
|
||||
public function waitForEvent($type, $target = null, $seconds = null)
|
||||
{
|
||||
$seconds = is_null($seconds) ? static::$waitSeconds : $seconds;
|
||||
|
||||
if ($target !== 'document' && $target !== 'window') {
|
||||
$target = $this->resolver->findOrFail($target ?? '');
|
||||
}
|
||||
|
||||
$this->driver->manage()->timeouts()->setScriptTimeout($seconds);
|
||||
|
||||
try {
|
||||
$this->driver->executeAsyncScript(
|
||||
'eval(arguments[0]).addEventListener(arguments[1], () => arguments[2](), { once: true });',
|
||||
[$target, $type]
|
||||
);
|
||||
} catch (ScriptTimeoutException $e) {
|
||||
throw new TimeoutException("Waited {$seconds} seconds for event [{$type}].");
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the given callback to be true.
|
||||
*
|
||||
* @param int|null $seconds
|
||||
* @param int $interval
|
||||
* @param \Closure $callback
|
||||
* @param string|null $message
|
||||
* @return $this
|
||||
*
|
||||
* @throws \Facebook\WebDriver\Exception\TimeoutException
|
||||
*/
|
||||
public function waitUsing($seconds, $interval, Closure $callback, $message = null)
|
||||
{
|
||||
$seconds = is_null($seconds) ? static::$waitSeconds : $seconds;
|
||||
|
||||
$this->pause($interval);
|
||||
|
||||
$this->driver->wait($seconds, $interval)->until(
|
||||
function ($driver) use ($callback) {
|
||||
try {
|
||||
return $callback();
|
||||
} catch (Exception $e) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
$message ? sprintf($message, $seconds) : "Waited {$seconds} seconds for callback."
|
||||
);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare custom TimeoutException message for sprintf().
|
||||
*
|
||||
* @param string $message
|
||||
* @param string $expected
|
||||
* @return string
|
||||
*/
|
||||
protected function formatTimeOutMessage($message, $expected)
|
||||
{
|
||||
return $message.' ['.$this->escapePercentCharacters($expected).'].';
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape percent characters in preparation for sending the given message to "sprintf".
|
||||
*
|
||||
* @param string $message
|
||||
* @return string
|
||||
*/
|
||||
protected function escapePercentCharacters($message)
|
||||
{
|
||||
return str_replace('%', '%%', $message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
<?php
|
||||
|
||||
namespace Laravel\Dusk\Console;
|
||||
|
||||
use Exception;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Psr7\Utils;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Dusk\OperatingSystem;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Process\Process;
|
||||
use ZipArchive;
|
||||
|
||||
/**
|
||||
* @copyright Originally created by Jonas Staudenmeir: https://github.com/staudenmeir/dusk-updater
|
||||
*/
|
||||
#[AsCommand(name: 'dusk:chrome-driver')]
|
||||
class ChromeDriverCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'dusk:chrome-driver {version?}
|
||||
{--all : Install a ChromeDriver binary for every OS}
|
||||
{--detect : Detect the installed Chrome / Chromium version}
|
||||
{--proxy= : The proxy to download the binary through (example: "tcp://127.0.0.1:9000")}
|
||||
{--ssl-no-verify : Bypass SSL certificate verification when installing through a proxy}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Install the ChromeDriver binary';
|
||||
|
||||
/**
|
||||
* The legacy versions for ChromeDriver.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $legacyVersions = [
|
||||
43 => '2.20',
|
||||
44 => '2.20',
|
||||
45 => '2.20',
|
||||
46 => '2.21',
|
||||
47 => '2.21',
|
||||
48 => '2.21',
|
||||
49 => '2.22',
|
||||
50 => '2.22',
|
||||
51 => '2.23',
|
||||
52 => '2.24',
|
||||
53 => '2.26',
|
||||
54 => '2.27',
|
||||
55 => '2.28',
|
||||
56 => '2.29',
|
||||
57 => '2.29',
|
||||
58 => '2.31',
|
||||
59 => '2.32',
|
||||
60 => '2.33',
|
||||
61 => '2.34',
|
||||
62 => '2.35',
|
||||
63 => '2.36',
|
||||
64 => '2.37',
|
||||
65 => '2.38',
|
||||
66 => '2.40',
|
||||
67 => '2.41',
|
||||
68 => '2.42',
|
||||
69 => '2.44',
|
||||
];
|
||||
|
||||
/**
|
||||
* Path to the bin directory.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $directory = __DIR__.'/../../bin/';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$version = $this->version();
|
||||
|
||||
$all = $this->option('all');
|
||||
|
||||
$currentOS = OperatingSystem::id();
|
||||
|
||||
foreach (OperatingSystem::all() as $os) {
|
||||
if ($all || ($os === $currentOS)) {
|
||||
$archive = $this->download($version, $os);
|
||||
|
||||
$binary = $this->extract($archive);
|
||||
|
||||
$this->rename($binary, $os);
|
||||
}
|
||||
}
|
||||
|
||||
$message = 'ChromeDriver %s successfully installed for version %s.';
|
||||
|
||||
$this->components->info(sprintf($message, $all ? 'binaries' : 'binary', $version));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the desired ChromeDriver version.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function version()
|
||||
{
|
||||
$version = $this->argument('version');
|
||||
|
||||
if ($this->option('detect')) {
|
||||
$version = $this->detectChromeVersion(OperatingSystem::id());
|
||||
}
|
||||
|
||||
if (! $version) {
|
||||
return $this->latestVersion();
|
||||
}
|
||||
|
||||
if (! ctype_digit($version)) {
|
||||
return $version;
|
||||
}
|
||||
|
||||
$version = (int) $version;
|
||||
|
||||
if ($version < 70) {
|
||||
return $this->legacyVersions[$version];
|
||||
} elseif ($version < 115) {
|
||||
return $this->fetchChromeVersionFromUrl($version);
|
||||
}
|
||||
|
||||
$milestones = $this->resolveChromeVersionsPerMilestone();
|
||||
|
||||
return $milestones['milestones'][$version]['version']
|
||||
?? throw new Exception('Could not determine the ChromeDriver version.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest stable ChromeDriver version.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function latestVersion()
|
||||
{
|
||||
$versions = json_decode($this->getUrl('https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json'), true);
|
||||
|
||||
return $versions['channels']['Stable']['version']
|
||||
?? throw new Exception('Could not get the latest ChromeDriver version.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the installed Chrome / Chromium major version.
|
||||
*
|
||||
* @param string $os
|
||||
* @return int|bool
|
||||
*/
|
||||
protected function detectChromeVersion($os)
|
||||
{
|
||||
foreach (OperatingSystem::chromeVersionCommands($os) as $command) {
|
||||
$process = Process::fromShellCommandline($command);
|
||||
|
||||
$process->run();
|
||||
|
||||
preg_match('/(\d+)(\.\d+){3}/', $process->getOutput(), $matches);
|
||||
|
||||
if (! isset($matches[1])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
$this->components->error('Chrome version could not be detected.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the ChromeDriver archive.
|
||||
*
|
||||
* @param string $version
|
||||
* @param string $os
|
||||
* @return string
|
||||
*/
|
||||
protected function download($version, $os)
|
||||
{
|
||||
$url = $this->resolveChromeDriverDownloadUrl($version, $os);
|
||||
|
||||
$resource = Utils::tryFopen($archive = $this->directory.'chromedriver.zip', 'w');
|
||||
|
||||
$client = new Client();
|
||||
|
||||
$response = $client->get($url, array_merge([
|
||||
'sink' => $resource,
|
||||
'verify' => $this->option('ssl-no-verify') === false,
|
||||
], array_filter([
|
||||
'proxy' => $this->option('proxy'),
|
||||
])));
|
||||
|
||||
if ($response->getStatusCode() < 200 || $response->getStatusCode() > 299) {
|
||||
throw new Exception("Unable to download ChromeDriver from [{$url}].");
|
||||
}
|
||||
|
||||
return $archive;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the ChromeDriver binary from the archive and delete the archive.
|
||||
*
|
||||
* @param string $archive
|
||||
* @return string
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
protected function extract($archive)
|
||||
{
|
||||
$zip = new ZipArchive;
|
||||
|
||||
$zip->open($archive);
|
||||
|
||||
$binary = null;
|
||||
|
||||
for ($fileIndex = 0; $fileIndex < $zip->numFiles; $fileIndex++) {
|
||||
$filename = $zip->getNameIndex($fileIndex);
|
||||
|
||||
if (Str::startsWith(basename($filename), 'chromedriver')) {
|
||||
$binary = $filename;
|
||||
|
||||
$zip->extractTo($this->directory, $binary);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
|
||||
unlink($archive);
|
||||
|
||||
if (! $binary) {
|
||||
throw new Exception('Could not extract the ChromeDriver binary.');
|
||||
}
|
||||
|
||||
return $binary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename the ChromeDriver binary and make it executable.
|
||||
*
|
||||
* @param string $binary
|
||||
* @param string $os
|
||||
* @return void
|
||||
*/
|
||||
protected function rename($binary, $os)
|
||||
{
|
||||
$binary = str_replace(DIRECTORY_SEPARATOR, '/', $binary);
|
||||
|
||||
$newName = Str::contains($binary, '/')
|
||||
? Str::after(str_replace('chromedriver', 'chromedriver-'.$os, $binary), '/')
|
||||
: str_replace('chromedriver', 'chromedriver-'.$os, $binary);
|
||||
|
||||
rename($this->directory.$binary, $this->directory.$newName);
|
||||
|
||||
chmod($this->directory.$newName, 0755);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Chrome version from URL.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function fetchChromeVersionFromUrl(int $version)
|
||||
{
|
||||
return trim((string) $this->getUrl(
|
||||
sprintf('https://chromedriver.storage.googleapis.com/LATEST_RELEASE_%d', $version)
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Chrome versions per milestone.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function resolveChromeVersionsPerMilestone()
|
||||
{
|
||||
return json_decode(
|
||||
$this->getUrl('https://googlechromelabs.github.io/chrome-for-testing/latest-versions-per-milestone-with-downloads.json'), true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the download URL.
|
||||
*
|
||||
* @return string
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
protected function resolveChromeDriverDownloadUrl(string $version, string $os)
|
||||
{
|
||||
$slug = OperatingSystem::chromeDriverSlug($os, $version);
|
||||
|
||||
if (version_compare($version, '115.0', '<')) {
|
||||
return sprintf('https://chromedriver.storage.googleapis.com/%s/chromedriver_%s.zip', $version, $slug);
|
||||
}
|
||||
|
||||
$milestone = (int) $version;
|
||||
|
||||
$versions = $this->resolveChromeVersionsPerMilestone();
|
||||
|
||||
/** @var array<string, mixed> $chromedrivers */
|
||||
$chromedrivers = $versions['milestones'][$milestone]['downloads']['chromedriver']
|
||||
?? throw new Exception('Could not get the ChromeDriver version.');
|
||||
|
||||
return collect($chromedrivers)->firstWhere('platform', $slug)['url']
|
||||
?? throw new Exception('Could not get the ChromeDriver version.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the contents of a URL using the 'proxy' and 'ssl-no-verify' command options.
|
||||
*
|
||||
* @return string
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
protected function getUrl(string $url)
|
||||
{
|
||||
$client = new Client();
|
||||
|
||||
$response = $client->get($url, array_merge([
|
||||
'verify' => $this->option('ssl-no-verify') === false,
|
||||
], array_filter([
|
||||
'proxy' => $this->option('proxy'),
|
||||
])));
|
||||
|
||||
if ($response->getStatusCode() < 200 || $response->getStatusCode() > 299) {
|
||||
throw new Exception("Unable to fetch contents from [{$url}].");
|
||||
}
|
||||
|
||||
return (string) $response->getBody();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace Laravel\Dusk\Console;
|
||||
|
||||
use Illuminate\Console\GeneratorCommand;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
|
||||
#[AsCommand(name: 'dusk:component')]
|
||||
class ComponentCommand extends GeneratorCommand
|
||||
{
|
||||
/**
|
||||
* The console command name.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'dusk:component {name : The name of the class}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Create a new Dusk component class';
|
||||
|
||||
/**
|
||||
* The type of class being generated.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $type = 'Component';
|
||||
|
||||
/**
|
||||
* Get the stub file for the generator.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function getStub()
|
||||
{
|
||||
return __DIR__.'/stubs/component.stub';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the destination class path.
|
||||
*
|
||||
* @param string $name
|
||||
* @return string
|
||||
*/
|
||||
protected function getPath($name)
|
||||
{
|
||||
$name = Str::replaceFirst($this->rootNamespace(), '', $name);
|
||||
|
||||
return $this->laravel->basePath().'/tests'.str_replace('\\', '/', $name).'.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default namespace for the class.
|
||||
*
|
||||
* @param string $rootNamespace
|
||||
* @return string
|
||||
*/
|
||||
protected function getDefaultNamespace($rootNamespace)
|
||||
{
|
||||
return $rootNamespace.'\Browser\Components';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the root namespace for the class.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function rootNamespace()
|
||||
{
|
||||
return 'Tests';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace Laravel\Dusk\Console\Concerns;
|
||||
|
||||
trait InteractsWithTestingFrameworks
|
||||
{
|
||||
/**
|
||||
* Determine if Pest is being used by the application.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function usingPest()
|
||||
{
|
||||
return function_exists('\Pest\\version') && file_exists(base_path('tests').'/Pest.php');
|
||||
}
|
||||
}
|
||||
+390
@@ -0,0 +1,390 @@
|
||||
<?php
|
||||
|
||||
namespace Laravel\Dusk\Console;
|
||||
|
||||
use Dotenv\Dotenv;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Str;
|
||||
use NunoMaduro\Collision\Adapters\Phpunit\Subscribers\EnsurePrinterIsRegisteredSubscriber;
|
||||
use PHPUnit\Runner\Version;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
use Symfony\Component\Process\Exception\ProcessSignaledException;
|
||||
use Symfony\Component\Process\Exception\RuntimeException;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
#[AsCommand(name: 'dusk')]
|
||||
class DuskCommand extends Command
|
||||
{
|
||||
use Concerns\InteractsWithTestingFrameworks;
|
||||
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'dusk
|
||||
{--browse : Open a browser instead of using headless mode}
|
||||
{--without-tty : Disable output to TTY}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Run the Dusk tests for the application';
|
||||
|
||||
/**
|
||||
* Indicates if the project has its own PHPUnit configuration.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
protected $hasPhpUnitConfiguration = false;
|
||||
|
||||
/**
|
||||
* Create a new command instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
|
||||
$this->ignoreValidationErrors();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$this->purgeScreenshots();
|
||||
|
||||
$this->purgeConsoleLogs();
|
||||
|
||||
$this->purgeSourceLogs();
|
||||
|
||||
$options = collect($_SERVER['argv'])
|
||||
->slice(2)
|
||||
->diff([
|
||||
'--browse', '--without-tty',
|
||||
'--quiet', '-q',
|
||||
'--verbose', '-v', '-vv', '-vvv',
|
||||
'--no-interaction', '-n',
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return $this->withDuskEnvironment(function () use ($options) {
|
||||
$process = (new Process(array_merge(
|
||||
$this->binary(), $this->phpunitArguments($options)
|
||||
), null, $this->env()))->setTimeout(null);
|
||||
|
||||
try {
|
||||
$process->setTty(! $this->option('without-tty'));
|
||||
} catch (RuntimeException $e) {
|
||||
$this->output->writeln('Warning: '.$e->getMessage());
|
||||
}
|
||||
|
||||
try {
|
||||
return $process->run(function ($type, $line) {
|
||||
$this->output->write($line);
|
||||
});
|
||||
} catch (ProcessSignaledException $e) {
|
||||
if (extension_loaded('pcntl') && $e->getSignal() !== SIGINT) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the PHP binary to execute.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function binary()
|
||||
{
|
||||
$binaryPath = 'vendor/phpunit/phpunit/phpunit';
|
||||
|
||||
if ($this->usingPest()) {
|
||||
$binaryPath = 'vendor/pestphp/pest/bin/pest';
|
||||
}
|
||||
|
||||
if ('phpdbg' === PHP_SAPI) {
|
||||
return [PHP_BINARY, '-qrr', $binaryPath];
|
||||
}
|
||||
|
||||
return [PHP_BINARY, $binaryPath];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the array of arguments for running PHPUnit.
|
||||
*
|
||||
* @param array $options
|
||||
* @return array
|
||||
*/
|
||||
protected function phpunitArguments($options)
|
||||
{
|
||||
if ($this->shouldUseCollisionPrinter()) {
|
||||
$options[] = '--no-output';
|
||||
}
|
||||
|
||||
$options = array_values(array_filter($options, function ($option) {
|
||||
return ! Str::startsWith($option, ['--env=', '--pest', '--ansi', '--no-ansi']);
|
||||
}));
|
||||
|
||||
if (! file_exists($file = base_path('phpunit.dusk.xml'))) {
|
||||
$file = base_path('phpunit.dusk.xml.dist');
|
||||
}
|
||||
|
||||
if (version_compare(Version::id(), '10.0', '>=')) {
|
||||
if ($this->option('ansi')) {
|
||||
$options[] = '--colors=always';
|
||||
}
|
||||
|
||||
if ($this->option('no-ansi')) {
|
||||
$options[] = '--colors=never';
|
||||
}
|
||||
}
|
||||
|
||||
return array_merge(['-c', $file], $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the PHP binary environment variables.
|
||||
*
|
||||
* @return array|null
|
||||
*/
|
||||
protected function env()
|
||||
{
|
||||
$variables = [];
|
||||
|
||||
if ($this->option('browse') && ! isset($_ENV['CI']) && ! isset($_SERVER['CI'])) {
|
||||
$variables['DUSK_HEADLESS_DISABLED'] = true;
|
||||
}
|
||||
|
||||
if ($this->shouldUseCollisionPrinter()) {
|
||||
$variables['COLLISION_PRINTER'] = 'DefaultPrinter';
|
||||
}
|
||||
|
||||
return $variables;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if Collision's printer should be used.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function shouldUseCollisionPrinter()
|
||||
{
|
||||
return ! $this->usingPest()
|
||||
&& class_exists(EnsurePrinterIsRegisteredSubscriber::class)
|
||||
&& version_compare(Version::id(), '10.0', '>=');
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge the failure screenshots.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function purgeScreenshots()
|
||||
{
|
||||
$this->purgeDebuggingFiles(
|
||||
base_path('tests/Browser/screenshots'), 'failure-*'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge the console logs.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function purgeConsoleLogs()
|
||||
{
|
||||
$this->purgeDebuggingFiles(
|
||||
base_path('tests/Browser/console'), '*.log'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge the source logs.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function purgeSourceLogs()
|
||||
{
|
||||
$this->purgeDebuggingFiles(
|
||||
base_path('tests/Browser/source'), '*.txt'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge debugging files based on path and patterns.
|
||||
*
|
||||
* @param string $path
|
||||
* @param string $patterns
|
||||
* @return void
|
||||
*/
|
||||
protected function purgeDebuggingFiles($path, $patterns)
|
||||
{
|
||||
if (! is_dir($path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$files = Finder::create()->files()
|
||||
->in($path)
|
||||
->name($patterns);
|
||||
|
||||
foreach ($files as $file) {
|
||||
@unlink($file->getRealPath());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the given callback with the Dusk configuration files.
|
||||
*
|
||||
* @param \Closure $callback
|
||||
* @return mixed
|
||||
*/
|
||||
protected function withDuskEnvironment($callback)
|
||||
{
|
||||
$this->setupDuskEnvironment();
|
||||
|
||||
try {
|
||||
return $callback();
|
||||
} finally {
|
||||
$this->teardownDuskEnvironment();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup the Dusk environment.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function setupDuskEnvironment()
|
||||
{
|
||||
if (file_exists(base_path($this->duskFile()))) {
|
||||
if (file_exists(base_path('.env')) &&
|
||||
file_get_contents(base_path('.env')) !== file_get_contents(base_path($this->duskFile()))) {
|
||||
$this->backupEnvironment();
|
||||
}
|
||||
|
||||
$this->refreshEnvironment();
|
||||
}
|
||||
|
||||
$this->writeConfiguration();
|
||||
|
||||
$this->setupSignalHandler();
|
||||
}
|
||||
|
||||
/**
|
||||
* Backup the current environment file.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function backupEnvironment()
|
||||
{
|
||||
copy(base_path('.env'), base_path('.env.backup'));
|
||||
|
||||
copy(base_path($this->duskFile()), base_path('.env'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the current environment variables.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function refreshEnvironment()
|
||||
{
|
||||
Dotenv::createMutable(base_path())->load();
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the Dusk PHPUnit configuration.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function writeConfiguration()
|
||||
{
|
||||
if (! file_exists($file = base_path('phpunit.dusk.xml')) &&
|
||||
! file_exists(base_path('phpunit.dusk.xml.dist'))) {
|
||||
copy(realpath(__DIR__.'/../../stubs/phpunit.xml'), $file);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->hasPhpUnitConfiguration = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup the SIGINT signal handler for CTRL+C exits.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function setupSignalHandler()
|
||||
{
|
||||
if (extension_loaded('pcntl')) {
|
||||
pcntl_async_signals(true);
|
||||
|
||||
pcntl_signal(SIGINT, function () {
|
||||
$this->teardownDuskEnvironment();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the original environment.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function teardownDuskEnvironment()
|
||||
{
|
||||
$this->removeConfiguration();
|
||||
|
||||
if (file_exists(base_path($this->duskFile())) && file_exists(base_path('.env.backup'))) {
|
||||
$this->restoreEnvironment();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the Dusk PHPUnit configuration.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function removeConfiguration()
|
||||
{
|
||||
if (! $this->hasPhpUnitConfiguration && file_exists($file = base_path('phpunit.dusk.xml'))) {
|
||||
unlink($file);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the backed-up environment file.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function restoreEnvironment()
|
||||
{
|
||||
copy(base_path('.env.backup'), base_path('.env'));
|
||||
|
||||
unlink(base_path('.env.backup'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the name of the Dusk file for the environment.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function duskFile()
|
||||
{
|
||||
if (file_exists(base_path($file = '.env.dusk.'.$this->laravel->environment()))) {
|
||||
return $file;
|
||||
}
|
||||
|
||||
return '.env.dusk';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace Laravel\Dusk\Console;
|
||||
|
||||
class DuskFailsCommand extends DuskCommand
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'dusk:fails
|
||||
{--browse : Open a browser instead of using headless mode}
|
||||
{--without-tty : Disable output to TTY}
|
||||
{--pest : Run the tests using Pest}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Run the failing Dusk tests from the last run and stop on failure';
|
||||
|
||||
/**
|
||||
* Get the array of arguments for running PHPUnit.
|
||||
*
|
||||
* @param array $options
|
||||
* @return array
|
||||
*/
|
||||
protected function phpunitArguments($options)
|
||||
{
|
||||
return array_unique(array_merge(parent::phpunitArguments($options), [
|
||||
'--cache-result', '--order-by=defects', '--stop-on-failure',
|
||||
]));
|
||||
}
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
namespace Laravel\Dusk\Console;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
|
||||
#[AsCommand(name: 'dusk:install')]
|
||||
class InstallCommand extends Command
|
||||
{
|
||||
use Concerns\InteractsWithTestingFrameworks;
|
||||
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'dusk:install
|
||||
{--proxy= : The proxy to download the binary through (example: "tcp://127.0.0.1:9000")}
|
||||
{--ssl-no-verify : Bypass SSL certificate verification when installing through a proxy}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Install Dusk into the application';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
if (! is_dir(base_path('tests/Browser/Pages'))) {
|
||||
mkdir(base_path('tests/Browser/Pages'), 0755, true);
|
||||
}
|
||||
|
||||
if (! is_dir(base_path('tests/Browser/Components'))) {
|
||||
mkdir(base_path('tests/Browser/Components'), 0755, true);
|
||||
}
|
||||
|
||||
if (! is_dir(base_path('tests/Browser/screenshots'))) {
|
||||
$this->createScreenshotsDirectory();
|
||||
}
|
||||
|
||||
if (! is_dir(base_path('tests/Browser/console'))) {
|
||||
$this->createConsoleDirectory();
|
||||
}
|
||||
|
||||
if (! is_dir(base_path('tests/Browser/source'))) {
|
||||
$this->createSourceDirectory();
|
||||
}
|
||||
|
||||
$stubs = [
|
||||
'HomePage.stub' => base_path('tests/Browser/Pages/HomePage.php'),
|
||||
'DuskTestCase.stub' => base_path('tests/DuskTestCase.php'),
|
||||
'Page.stub' => base_path('tests/Browser/Pages/Page.php'),
|
||||
];
|
||||
|
||||
if ($this->usingPest()) {
|
||||
$stubs['ExampleTest.pest.stub'] = base_path('tests/Browser/ExampleTest.php');
|
||||
|
||||
$contents = file_get_contents(base_path('tests/Pest.php'));
|
||||
|
||||
if (str_contains($contents, 'uses(')) {
|
||||
$contents = str_replace('<?php', <<<EOT
|
||||
<?php
|
||||
|
||||
uses(
|
||||
Tests\DuskTestCase::class,
|
||||
// Illuminate\Foundation\Testing\DatabaseMigrations::class,
|
||||
)->in('Browser');
|
||||
EOT, $contents);
|
||||
} else {
|
||||
$contents = str_replace('<?php', <<<EOT
|
||||
<?php
|
||||
|
||||
pest()->extend(Tests\DuskTestCase::class)
|
||||
// ->use(Illuminate\Foundation\Testing\DatabaseMigrations::class)
|
||||
->in('Browser');
|
||||
EOT, $contents);
|
||||
}
|
||||
|
||||
file_put_contents(base_path('tests/Pest.php'), $contents);
|
||||
} else {
|
||||
$stubs['ExampleTest.stub'] = base_path('tests/Browser/ExampleTest.php');
|
||||
}
|
||||
|
||||
foreach ($stubs as $stub => $file) {
|
||||
if (! is_file($file)) {
|
||||
copy(__DIR__.'/../../stubs/'.$stub, $file);
|
||||
}
|
||||
}
|
||||
|
||||
$baseTestCase = file_get_contents(base_path('tests/DuskTestCase.php'));
|
||||
|
||||
if (! trait_exists(\Tests\CreatesApplication::class)) {
|
||||
file_put_contents(base_path('tests/DuskTestCase.php'), str_replace(<<<'EOT'
|
||||
{
|
||||
use CreatesApplication;
|
||||
|
||||
EOT, <<<'EOT'
|
||||
{
|
||||
EOT,
|
||||
$baseTestCase,
|
||||
));
|
||||
}
|
||||
|
||||
$this->components->info('Dusk scaffolding installed successfully.');
|
||||
|
||||
$this->components->task('Downloading ChromeDriver binaries...', function () {
|
||||
$driverCommandArgs = [];
|
||||
|
||||
if ($this->option('proxy')) {
|
||||
$driverCommandArgs['--proxy'] = $this->option('proxy');
|
||||
}
|
||||
|
||||
if ($this->option('ssl-no-verify')) {
|
||||
$driverCommandArgs['--ssl-no-verify'] = true;
|
||||
}
|
||||
|
||||
$this->call('dusk:chrome-driver', $driverCommandArgs);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the screenshots directory.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function createScreenshotsDirectory()
|
||||
{
|
||||
mkdir(base_path('tests/Browser/screenshots'), 0755, true);
|
||||
|
||||
file_put_contents(base_path('tests/Browser/screenshots/.gitignore'), '*
|
||||
!.gitignore
|
||||
');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the console directory.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function createConsoleDirectory()
|
||||
{
|
||||
mkdir(base_path('tests/Browser/console'), 0755, true);
|
||||
|
||||
file_put_contents(base_path('tests/Browser/console/.gitignore'), '*
|
||||
!.gitignore
|
||||
');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the source directory.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function createSourceDirectory()
|
||||
{
|
||||
mkdir(base_path('tests/Browser/source'), 0755, true);
|
||||
|
||||
file_put_contents(base_path('tests/Browser/source/.gitignore'), '*
|
||||
!.gitignore
|
||||
');
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace Laravel\Dusk\Console;
|
||||
|
||||
use Illuminate\Console\GeneratorCommand;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
|
||||
#[AsCommand(name: 'dusk:make')]
|
||||
class MakeCommand extends GeneratorCommand
|
||||
{
|
||||
use Concerns\InteractsWithTestingFrameworks;
|
||||
|
||||
/**
|
||||
* The console command name.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'dusk:make {name : The name of the class}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Create a new Dusk test class';
|
||||
|
||||
/**
|
||||
* The type of class being generated.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $type = 'Test';
|
||||
|
||||
/**
|
||||
* Get the stub file for the generator.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function getStub()
|
||||
{
|
||||
return $this->usingPest()
|
||||
? __DIR__.'/stubs/test.pest.stub'
|
||||
: __DIR__.'/stubs/test.stub';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the destination class path.
|
||||
*
|
||||
* @param string $name
|
||||
* @return string
|
||||
*/
|
||||
protected function getPath($name)
|
||||
{
|
||||
$name = Str::replaceFirst($this->rootNamespace(), '', $name);
|
||||
|
||||
return $this->laravel->basePath().'/tests'.str_replace('\\', '/', $name).'.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default namespace for the class.
|
||||
*
|
||||
* @param string $rootNamespace
|
||||
* @return string
|
||||
*/
|
||||
protected function getDefaultNamespace($rootNamespace)
|
||||
{
|
||||
return $rootNamespace.'\Browser';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the root namespace for the class.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function rootNamespace()
|
||||
{
|
||||
return 'Tests';
|
||||
}
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
namespace Laravel\Dusk\Console;
|
||||
|
||||
use Illuminate\Console\GeneratorCommand;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
|
||||
#[AsCommand(name: 'dusk:page')]
|
||||
class PageCommand extends GeneratorCommand
|
||||
{
|
||||
/**
|
||||
* The console command name.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'dusk:page {name : The name of the class}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Create a new Dusk page class';
|
||||
|
||||
/**
|
||||
* The type of class being generated.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $type = 'Page';
|
||||
|
||||
/**
|
||||
* Build the class with the given name.
|
||||
*
|
||||
* @param string $name
|
||||
* @return string
|
||||
*/
|
||||
protected function buildClass($name)
|
||||
{
|
||||
$result = parent::buildClass($name);
|
||||
|
||||
$pageName = $this->argument('name');
|
||||
|
||||
$baseClass = 'Tests\Browser\Pages\Page';
|
||||
|
||||
if (! Str::contains($pageName, '/') && class_exists($baseClass)) {
|
||||
return $result;
|
||||
} elseif (! class_exists($baseClass)) {
|
||||
$baseClass = 'Laravel\Dusk\Page';
|
||||
}
|
||||
|
||||
$lineEndingCount = [
|
||||
"\r\n" => substr_count($result, "\r\n"),
|
||||
"\r" => substr_count($result, "\r"),
|
||||
"\n" => substr_count($result, "\n"),
|
||||
];
|
||||
|
||||
$eol = array_keys($lineEndingCount, max($lineEndingCount))[0];
|
||||
|
||||
return str_replace(
|
||||
'use Laravel\Dusk\Browser;'.$eol,
|
||||
'use Laravel\Dusk\Browser;'.$eol."use {$baseClass};".$eol,
|
||||
$result
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the stub file for the generator.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function getStub()
|
||||
{
|
||||
return __DIR__.'/stubs/page.stub';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the destination class path.
|
||||
*
|
||||
* @param string $name
|
||||
* @return string
|
||||
*/
|
||||
protected function getPath($name)
|
||||
{
|
||||
$name = Str::replaceFirst($this->rootNamespace(), '', $name);
|
||||
|
||||
return $this->laravel->basePath().'/tests'.str_replace('\\', '/', $name).'.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default namespace for the class.
|
||||
*
|
||||
* @param string $rootNamespace
|
||||
* @return string
|
||||
*/
|
||||
protected function getDefaultNamespace($rootNamespace)
|
||||
{
|
||||
return $rootNamespace.'\Browser\Pages';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the root namespace for the class.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function rootNamespace()
|
||||
{
|
||||
return 'Tests';
|
||||
}
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace Laravel\Dusk\Console;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
|
||||
#[AsCommand(name: 'dusk:purge')]
|
||||
class PurgeCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'dusk:purge';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Purge dusk test debugging files';
|
||||
|
||||
/**
|
||||
* Create a new command instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
|
||||
$this->ignoreValidationErrors();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$this->purgeScreenshots();
|
||||
$this->purgeConsoleLogs();
|
||||
$this->purgeSourceLogs();
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge the failure screenshots.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function purgeScreenshots()
|
||||
{
|
||||
$this->purgeDebuggingFiles(
|
||||
'tests/Browser/screenshots', 'failure-*'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge the console logs.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function purgeConsoleLogs()
|
||||
{
|
||||
$this->purgeDebuggingFiles(
|
||||
'tests/Browser/console', '*.log'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge the source logs.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function purgeSourceLogs()
|
||||
{
|
||||
$this->purgeDebuggingFiles(
|
||||
'tests/Browser/source', '*.txt'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge debugging files based on path and patterns.
|
||||
*
|
||||
* @param string $relativePath
|
||||
* @param string $patterns
|
||||
* @return void
|
||||
*/
|
||||
protected function purgeDebuggingFiles($relativePath, $patterns)
|
||||
{
|
||||
$path = base_path($relativePath);
|
||||
|
||||
if (! is_dir($path)) {
|
||||
$this->components->warn(
|
||||
"Unable to purge missing directory [{$relativePath}].", OutputInterface::VERBOSITY_DEBUG
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$files = Finder::create()->files()
|
||||
->in($path)
|
||||
->name($patterns);
|
||||
|
||||
foreach ($files as $file) {
|
||||
@unlink($file->getRealPath());
|
||||
}
|
||||
|
||||
$this->components->info("Purged \"{$patterns}\" from [{$relativePath}].");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace DummyNamespace;
|
||||
|
||||
use Laravel\Dusk\Browser;
|
||||
use Laravel\Dusk\Component as BaseComponent;
|
||||
|
||||
class DummyClass extends BaseComponent
|
||||
{
|
||||
/**
|
||||
* Get the root selector for the component.
|
||||
*/
|
||||
public function selector(): string
|
||||
{
|
||||
return '#selector';
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the browser page contains the component.
|
||||
*/
|
||||
public function assert(Browser $browser): void
|
||||
{
|
||||
$browser->assertVisible($this->selector());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the element shortcuts for the component.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function elements(): array
|
||||
{
|
||||
return [
|
||||
'@element' => '#selector',
|
||||
];
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace DummyNamespace;
|
||||
|
||||
use Laravel\Dusk\Browser;
|
||||
|
||||
class DummyClass extends Page
|
||||
{
|
||||
/**
|
||||
* Get the URL for the page.
|
||||
*/
|
||||
public function url(): string
|
||||
{
|
||||
return '/';
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the browser is on the page.
|
||||
*/
|
||||
public function assert(Browser $browser): void
|
||||
{
|
||||
$browser->assertPathIs($this->url());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the element shortcuts for the page.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function elements(): array
|
||||
{
|
||||
return [
|
||||
'@element' => '#selector',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
use Laravel\Dusk\Browser;
|
||||
|
||||
test('example', function () {
|
||||
$this->browse(function (Browser $browser) {
|
||||
$browser->visit('/')
|
||||
->assertSee('Laravel');
|
||||
});
|
||||
});
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace DummyNamespace;
|
||||
|
||||
use Laravel\Dusk\Browser;
|
||||
use Illuminate\Foundation\Testing\DatabaseMigrations;
|
||||
use Tests\DuskTestCase;
|
||||
|
||||
class DummyClass extends DuskTestCase
|
||||
{
|
||||
/**
|
||||
* A Dusk test example.
|
||||
*/
|
||||
public function test_example(): void
|
||||
{
|
||||
$this->browse(function (Browser $browser) {
|
||||
$browser->visit('/')
|
||||
->assertSee('Laravel');
|
||||
});
|
||||
}
|
||||
}
|
||||
Vendored
+64
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace Laravel\Dusk;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
class Dusk
|
||||
{
|
||||
/**
|
||||
* The Dusk selector (@dusk) HTML attribute.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public static $selectorHtmlAttribute = 'dusk';
|
||||
|
||||
/**
|
||||
* Register the Dusk service provider.
|
||||
*
|
||||
* @param array $options
|
||||
* @return void
|
||||
*/
|
||||
public static function register(array $options = [])
|
||||
{
|
||||
if (static::duskEnvironment($options)) {
|
||||
app()->register(DuskServiceProvider::class);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if Dusk may run in this environment.
|
||||
*
|
||||
* @param array $options
|
||||
* @return bool
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
protected static function duskEnvironment($options)
|
||||
{
|
||||
if (! isset($options['environments'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (is_string($options['environments'])) {
|
||||
$options['environments'] = [$options['environments']];
|
||||
}
|
||||
|
||||
if (! is_array($options['environments'])) {
|
||||
throw new InvalidArgumentException('Dusk environments must be listed as an array.');
|
||||
}
|
||||
|
||||
return app()->environment(...$options['environments']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the Dusk selector (@dusk) HTML attribute.
|
||||
*
|
||||
* @param string $attribute
|
||||
* @return void
|
||||
*/
|
||||
public static function selectorHtmlAttribute(string $attribute)
|
||||
{
|
||||
static::$selectorHtmlAttribute = $attribute;
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace Laravel\Dusk;
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class DuskServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Bootstrap any package services.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function boot()
|
||||
{
|
||||
if (! $this->app->environment('production')) {
|
||||
Route::group(array_filter([
|
||||
'prefix' => config('dusk.path', '_dusk'),
|
||||
'domain' => config('dusk.domain', null),
|
||||
'middleware' => config('dusk.middleware', 'web'),
|
||||
]), function () {
|
||||
Route::get('/login/{userId}/{guard?}', [
|
||||
'uses' => 'Laravel\Dusk\Http\Controllers\UserController@login',
|
||||
'as' => 'dusk.login',
|
||||
]);
|
||||
|
||||
Route::get('/logout/{guard?}', [
|
||||
'uses' => 'Laravel\Dusk\Http\Controllers\UserController@logout',
|
||||
'as' => 'dusk.logout',
|
||||
]);
|
||||
|
||||
Route::get('/user/{guard?}', [
|
||||
'uses' => 'Laravel\Dusk\Http\Controllers\UserController@user',
|
||||
'as' => 'dusk.user',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
if ($this->app->runningInConsole()) {
|
||||
$this->commands([
|
||||
Console\InstallCommand::class,
|
||||
Console\DuskCommand::class,
|
||||
Console\DuskFailsCommand::class,
|
||||
Console\MakeCommand::class,
|
||||
Console\PageCommand::class,
|
||||
Console\PurgeCommand::class,
|
||||
Console\ComponentCommand::class,
|
||||
Console\ChromeDriverCommand::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
+426
@@ -0,0 +1,426 @@
|
||||
<?php
|
||||
|
||||
namespace Laravel\Dusk;
|
||||
|
||||
use Exception;
|
||||
use Facebook\WebDriver\WebDriverBy;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Support\Traits\Macroable;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class ElementResolver
|
||||
{
|
||||
use Macroable;
|
||||
|
||||
/**
|
||||
* The remote web driver instance.
|
||||
*
|
||||
* @var \Facebook\WebDriver\Remote\RemoteWebDriver
|
||||
*/
|
||||
public $driver;
|
||||
|
||||
/**
|
||||
* The selector prefix for the resolver.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public $prefix;
|
||||
|
||||
/**
|
||||
* Set the elements the resolver should use as shortcuts.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
public $elements = [];
|
||||
|
||||
/**
|
||||
* The button finding methods.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $buttonFinders = [
|
||||
'findById',
|
||||
'findButtonBySelector',
|
||||
'findButtonByName',
|
||||
'findButtonByValue',
|
||||
'findButtonByText',
|
||||
];
|
||||
|
||||
/**
|
||||
* Create a new element resolver instance.
|
||||
*
|
||||
* @param \Facebook\WebDriver\Remote\RemoteWebDriver $driver
|
||||
* @param string $prefix
|
||||
* @return void
|
||||
*/
|
||||
public function __construct($driver, $prefix = 'body')
|
||||
{
|
||||
$this->driver = $driver;
|
||||
$this->prefix = trim($prefix);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the page elements the resolver should use as shortcuts.
|
||||
*
|
||||
* @param array<string, string> $elements
|
||||
* @return $this
|
||||
*/
|
||||
public function pageElements(array $elements)
|
||||
{
|
||||
$this->elements = $elements;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the element for a given input "field".
|
||||
*
|
||||
* @param string $field
|
||||
* @return \Facebook\WebDriver\Remote\RemoteWebElement
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function resolveForTyping($field)
|
||||
{
|
||||
if (! is_null($element = $this->findById($field))) {
|
||||
return $element;
|
||||
}
|
||||
|
||||
return $this->firstOrFail([
|
||||
"input[name='{$field}']", "textarea[name='{$field}']", $field,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the element for a given select "field".
|
||||
*
|
||||
* @param string $field
|
||||
* @return \Facebook\WebDriver\Remote\RemoteWebElement
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function resolveForSelection($field)
|
||||
{
|
||||
if (! is_null($element = $this->findById($field))) {
|
||||
return $element;
|
||||
}
|
||||
|
||||
return $this->firstOrFail([
|
||||
"select[name='{$field}']", $field,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve all the options with the given value on the select field.
|
||||
*
|
||||
* @param string $field
|
||||
* @param array $values
|
||||
* @return \Facebook\WebDriver\Remote\RemoteWebElement[]
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function resolveSelectOptions($field, array $values)
|
||||
{
|
||||
$options = $this->resolveForSelection($field)
|
||||
->findElements(WebDriverBy::tagName('option'));
|
||||
|
||||
if (empty($options)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_filter($options, function ($option) use ($values) {
|
||||
return in_array($option->getAttribute('value'), $values);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the element for a given radio "field" / value.
|
||||
*
|
||||
* @param string $field
|
||||
* @param string|null $value
|
||||
* @return \Facebook\WebDriver\Remote\RemoteWebElement
|
||||
*
|
||||
* @throws \Exception
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function resolveForRadioSelection($field, $value = null)
|
||||
{
|
||||
if (! is_null($element = $this->findById($field))) {
|
||||
return $element;
|
||||
}
|
||||
|
||||
if (is_null($value)) {
|
||||
throw new InvalidArgumentException(
|
||||
"No value was provided for radio button [{$field}]."
|
||||
);
|
||||
}
|
||||
|
||||
return $this->firstOrFail([
|
||||
"input[type=radio][name='{$field}'][value='{$value}']", $field,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the element for a given checkbox "field".
|
||||
*
|
||||
* @param string|null $field
|
||||
* @param string|null $value
|
||||
* @return \Facebook\WebDriver\Remote\RemoteWebElement
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function resolveForChecking($field, $value = null)
|
||||
{
|
||||
if (! is_null($element = $this->findById($field))) {
|
||||
return $element;
|
||||
}
|
||||
|
||||
$selector = 'input[type=checkbox]';
|
||||
|
||||
if (! is_null($field)) {
|
||||
$selector .= "[name='{$field}']";
|
||||
}
|
||||
|
||||
if (! is_null($value)) {
|
||||
$selector .= "[value='{$value}']";
|
||||
}
|
||||
|
||||
return $this->firstOrFail([
|
||||
$selector, $field,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the element for a given file "field".
|
||||
*
|
||||
* @param string $field
|
||||
* @return \Facebook\WebDriver\Remote\RemoteWebElement
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function resolveForAttachment($field)
|
||||
{
|
||||
if (! is_null($element = $this->findById($field))) {
|
||||
return $element;
|
||||
}
|
||||
|
||||
return $this->firstOrFail([
|
||||
"input[type=file][name='{$field}']", $field,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the element for a given "field".
|
||||
*
|
||||
* @param string $field
|
||||
* @return \Facebook\WebDriver\Remote\RemoteWebElement
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function resolveForField($field)
|
||||
{
|
||||
if (! is_null($element = $this->findById($field))) {
|
||||
return $element;
|
||||
}
|
||||
|
||||
return $this->firstOrFail([
|
||||
"input[name='{$field}']", "textarea[name='{$field}']",
|
||||
"select[name='{$field}']", "button[name='{$field}']", $field,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the element for a given button.
|
||||
*
|
||||
* @param string $button
|
||||
* @return \Facebook\WebDriver\Remote\RemoteWebElement
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function resolveForButtonPress($button)
|
||||
{
|
||||
foreach ($this->buttonFinders as $method) {
|
||||
if (! is_null($element = $this->{$method}($button))) {
|
||||
return $element;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException(
|
||||
"Unable to locate button [{$button}]."
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the element for a given button by selector.
|
||||
*
|
||||
* @param string $button
|
||||
* @return \Facebook\WebDriver\Remote\RemoteWebElement|null
|
||||
*/
|
||||
protected function findButtonBySelector($button)
|
||||
{
|
||||
if (! is_null($element = $this->find($button))) {
|
||||
return $element;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the element for a given button by name.
|
||||
*
|
||||
* @param string $button
|
||||
* @return \Facebook\WebDriver\Remote\RemoteWebElement|null
|
||||
*/
|
||||
protected function findButtonByName($button)
|
||||
{
|
||||
if (! is_null($element = $this->find("input[type=submit][name='{$button}']")) ||
|
||||
! is_null($element = $this->find("input[type=button][value='{$button}']")) ||
|
||||
! is_null($element = $this->find("button[name='{$button}']"))) {
|
||||
return $element;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the element for a given button by value.
|
||||
*
|
||||
* @param string $button
|
||||
* @return \Facebook\WebDriver\Remote\RemoteWebElement|null
|
||||
*/
|
||||
protected function findButtonByValue($button)
|
||||
{
|
||||
foreach ($this->all('input[type=submit]') as $element) {
|
||||
if ($element->getAttribute('value') === $button) {
|
||||
return $element;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the element for a given button by text.
|
||||
*
|
||||
* @param string $button
|
||||
* @return \Facebook\WebDriver\Remote\RemoteWebElement|null
|
||||
*/
|
||||
protected function findButtonByText($button)
|
||||
{
|
||||
// First, try to find a button with an exact text match...
|
||||
foreach ($this->all('button') as $element) {
|
||||
if (trim($element->getText()) === $button) {
|
||||
return $element;
|
||||
}
|
||||
}
|
||||
|
||||
// If no exact match is found, fall back to a "contains" match...
|
||||
foreach ($this->all('button') as $element) {
|
||||
if (Str::contains($element->getText(), $button)) {
|
||||
return $element;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to find the selector by ID.
|
||||
*
|
||||
* @param string $selector
|
||||
* @return \Facebook\WebDriver\Remote\RemoteWebElement|null
|
||||
*/
|
||||
protected function findById($selector)
|
||||
{
|
||||
if (preg_match('/^#[\w\-:]+$/', $selector)) {
|
||||
return $this->driver->findElement(WebDriverBy::id(substr($selector, 1)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an element by the given selector or return null.
|
||||
*
|
||||
* @param string $selector
|
||||
* @return \Facebook\WebDriver\Remote\RemoteWebElement|null
|
||||
*/
|
||||
public function find($selector)
|
||||
{
|
||||
try {
|
||||
return $this->findOrFail($selector);
|
||||
} catch (Exception $e) {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the first element matching the given selectors.
|
||||
*
|
||||
* @param array $selectors
|
||||
* @return \Facebook\WebDriver\Remote\RemoteWebElement
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function firstOrFail($selectors)
|
||||
{
|
||||
foreach ((array) $selectors as $selector) {
|
||||
try {
|
||||
return $this->findOrFail($selector);
|
||||
} catch (Exception $e) {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an element by the given selector or throw an exception.
|
||||
*
|
||||
* @param string $selector
|
||||
* @return \Facebook\WebDriver\Remote\RemoteWebElement
|
||||
*/
|
||||
public function findOrFail($selector)
|
||||
{
|
||||
if (! is_null($element = $this->findById($selector))) {
|
||||
return $element;
|
||||
}
|
||||
|
||||
return $this->driver->findElement(
|
||||
WebDriverBy::cssSelector($this->format($selector))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the elements by the given selector or return an empty array.
|
||||
*
|
||||
* @param string $selector
|
||||
* @return \Facebook\WebDriver\Remote\RemoteWebElement[]
|
||||
*/
|
||||
public function all($selector)
|
||||
{
|
||||
try {
|
||||
return $this->driver->findElements(
|
||||
WebDriverBy::cssSelector($this->format($selector))
|
||||
);
|
||||
} catch (Exception $e) {
|
||||
//
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the given selector with the current prefix.
|
||||
*
|
||||
* @param string $selector
|
||||
* @return string
|
||||
*/
|
||||
public function format($selector)
|
||||
{
|
||||
$sortedElements = collect($this->elements)->sortByDesc(function ($element, $key) {
|
||||
return strlen($key);
|
||||
})->toArray();
|
||||
|
||||
$selector = str_replace(
|
||||
array_keys($sortedElements), array_values($sortedElements), $originalSelector = $selector
|
||||
);
|
||||
|
||||
if (Str::startsWith($selector, '@') && $selector === $originalSelector) {
|
||||
$selector = preg_replace('/@([^\s\)]+)/', '['.Dusk::$selectorHtmlAttribute.'="$1"]', $selector);
|
||||
}
|
||||
|
||||
return trim($this->prefix.' '.$selector);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace Laravel\Dusk\Http\Controllers;
|
||||
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class UserController
|
||||
{
|
||||
/**
|
||||
* Retrieve the authenticated user identifier and class name.
|
||||
*
|
||||
* @param string|null $guard
|
||||
* @return array
|
||||
*/
|
||||
public function user($guard = null)
|
||||
{
|
||||
$user = Auth::guard($guard)->user();
|
||||
|
||||
if (! $user) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $user->getAuthIdentifier(),
|
||||
'className' => get_class($user),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Login using the given user ID / email.
|
||||
*
|
||||
* @param string $userId
|
||||
* @param string|null $guard
|
||||
* @return void
|
||||
*/
|
||||
public function login($userId, $guard = null)
|
||||
{
|
||||
$guard = $guard ?: config('auth.defaults.guard');
|
||||
|
||||
$provider = Auth::guard($guard)->getProvider();
|
||||
|
||||
$user = Str::contains($userId, '@')
|
||||
? $provider->retrieveByCredentials(['email' => $userId])
|
||||
: $provider->retrieveById($userId);
|
||||
|
||||
Auth::guard($guard)->login($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log the user out of the application.
|
||||
*
|
||||
* @param string|null $guard
|
||||
* @return void
|
||||
*/
|
||||
public function logout($guard = null)
|
||||
{
|
||||
$guard = $guard ?: config('auth.defaults.guard');
|
||||
|
||||
Auth::guard($guard)->logout();
|
||||
|
||||
Session::forget('password_hash_'.$guard);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the model for the given guard.
|
||||
*
|
||||
* @param string $guard
|
||||
* @return string
|
||||
*/
|
||||
protected function modelForGuard($guard)
|
||||
{
|
||||
$provider = config("auth.guards.{$guard}.provider");
|
||||
|
||||
return config("auth.providers.{$provider}.model");
|
||||
}
|
||||
}
|
||||
Vendored
+114
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace Laravel\Dusk;
|
||||
|
||||
use BadMethodCallException;
|
||||
use Illuminate\Support\Traits\Macroable;
|
||||
|
||||
/**
|
||||
* @mixin \Facebook\WebDriver\Remote\RemoteKeyboard
|
||||
*/
|
||||
class Keyboard
|
||||
{
|
||||
use Macroable {
|
||||
__call as macroCall;
|
||||
}
|
||||
|
||||
/**
|
||||
* The browser instance.
|
||||
*
|
||||
* @var \Laravel\Dusk\Browser
|
||||
*/
|
||||
public $browser;
|
||||
|
||||
/**
|
||||
* Create a keyboard instance.
|
||||
*
|
||||
* @param \Laravel\Dusk\Browser $browser
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(Browser $browser)
|
||||
{
|
||||
$this->browser = $browser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Press the key using keyboard.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function press($key)
|
||||
{
|
||||
$this->pressKey($key);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the given pressed key.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function release($key)
|
||||
{
|
||||
$this->releaseKey($key);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type the given keys using keyboard.
|
||||
*
|
||||
* @param string|array<int, string> $keys
|
||||
* @return $this
|
||||
*/
|
||||
public function type($keys)
|
||||
{
|
||||
$this->sendKeys($keys);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause for the given amount of milliseconds.
|
||||
*
|
||||
* @param int $milliseconds
|
||||
* @return $this
|
||||
*/
|
||||
public function pause($milliseconds)
|
||||
{
|
||||
$this->browser->pause($milliseconds);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamically call a method on the keyboard.
|
||||
*
|
||||
* @param string $method
|
||||
* @param array $parameters
|
||||
* @return mixed
|
||||
*
|
||||
* @throws \BadMethodCallException
|
||||
*/
|
||||
public function __call($method, $parameters)
|
||||
{
|
||||
if (static::hasMacro($method)) {
|
||||
return $this->macroCall($method, $parameters);
|
||||
}
|
||||
|
||||
$keyboard = $this->browser->driver->getKeyboard();
|
||||
|
||||
if (method_exists($keyboard, $method)) {
|
||||
$response = $keyboard->{$method}(...$parameters);
|
||||
|
||||
if ($response === $keyboard) {
|
||||
return $this;
|
||||
} else {
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
||||
throw new BadMethodCallException("Call to undefined keyboard method [{$method}].");
|
||||
}
|
||||
}
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
namespace Laravel\Dusk;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class OperatingSystem
|
||||
{
|
||||
/**
|
||||
* List of available operating system platforms.
|
||||
*
|
||||
* @var array<string, array{slug: string, commands: array<int, string>}>
|
||||
*/
|
||||
protected static $platforms = [
|
||||
'linux' => [
|
||||
'slug' => 'linux64',
|
||||
'commands' => [
|
||||
'/usr/bin/google-chrome --version',
|
||||
'/usr/bin/chromium-browser --version',
|
||||
'/usr/bin/chromium --version',
|
||||
'/usr/bin/google-chrome-stable --version',
|
||||
],
|
||||
],
|
||||
'mac' => [
|
||||
'slug' => 'mac-x64',
|
||||
'commands' => [
|
||||
'/Applications/Google\ Chrome\ for\ Testing.app/Contents/MacOS/Google\ Chrome\ for\ Testing --version',
|
||||
'/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --version',
|
||||
],
|
||||
],
|
||||
'mac-intel' => [
|
||||
'slug' => 'mac-x64',
|
||||
'commands' => [
|
||||
'/Applications/Google\ Chrome\ for\ Testing.app/Contents/MacOS/Google\ Chrome\ for\ Testing --version',
|
||||
'/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --version',
|
||||
],
|
||||
],
|
||||
'mac-arm' => [
|
||||
'slug' => 'mac-arm64',
|
||||
'commands' => [
|
||||
'/Applications/Google\ Chrome\ for\ Testing.app/Contents/MacOS/Google\ Chrome\ for\ Testing --version',
|
||||
'/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --version',
|
||||
],
|
||||
],
|
||||
'win' => [
|
||||
'slug' => 'win32',
|
||||
'commands' => [
|
||||
'reg query "HKEY_CURRENT_USER\Software\Google\Chrome\BLBeacon" /v version',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* Resolve the Chrome version commands for the given operating system.
|
||||
*
|
||||
* @param string $operatingSystem
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function chromeVersionCommands($operatingSystem)
|
||||
{
|
||||
$commands = static::$platforms[$operatingSystem]['commands'] ?? null;
|
||||
|
||||
if (is_null($commands)) {
|
||||
throw new InvalidArgumentException("Unable to find commands for Operating System [{$operatingSystem}]");
|
||||
}
|
||||
|
||||
return $commands;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the ChromeDriver slug for the given operating system.
|
||||
*
|
||||
* @param string $operatingSystem
|
||||
* @param string|null $version
|
||||
* @return string
|
||||
*/
|
||||
public static function chromeDriverSlug($operatingSystem, $version = null)
|
||||
{
|
||||
$slug = static::$platforms[$operatingSystem]['slug'] ?? null;
|
||||
|
||||
if (is_null($slug)) {
|
||||
throw new InvalidArgumentException("Unable to find ChromeDriver slug for Operating System [{$operatingSystem}]");
|
||||
}
|
||||
|
||||
if (! is_null($version) && version_compare($version, '115.0', '<')) {
|
||||
if ($slug === 'mac-arm64') {
|
||||
return version_compare($version, '106.0.5249', '<') ? 'mac64_m1' : 'mac_arm64';
|
||||
} elseif ($slug === 'mac-x64') {
|
||||
return 'mac64';
|
||||
}
|
||||
}
|
||||
|
||||
return $slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all supported operating systems.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function all()
|
||||
{
|
||||
return array_keys(static::$platforms);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current operating system identifier.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function id()
|
||||
{
|
||||
if (static::onWindows()) {
|
||||
return 'win';
|
||||
} elseif (static::onMac()) {
|
||||
return static::macArchitectureId();
|
||||
}
|
||||
|
||||
return 'linux';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the operating system is Windows or Windows Subsystem for Linux.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function onWindows()
|
||||
{
|
||||
return PHP_OS === 'WINNT' || Str::contains(php_uname(), 'Microsoft');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the operating system is macOS.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function onMac()
|
||||
{
|
||||
return PHP_OS === 'Darwin';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current macOS platform architecture.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function macArchitectureId()
|
||||
{
|
||||
switch (php_uname('m')) {
|
||||
case 'arm64':
|
||||
return 'mac-arm';
|
||||
case 'x86_64':
|
||||
return 'mac-intel';
|
||||
default:
|
||||
return 'mac';
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+44
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace Laravel\Dusk;
|
||||
|
||||
abstract class Page
|
||||
{
|
||||
/**
|
||||
* Get the URL for the page.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
abstract public function url();
|
||||
|
||||
/**
|
||||
* Assert that the browser is on the page.
|
||||
*
|
||||
* @param \Laravel\Dusk\Browser $browser
|
||||
* @return void
|
||||
*/
|
||||
public function assert(Browser $browser)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the element shortcuts for the page.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function elements()
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the global element shortcuts for the site.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function siteElements()
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
Vendored
+100
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace Laravel\Dusk;
|
||||
|
||||
use Exception;
|
||||
use Facebook\WebDriver\Remote\DesiredCapabilities;
|
||||
use Facebook\WebDriver\Remote\RemoteWebDriver;
|
||||
use Illuminate\Foundation\Testing\TestCase as FoundationTestCase;
|
||||
use Laravel\Dusk\Chrome\SupportsChrome;
|
||||
use Laravel\Dusk\Concerns\ProvidesBrowser;
|
||||
|
||||
abstract class TestCase extends FoundationTestCase
|
||||
{
|
||||
use ProvidesBrowser, SupportsChrome;
|
||||
|
||||
/**
|
||||
* Register the base URL with Dusk.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Browser::$baseUrl = $this->baseUrl();
|
||||
|
||||
Browser::$storeScreenshotsAt = base_path('tests/Browser/screenshots');
|
||||
|
||||
Browser::$storeConsoleLogAt = base_path('tests/Browser/console');
|
||||
|
||||
Browser::$storeSourceAt = base_path('tests/Browser/source');
|
||||
|
||||
Browser::$userResolver = function () {
|
||||
return $this->user();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the RemoteWebDriver instance.
|
||||
*
|
||||
* @return \Facebook\WebDriver\Remote\RemoteWebDriver
|
||||
*/
|
||||
protected function driver()
|
||||
{
|
||||
return RemoteWebDriver::create(
|
||||
$_ENV['DUSK_DRIVER_URL'] ?? env('DUSK_DRIVER_URL') ?? 'http://localhost:9515',
|
||||
DesiredCapabilities::chrome()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the application's base URL.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function baseUrl()
|
||||
{
|
||||
return rtrim(config('app.url'), '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the default user to authenticate.
|
||||
*
|
||||
* @return \App\User|int|null
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
protected function user()
|
||||
{
|
||||
throw new Exception('User resolver has not been set.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the Dusk command has disabled headless mode.
|
||||
*/
|
||||
protected function hasHeadlessDisabled(): bool
|
||||
{
|
||||
return isset($_SERVER['DUSK_HEADLESS_DISABLED']) ||
|
||||
isset($_ENV['DUSK_HEADLESS_DISABLED']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the browser window should start maximized.
|
||||
*/
|
||||
protected function shouldStartMaximized(): bool
|
||||
{
|
||||
return isset($_SERVER['DUSK_START_MAXIMIZED']) ||
|
||||
isset($_ENV['DUSK_START_MAXIMIZED']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the tests are running within Laravel Sail.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected static function runningInSail()
|
||||
{
|
||||
return isset($_ENV['LARAVEL_SAIL']) && $_ENV['LARAVEL_SAIL'] == '1';
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use Facebook\WebDriver\Chrome\ChromeOptions;
|
||||
use Facebook\WebDriver\Remote\DesiredCapabilities;
|
||||
use Facebook\WebDriver\Remote\RemoteWebDriver;
|
||||
use Illuminate\Support\Collection;
|
||||
use Laravel\Dusk\TestCase as BaseTestCase;
|
||||
use PHPUnit\Framework\Attributes\BeforeClass;
|
||||
|
||||
abstract class DuskTestCase extends BaseTestCase
|
||||
{
|
||||
use CreatesApplication;
|
||||
|
||||
/**
|
||||
* Prepare for Dusk test execution.
|
||||
*/
|
||||
#[BeforeClass]
|
||||
public static function prepare(): void
|
||||
{
|
||||
if (! static::runningInSail()) {
|
||||
static::startChromeDriver(['--port=9515']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the RemoteWebDriver instance.
|
||||
*/
|
||||
protected function driver(): RemoteWebDriver
|
||||
{
|
||||
$options = (new ChromeOptions)->addArguments(collect([
|
||||
$this->shouldStartMaximized() ? '--start-maximized' : '--window-size=1920,1080',
|
||||
'--disable-search-engine-choice-screen',
|
||||
'--disable-smooth-scrolling',
|
||||
])->unless($this->hasHeadlessDisabled(), function (Collection $items) {
|
||||
return $items->merge([
|
||||
'--disable-gpu',
|
||||
'--headless=new',
|
||||
]);
|
||||
})->all());
|
||||
|
||||
return RemoteWebDriver::create(
|
||||
$_ENV['DUSK_DRIVER_URL'] ?? env('DUSK_DRIVER_URL') ?? 'http://localhost:9515',
|
||||
DesiredCapabilities::chrome()->setCapability(
|
||||
ChromeOptions::CAPABILITY, $options
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
use Laravel\Dusk\Browser;
|
||||
|
||||
test('basic example', function () {
|
||||
$this->browse(function (Browser $browser) {
|
||||
$browser->visit('/')
|
||||
->assertSee('Laravel');
|
||||
});
|
||||
});
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Browser;
|
||||
|
||||
use Illuminate\Foundation\Testing\DatabaseMigrations;
|
||||
use Laravel\Dusk\Browser;
|
||||
use Tests\DuskTestCase;
|
||||
|
||||
class ExampleTest extends DuskTestCase
|
||||
{
|
||||
/**
|
||||
* A basic browser test example.
|
||||
*/
|
||||
public function test_basic_example(): void
|
||||
{
|
||||
$this->browse(function (Browser $browser) {
|
||||
$browser->visit('/')
|
||||
->assertSee('Laravel');
|
||||
});
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Browser\Pages;
|
||||
|
||||
use Laravel\Dusk\Browser;
|
||||
|
||||
class HomePage extends Page
|
||||
{
|
||||
/**
|
||||
* Get the URL for the page.
|
||||
*/
|
||||
public function url(): string
|
||||
{
|
||||
return '/';
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the browser is on the page.
|
||||
*/
|
||||
public function assert(Browser $browser): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the element shortcuts for the page.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function elements(): array
|
||||
{
|
||||
return [
|
||||
'@element' => '#selector',
|
||||
];
|
||||
}
|
||||
}
|
||||
Vendored
+20
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Browser\Pages;
|
||||
|
||||
use Laravel\Dusk\Page as BasePage;
|
||||
|
||||
abstract class Page extends BasePage
|
||||
{
|
||||
/**
|
||||
* Get the global element shortcuts for the site.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function siteElements(): array
|
||||
{
|
||||
return [
|
||||
'@element' => '#selector',
|
||||
];
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit backupGlobals="false"
|
||||
beStrictAboutTestsThatDoNotTestAnything="false"
|
||||
colors="true"
|
||||
processIsolation="false"
|
||||
stopOnError="false"
|
||||
stopOnFailure="false"
|
||||
cacheDirectory=".phpunit.cache"
|
||||
backupStaticProperties="false">
|
||||
<testsuites>
|
||||
<testsuite name="Browser Test Suite">
|
||||
<directory suffix="Test.php">./tests/Browser</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
</phpunit>
|
||||
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<script type="text/javascript">
|
||||
function copy(e) {
|
||||
e.innerHTML = 'Copied!'
|
||||
|
||||
setTimeout(() => {
|
||||
e.innerHTML = 'Copy'
|
||||
}, 2000);
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<button dusk="copy-button" onclick="copy(this)">Copy</button>
|
||||
</body>
|
||||
</html>
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Web Routes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here is where you can register web routes for your application. These
|
||||
| routes are loaded by the RouteServiceProvider and all of them will
|
||||
| be assigned to the "web" middleware group. Make something great!
|
||||
|
|
||||
*/
|
||||
|
||||
Route::view('/', 'welcome');
|
||||
Route::view('tests/wait-for-text-in', 'workbench::wait-for-text-in');
|
||||
Reference in New Issue
Block a user