├── LICENSE.md ├── README.md ├── art └── logo.svg ├── bin └── jquery.js ├── composer.json ├── phpunit.dusk.xml.dist ├── src ├── Browser.php ├── Chrome │ ├── ChromeProcess.php │ └── SupportsChrome.php ├── Component.php ├── Concerns │ ├── InteractsWithAuthentication.php │ ├── InteractsWithCookies.php │ ├── InteractsWithElements.php │ ├── InteractsWithJavascript.php │ ├── InteractsWithKeyboard.php │ ├── InteractsWithMouse.php │ ├── MakesAssertions.php │ ├── MakesUrlAssertions.php │ ├── ProvidesBrowser.php │ └── WaitsForElements.php ├── Console │ ├── ChromeDriverCommand.php │ ├── ComponentCommand.php │ ├── Concerns │ │ └── InteractsWithTestingFrameworks.php │ ├── DuskCommand.php │ ├── DuskFailsCommand.php │ ├── InstallCommand.php │ ├── MakeCommand.php │ ├── PageCommand.php │ ├── PurgeCommand.php │ └── stubs │ │ ├── component.stub │ │ ├── page.stub │ │ ├── test.pest.stub │ │ └── test.stub ├── Dusk.php ├── DuskServiceProvider.php ├── ElementResolver.php ├── Http │ └── Controllers │ │ └── UserController.php ├── Keyboard.php ├── OperatingSystem.php ├── Page.php └── TestCase.php ├── stubs ├── DuskTestCase.stub ├── ExampleTest.pest.stub ├── ExampleTest.stub ├── HomePage.stub ├── Page.stub └── phpunit.xml └── workbench ├── resources └── views │ └── wait-for-text-in.blade.php └── routes └── web.php /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Taylor Otwell 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Logo Laravel Dusk

2 | 3 |

4 | Build Status 5 | Total Downloads 6 | Latest Stable Version 7 | License 8 |

9 | 10 | ## Introduction 11 | 12 | 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. 13 | 14 | ## Official Documentation 15 | 16 | Documentation for Dusk can be found on the [Laravel website](https://laravel.com/docs/dusk). 17 | 18 | ## Contributing 19 | 20 | Thank you for considering contributing to Dusk! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions). 21 | 22 | ## Code of Conduct 23 | 24 | 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). 25 | 26 | ## Security Vulnerabilities 27 | 28 | Please review [our security policy](https://github.com/laravel/dusk/security/policy) on how to report security vulnerabilities. 29 | 30 | ## License 31 | 32 | Laravel Dusk is open-sourced software licensed under the [MIT license](LICENSE.md). 33 | -------------------------------------------------------------------------------- /art/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "laravel/dusk", 3 | "description": "Laravel Dusk provides simple end-to-end testing and browser automation.", 4 | "keywords": [ 5 | "laravel", 6 | "testing", 7 | "webdriver" 8 | ], 9 | "license": "MIT", 10 | "authors": [ 11 | { 12 | "name": "Taylor Otwell", 13 | "email": "taylor@laravel.com" 14 | } 15 | ], 16 | "require": { 17 | "php": "^8.1", 18 | "ext-json": "*", 19 | "ext-zip": "*", 20 | "guzzlehttp/guzzle": "^7.5", 21 | "illuminate/console": "^10.0|^11.0|^12.0", 22 | "illuminate/support": "^10.0|^11.0|^12.0", 23 | "php-webdriver/webdriver": "^1.15.2", 24 | "symfony/console": "^6.2|^7.0", 25 | "symfony/finder": "^6.2|^7.0", 26 | "symfony/process": "^6.2|^7.0", 27 | "vlucas/phpdotenv": "^5.2" 28 | }, 29 | "require-dev": { 30 | "laravel/framework": "^10.0|^11.0|^12.0", 31 | "mockery/mockery": "^1.6", 32 | "orchestra/testbench-core": "^8.19|^9.0|^10.0", 33 | "phpstan/phpstan": "^1.10", 34 | "phpunit/phpunit": "^10.1|^11.0|^12.0.1", 35 | "psy/psysh": "^0.11.12|^0.12", 36 | "symfony/yaml": "^6.2|^7.0" 37 | }, 38 | "suggest": { 39 | "ext-pcntl": "Used to gracefully terminate Dusk when tests are running." 40 | }, 41 | "autoload": { 42 | "psr-4": { 43 | "Laravel\\Dusk\\": "src/" 44 | } 45 | }, 46 | "autoload-dev": { 47 | "psr-4": { 48 | "Laravel\\Dusk\\Tests\\": "tests/" 49 | } 50 | }, 51 | "extra": { 52 | "laravel": { 53 | "providers": [ 54 | "Laravel\\Dusk\\DuskServiceProvider" 55 | ] 56 | } 57 | }, 58 | "config": { 59 | "sort-packages": true 60 | }, 61 | "scripts": { 62 | "post-autoload-dump": [ 63 | "@clear", 64 | "@prepare" 65 | ], 66 | "clear": "@php vendor/bin/testbench package:purge-skeleton --ansi", 67 | "prepare": "@php vendor/bin/testbench package:discover --ansi", 68 | "build": "@php vendor/bin/testbench workbench:build --ansi", 69 | "serve": [ 70 | "@build", 71 | "@php vendor/bin/testbench serve" 72 | ], 73 | "lint": [ 74 | "@php vendor/bin/phpstan analyse" 75 | ], 76 | "test": [ 77 | "@build", 78 | "@php vendor/bin/phpunit" 79 | ] 80 | }, 81 | "minimum-stability": "dev", 82 | "prefer-stable": true 83 | } 84 | -------------------------------------------------------------------------------- /phpunit.dusk.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 12 | ./tests/Browser 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/Browser.php: -------------------------------------------------------------------------------- 1 | [ 49 | 'width' => 360, 50 | 'height' => 640, 51 | ], 52 | 'sm' => [ 53 | 'width' => 640, 54 | 'height' => 360, 55 | ], 56 | 'md' => [ 57 | 'width' => 768, 58 | 'height' => 1024, 59 | ], 60 | 'lg' => [ 61 | 'width' => 1024, 62 | 'height' => 768, 63 | ], 64 | 'xl' => [ 65 | 'width' => 1280, 66 | 'height' => 1024, 67 | ], 68 | '2xl' => [ 69 | 'width' => 1536, 70 | 'height' => 864, 71 | ], 72 | ]; 73 | 74 | /** 75 | * The directory that will contain any console logs. 76 | * 77 | * @var string 78 | */ 79 | public static $storeConsoleLogAt; 80 | 81 | /** 82 | * The directory where source code snapshots will be stored. 83 | * 84 | * @var string 85 | */ 86 | public static $storeSourceAt; 87 | 88 | /** 89 | * The browsers that support retrieving logs. 90 | * 91 | * @var array 92 | */ 93 | public static $supportsRemoteLogs = [ 94 | WebDriverBrowserType::CHROME, 95 | WebDriverBrowserType::PHANTOMJS, 96 | ]; 97 | 98 | /** 99 | * Get the callback which resolves the default user to authenticate. 100 | * 101 | * @var \Closure 102 | */ 103 | public static $userResolver; 104 | 105 | /** 106 | * The default wait time in seconds. 107 | * 108 | * @var int 109 | */ 110 | public static $waitSeconds = 5; 111 | 112 | /** 113 | * The RemoteWebDriver instance. 114 | * 115 | * @var \Facebook\WebDriver\Remote\RemoteWebDriver 116 | */ 117 | public $driver; 118 | 119 | /** 120 | * The element resolver instance. 121 | * 122 | * @var \Laravel\Dusk\ElementResolver 123 | */ 124 | public $resolver; 125 | 126 | /** 127 | * The page object currently being viewed. 128 | * 129 | * @var mixed 130 | */ 131 | public $page; 132 | 133 | /** 134 | * The component object currently being viewed. 135 | * 136 | * @var mixed 137 | */ 138 | public $component; 139 | 140 | /** 141 | * Indicates that the browser should be resized to fit the entire "body" before screenshotting failures. 142 | * 143 | * @var bool 144 | */ 145 | public $fitOnFailure = true; 146 | 147 | /** 148 | * Create a browser instance. 149 | * 150 | * @param \Facebook\WebDriver\Remote\RemoteWebDriver $driver 151 | * @param \Laravel\Dusk\ElementResolver|null $resolver 152 | * @return void 153 | */ 154 | public function __construct($driver, $resolver = null) 155 | { 156 | $this->driver = $driver; 157 | 158 | $this->resolver = $resolver ?: new ElementResolver($driver); 159 | } 160 | 161 | /** 162 | * Browse to the given URL. 163 | * 164 | * @param string|Page $url 165 | * @return $this 166 | */ 167 | public function visit($url) 168 | { 169 | // First, if the URL is an object it means we are actually dealing with a page 170 | // and we need to create this page then get the URL from the page object as 171 | // it contains the URL. Once that is done, we will be ready to format it. 172 | if (is_object($url)) { 173 | $page = $url; 174 | 175 | $url = $page->url(); 176 | } 177 | 178 | // If the URL does not start with http or https, then we will prepend the base 179 | // URL onto the URL and navigate to the URL. This will actually navigate to 180 | // the URL in the browser. Then we will be ready to make assertions, etc. 181 | if (! Str::startsWith($url, ['http://', 'https://'])) { 182 | $url = static::$baseUrl.'/'.ltrim($url, '/'); 183 | } 184 | 185 | $this->driver->navigate()->to($url); 186 | 187 | // If the page variable was set, we will call the "on" method which will set a 188 | // page instance variable and call an assert method on the page so that the 189 | // page can have the chance to verify that we are within the right pages. 190 | if (isset($page)) { 191 | $this->on($page); 192 | } 193 | 194 | return $this; 195 | } 196 | 197 | /** 198 | * Browse to the given route. 199 | * 200 | * @param string $route 201 | * @param array $parameters 202 | * @return $this 203 | */ 204 | public function visitRoute($route, $parameters = []) 205 | { 206 | return $this->visit(route($route, $parameters)); 207 | } 208 | 209 | /** 210 | * Browse to the "about:blank" page. 211 | * 212 | * @return $this 213 | */ 214 | public function blank() 215 | { 216 | $this->driver->navigate()->to('about:blank'); 217 | 218 | return $this; 219 | } 220 | 221 | /** 222 | * Set the current page object. 223 | * 224 | * @param mixed $page 225 | * @return $this 226 | */ 227 | public function on($page) 228 | { 229 | $this->onWithoutAssert($page); 230 | 231 | $page->assert($this); 232 | 233 | return $this; 234 | } 235 | 236 | /** 237 | * Set the current page object without executing the assertions. 238 | * 239 | * @param mixed $page 240 | * @return $this 241 | */ 242 | public function onWithoutAssert($page) 243 | { 244 | $this->page = $page; 245 | 246 | // Here we will set the page elements on the resolver instance, which will allow 247 | // the developer to access short-cuts for CSS selectors on the page which can 248 | // allow for more expressive navigation and interaction with all the pages. 249 | $this->resolver->pageElements(array_merge( 250 | $page::siteElements(), $page->elements() 251 | )); 252 | 253 | return $this; 254 | } 255 | 256 | /** 257 | * Refresh the page. 258 | * 259 | * @return $this 260 | */ 261 | public function refresh() 262 | { 263 | $this->driver->navigate()->refresh(); 264 | 265 | return $this; 266 | } 267 | 268 | /** 269 | * Navigate to the previous page. 270 | * 271 | * @return $this 272 | */ 273 | public function back() 274 | { 275 | $this->driver->navigate()->back(); 276 | 277 | return $this; 278 | } 279 | 280 | /** 281 | * Navigate to the next page. 282 | * 283 | * @return $this 284 | */ 285 | public function forward() 286 | { 287 | $this->driver->navigate()->forward(); 288 | 289 | return $this; 290 | } 291 | 292 | /** 293 | * Maximize the browser window. 294 | * 295 | * @return $this 296 | */ 297 | public function maximize() 298 | { 299 | $this->driver->manage()->window()->maximize(); 300 | 301 | return $this; 302 | } 303 | 304 | /** 305 | * Resize the browser window. 306 | * 307 | * @param int $width 308 | * @param int $height 309 | * @return $this 310 | */ 311 | public function resize($width, $height) 312 | { 313 | $this->driver->manage()->window()->setSize( 314 | new WebDriverDimension($width, $height) 315 | ); 316 | 317 | return $this; 318 | } 319 | 320 | /** 321 | * Make the browser window as large as the content. 322 | * 323 | * @return $this 324 | */ 325 | public function fitContent() 326 | { 327 | $this->driver->switchTo()->defaultContent(); 328 | 329 | $html = $this->driver->findElement(WebDriverBy::tagName('html')); 330 | 331 | if (! empty($html) && $html->getSize()->getWidth() > 0 && $html->getSize()->getHeight() > 0) { 332 | $this->resize($html->getSize()->getWidth(), $html->getSize()->getHeight()); 333 | } 334 | 335 | return $this; 336 | } 337 | 338 | /** 339 | * Disable fit on failures. 340 | * 341 | * @return $this 342 | */ 343 | public function disableFitOnFailure() 344 | { 345 | $this->fitOnFailure = false; 346 | 347 | return $this; 348 | } 349 | 350 | /** 351 | * Enable fit on failures. 352 | * 353 | * @return $this 354 | */ 355 | public function enableFitOnFailure() 356 | { 357 | $this->fitOnFailure = true; 358 | 359 | return $this; 360 | } 361 | 362 | /** 363 | * Move the browser window. 364 | * 365 | * @param int $x 366 | * @param int $y 367 | * @return $this 368 | */ 369 | public function move($x, $y) 370 | { 371 | $this->driver->manage()->window()->setPosition( 372 | new WebDriverPoint($x, $y) 373 | ); 374 | 375 | return $this; 376 | } 377 | 378 | /** 379 | * Scroll element into view at the given selector. 380 | * 381 | * @param string $selector 382 | * @return $this 383 | */ 384 | public function scrollIntoView($selector) 385 | { 386 | $selector = addslashes($this->resolver->format($selector)); 387 | 388 | $this->driver->executeScript("document.querySelector(\"$selector\").scrollIntoView();"); 389 | 390 | return $this; 391 | } 392 | 393 | /** 394 | * Scroll screen to element at the given selector. 395 | * 396 | * @param string $selector 397 | * @return $this 398 | */ 399 | public function scrollTo($selector) 400 | { 401 | $this->ensurejQueryIsAvailable(); 402 | 403 | $selector = addslashes($this->resolver->format($selector)); 404 | 405 | $this->driver->executeScript("jQuery(\"html, body\").animate({scrollTop: jQuery(\"$selector\").offset().top}, 0);"); 406 | 407 | return $this; 408 | } 409 | 410 | /** 411 | * Take a screenshot and store it with the given name. 412 | * 413 | * @param string $name 414 | * @return $this 415 | */ 416 | public function screenshot($name) 417 | { 418 | $filePath = sprintf('%s/%s.png', rtrim(static::$storeScreenshotsAt, '/'), $name); 419 | 420 | $directoryPath = dirname($filePath); 421 | 422 | if (! is_dir($directoryPath)) { 423 | mkdir($directoryPath, 0777, true); 424 | } 425 | 426 | $this->driver->takeScreenshot($filePath); 427 | 428 | return $this; 429 | } 430 | 431 | /** 432 | * Take a series of screenshots at different browser sizes to emulate different devices. 433 | * 434 | * @param string $name 435 | * @return $this 436 | */ 437 | public function responsiveScreenshots($name) 438 | { 439 | if (substr($name, -1) !== '/') { 440 | $name .= '-'; 441 | } 442 | 443 | foreach (static::$responsiveScreenSizes as $device => $size) { 444 | $this->resize($size['width'], $size['height']) 445 | ->screenshot("$name$device"); 446 | } 447 | 448 | return $this; 449 | } 450 | 451 | /** 452 | * Take a screenshot of a specific element and store it with the given name. 453 | * 454 | * @param string $selector 455 | * @param string $name 456 | * @return $this 457 | */ 458 | public function screenshotElement($selector, $name) 459 | { 460 | $filePath = sprintf('%s/%s.png', rtrim(static::$storeScreenshotsAt, '/'), $name); 461 | 462 | $directoryPath = dirname($filePath); 463 | 464 | if (! is_dir($directoryPath)) { 465 | mkdir($directoryPath, 0777, true); 466 | } 467 | 468 | $this->scrollIntoView($selector) 469 | ->driver->findElement(WebDriverBy::cssSelector($this->resolver->format($selector))) 470 | ->takeElementScreenshot($filePath); 471 | 472 | return $this; 473 | } 474 | 475 | /** 476 | * Store the console output with the given name. 477 | * 478 | * @param string $name 479 | * @return $this 480 | */ 481 | public function storeConsoleLog($name) 482 | { 483 | if (in_array($this->driver->getCapabilities()->getBrowserName(), static::$supportsRemoteLogs)) { 484 | $console = $this->driver->manage()->getLog('browser'); 485 | 486 | if (! empty($console)) { 487 | $filePath = sprintf('%s/%s.log', rtrim(static::$storeConsoleLogAt, '/'), $name); 488 | 489 | $directoryPath = dirname($filePath); 490 | 491 | if (! is_dir($directoryPath)) { 492 | mkdir($directoryPath, 0777, true); 493 | } 494 | 495 | file_put_contents( 496 | $filePath, json_encode($console, JSON_PRETTY_PRINT) 497 | ); 498 | } 499 | } 500 | 501 | return $this; 502 | } 503 | 504 | /** 505 | * Store a snapshot of the page's current source code with the given name. 506 | * 507 | * @param string $name 508 | * @return $this 509 | */ 510 | public function storeSource($name) 511 | { 512 | $source = $this->driver->getPageSource(); 513 | 514 | if (! empty($source)) { 515 | $filePath = sprintf('%s/%s.txt', rtrim(static::$storeSourceAt, '/'), $name); 516 | 517 | $directoryPath = dirname($filePath); 518 | 519 | if (! is_dir($directoryPath)) { 520 | mkdir($directoryPath, 0777, true); 521 | } 522 | 523 | file_put_contents($filePath, $source); 524 | } 525 | 526 | return $this; 527 | } 528 | 529 | /** 530 | * Switch to a specified frame in the browser and execute the given callback. 531 | * 532 | * @param string $selector 533 | * @param \Closure $callback 534 | * @return $this 535 | */ 536 | public function withinFrame($selector, Closure $callback) 537 | { 538 | $this->driver->switchTo()->frame($this->resolver->findOrFail($selector)); 539 | 540 | $callback($this); 541 | 542 | $this->driver->switchTo()->defaultContent(); 543 | 544 | return $this; 545 | } 546 | 547 | /** 548 | * Execute a Closure with a scoped browser instance. 549 | * 550 | * @param string|\Laravel\Dusk\Component $selector 551 | * @param \Closure $callback 552 | * @return $this 553 | */ 554 | public function within($selector, Closure $callback) 555 | { 556 | return $this->with($selector, $callback); 557 | } 558 | 559 | /** 560 | * Execute a Closure with a scoped browser instance. 561 | * 562 | * @param string|\Laravel\Dusk\Component $selector 563 | * @param \Closure $callback 564 | * @return $this 565 | */ 566 | public function with($selector, Closure $callback) 567 | { 568 | $browser = new static( 569 | $this->driver, new ElementResolver($this->driver, $this->resolver->format($selector)) 570 | ); 571 | 572 | if ($this->page) { 573 | $browser->onWithoutAssert($this->page); 574 | } 575 | 576 | if ($selector instanceof Component) { 577 | $browser->onComponent($selector, $this->resolver); 578 | } 579 | 580 | call_user_func($callback, $browser); 581 | 582 | return $this; 583 | } 584 | 585 | /** 586 | * Execute a Closure outside of the current browser scope. 587 | * 588 | * @param string|\Laravel\Dusk\Component $selector 589 | * @param \Closure $callback 590 | * @return $this 591 | */ 592 | public function elsewhere($selector, Closure $callback) 593 | { 594 | $browser = new static( 595 | $this->driver, new ElementResolver($this->driver, 'body '.$selector) 596 | ); 597 | 598 | if ($this->page) { 599 | $browser->onWithoutAssert($this->page); 600 | } 601 | 602 | if ($selector instanceof Component) { 603 | $browser->onComponent($selector, $this->resolver); 604 | } 605 | 606 | call_user_func($callback, $browser); 607 | 608 | return $this; 609 | } 610 | 611 | /** 612 | * Execute a Closure outside of the current browser scope when the selector is available. 613 | * 614 | * @param string $selector 615 | * @param \Closure $callback 616 | * @param int|null $seconds 617 | * @return $this 618 | */ 619 | public function elsewhereWhenAvailable($selector, Closure $callback, $seconds = null) 620 | { 621 | return $this->elsewhere('', function ($browser) use ($selector, $callback, $seconds) { 622 | $browser->whenAvailable($selector, $callback, $seconds); 623 | }); 624 | } 625 | 626 | /** 627 | * Return a browser scoped to the given component. 628 | * 629 | * @param \Laravel\Dusk\Component $component 630 | * @return \Laravel\Dusk\Browser 631 | */ 632 | public function component(Component $component) 633 | { 634 | $browser = new static( 635 | $this->driver, new ElementResolver($this->driver, $this->resolver->format($component)) 636 | ); 637 | 638 | if ($this->page) { 639 | $browser->onWithoutAssert($this->page); 640 | } 641 | 642 | $browser->onComponent($component, $this->resolver); 643 | 644 | return $browser; 645 | } 646 | 647 | /** 648 | * Set the current component state. 649 | * 650 | * @param \Laravel\Dusk\Component $component 651 | * @param \Laravel\Dusk\ElementResolver $parentResolver 652 | * @return void 653 | */ 654 | public function onComponent($component, $parentResolver) 655 | { 656 | $this->component = $component; 657 | 658 | // Here we will set the component elements on the resolver instance, which will allow 659 | // the developer to access short-cuts for CSS selectors on the component which can 660 | // allow for more expressive navigation and interaction with all the components. 661 | $this->resolver->pageElements( 662 | $component->elements() + $parentResolver->elements 663 | ); 664 | 665 | $component->assert($this); 666 | 667 | $this->resolver->prefix = $this->resolver->format( 668 | $component->selector() 669 | ); 670 | } 671 | 672 | /** 673 | * Ensure that jQuery is available on the page. 674 | * 675 | * @return void 676 | */ 677 | public function ensurejQueryIsAvailable() 678 | { 679 | if ($this->driver->executeScript('return window.jQuery == null')) { 680 | $this->driver->executeScript(file_get_contents(__DIR__.'/../bin/jquery.js')); 681 | } 682 | } 683 | 684 | /** 685 | * Pause for the given amount of milliseconds. 686 | * 687 | * @param int $milliseconds 688 | * @return $this 689 | */ 690 | public function pause($milliseconds) 691 | { 692 | usleep($milliseconds * 1000); 693 | 694 | return $this; 695 | } 696 | 697 | /** 698 | * Pause for the given amount of milliseconds if the given condition is true. 699 | * 700 | * @param bool $boolean 701 | * @param int $milliseconds 702 | * @return $this 703 | */ 704 | public function pauseIf($boolean, $milliseconds) 705 | { 706 | if ($boolean) { 707 | return $this->pause($milliseconds); 708 | } 709 | 710 | return $this; 711 | } 712 | 713 | /** 714 | * Pause for the given amount of milliseconds unless the given condition is true. 715 | * 716 | * @param bool $boolean 717 | * @param int $milliseconds 718 | * @return $this 719 | */ 720 | public function pauseUnless($boolean, $milliseconds) 721 | { 722 | if (! $boolean) { 723 | return $this->pause($milliseconds); 724 | } 725 | 726 | return $this; 727 | } 728 | 729 | /** 730 | * Close the browser. 731 | * 732 | * @return void 733 | */ 734 | public function quit() 735 | { 736 | $this->driver->quit(); 737 | } 738 | 739 | /** 740 | * Tap the browser into a callback. 741 | * 742 | * @param \Closure $callback 743 | * @return $this 744 | */ 745 | public function tap($callback) 746 | { 747 | $callback($this); 748 | 749 | return $this; 750 | } 751 | 752 | /** 753 | * Dump the content from the last response. 754 | * 755 | * @return $this 756 | */ 757 | public function dump() 758 | { 759 | dump($this->driver->getPageSource()); 760 | 761 | return $this; 762 | } 763 | 764 | /** 765 | * Dump and die the content from the last response. 766 | * 767 | * @return void 768 | */ 769 | public function dd() 770 | { 771 | dump($this->driver->getPageSource()); 772 | 773 | $this->quit(); 774 | 775 | exit; 776 | } 777 | 778 | /** 779 | * Pause execution of test and open Laravel Tinker (PsySH) REPL. 780 | * 781 | * @return $this 782 | */ 783 | public function tinker() 784 | { 785 | \Psy\debug([ 786 | 'browser' => $this, 787 | 'driver' => $this->driver, 788 | 'resolver' => $this->resolver, 789 | 'page' => $this->page, 790 | ], $this); 791 | 792 | return $this; 793 | } 794 | 795 | /** 796 | * Stop running tests but leave the browser open. 797 | * 798 | * @return void 799 | */ 800 | public function stop() 801 | { 802 | exit; 803 | } 804 | 805 | /** 806 | * Dynamically call a method on the browser. 807 | * 808 | * @param string $method 809 | * @param array $parameters 810 | * @return mixed 811 | * 812 | * @throws \BadMethodCallException 813 | */ 814 | public function __call($method, $parameters) 815 | { 816 | if (static::hasMacro($method)) { 817 | return $this->macroCall($method, $parameters); 818 | } 819 | 820 | if ($this->component && method_exists($this->component, $method)) { 821 | array_unshift($parameters, $this); 822 | 823 | $this->component->{$method}(...$parameters); 824 | 825 | return $this; 826 | } 827 | 828 | if ($this->page && method_exists($this->page, $method)) { 829 | array_unshift($parameters, $this); 830 | 831 | $this->page->{$method}(...$parameters); 832 | 833 | return $this; 834 | } 835 | 836 | throw new BadMethodCallException("Call to undefined method [{$method}]."); 837 | } 838 | } 839 | -------------------------------------------------------------------------------- /src/Chrome/ChromeProcess.php: -------------------------------------------------------------------------------- 1 | driver = $driver; 27 | } 28 | 29 | /** 30 | * Build the process to run Chromedriver. 31 | * 32 | * @param array $arguments 33 | * @return \Symfony\Component\Process\Process 34 | * 35 | * @throws \RuntimeException 36 | */ 37 | public function toProcess(array $arguments = []) 38 | { 39 | if ($this->driver) { 40 | $driver = $this->driver; 41 | } else { 42 | $filenames = [ 43 | 'linux' => 'chromedriver-linux', 44 | 'mac' => 'chromedriver-mac', 45 | 'mac-intel' => 'chromedriver-mac-intel', 46 | 'mac-arm' => 'chromedriver-mac-arm', 47 | 'win' => 'chromedriver-win.exe', 48 | ]; 49 | 50 | $driver = __DIR__.'/../../bin'.DIRECTORY_SEPARATOR.$filenames[$this->operatingSystemId()]; 51 | } 52 | 53 | $this->driver = realpath($driver); 54 | 55 | if ($this->driver === false) { 56 | throw new RuntimeException( 57 | "Invalid path to Chromedriver [{$driver}]. Make sure to install the Chromedriver first by running the dusk:chrome-driver command." 58 | ); 59 | } 60 | 61 | return $this->process($arguments); 62 | } 63 | 64 | /** 65 | * Build the Chromedriver with Symfony Process. 66 | * 67 | * @param array $arguments 68 | * @return \Symfony\Component\Process\Process 69 | */ 70 | protected function process(array $arguments = []) 71 | { 72 | return new Process( 73 | array_merge([$this->driver], $arguments), null, $this->chromeEnvironment() 74 | ); 75 | } 76 | 77 | /** 78 | * Get the Chromedriver environment variables. 79 | * 80 | * @return array 81 | */ 82 | protected function chromeEnvironment() 83 | { 84 | if ($this->onMac() || $this->onWindows()) { 85 | return []; 86 | } 87 | 88 | return ['DISPLAY' => $_ENV['DISPLAY'] ?? ':0']; 89 | } 90 | 91 | /** 92 | * Determine if Dusk is running on Windows or Windows Subsystem for Linux. 93 | * 94 | * @return bool 95 | */ 96 | protected function onWindows() 97 | { 98 | return OperatingSystem::onWindows(); 99 | } 100 | 101 | /** 102 | * Determine if Dusk is running on Mac. 103 | * 104 | * @return bool 105 | */ 106 | protected function onMac() 107 | { 108 | return OperatingSystem::onMac(); 109 | } 110 | 111 | /** 112 | * Determine OS ID. 113 | * 114 | * @return string 115 | */ 116 | protected function operatingSystemId() 117 | { 118 | return OperatingSystem::id(); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/Chrome/SupportsChrome.php: -------------------------------------------------------------------------------- 1 | start(); 34 | 35 | static::afterClass(function () { 36 | static::stopChromeDriver(); 37 | }); 38 | } 39 | 40 | /** 41 | * Stop the Chromedriver process. 42 | * 43 | * @return void 44 | */ 45 | public static function stopChromeDriver() 46 | { 47 | if (static::$chromeProcess) { 48 | static::$chromeProcess->stop(); 49 | } 50 | } 51 | 52 | /** 53 | * Build the process to run the Chromedriver. 54 | * 55 | * @param array $arguments 56 | * @return \Symfony\Component\Process\Process 57 | * 58 | * @throws \RuntimeException 59 | */ 60 | protected static function buildChromeProcess(array $arguments = []) 61 | { 62 | return (new ChromeProcess(static::$chromeDriver))->toProcess($arguments); 63 | } 64 | 65 | /** 66 | * Set the path to the custom Chromedriver. 67 | * 68 | * @param string $path 69 | * @return void 70 | */ 71 | public static function useChromedriver($path) 72 | { 73 | static::$chromeDriver = $path; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Component.php: -------------------------------------------------------------------------------- 1 | loginAs(call_user_func(Browser::$userResolver)); 18 | } 19 | 20 | /** 21 | * Log into the application using a given user ID or email. 22 | * 23 | * @param object|string $userId 24 | * @param string|null $guard 25 | * @return $this 26 | */ 27 | public function loginAs($userId, $guard = null) 28 | { 29 | $userId = is_object($userId) && method_exists($userId, 'getKey') ? $userId->getKey() : $userId; 30 | 31 | return $this->visit(rtrim(route('dusk.login', ['userId' => $userId, 'guard' => $guard], $this->shouldUseAbsoluteRouteForAuthentication()))); 32 | } 33 | 34 | /** 35 | * Log out of the application. 36 | * 37 | * @param string|null $guard 38 | * @return $this 39 | */ 40 | public function logout($guard = null) 41 | { 42 | return $this->visit(rtrim(route('dusk.logout', ['guard' => $guard], $this->shouldUseAbsoluteRouteForAuthentication()), '/')); 43 | } 44 | 45 | /** 46 | * Get the ID and the class name of the authenticated user. 47 | * 48 | * @param string|null $guard 49 | * @return array 50 | */ 51 | protected function currentUserInfo($guard = null) 52 | { 53 | $response = $this->visit(route('dusk.user', ['guard' => $guard], $this->shouldUseAbsoluteRouteForAuthentication())); 54 | 55 | return json_decode(strip_tags($response->driver->getPageSource()), true); 56 | } 57 | 58 | /** 59 | * Assert that the user is authenticated. 60 | * 61 | * @param string|null $guard 62 | * @return $this 63 | */ 64 | public function assertAuthenticated($guard = null) 65 | { 66 | $currentUrl = $this->driver->getCurrentURL(); 67 | 68 | PHPUnit::assertNotEmpty($this->currentUserInfo($guard), 'The user is not authenticated.'); 69 | 70 | return $this->visit($currentUrl); 71 | } 72 | 73 | /** 74 | * Assert that the user is not authenticated. 75 | * 76 | * @param string|null $guard 77 | * @return $this 78 | */ 79 | public function assertGuest($guard = null) 80 | { 81 | $currentUrl = $this->driver->getCurrentURL(); 82 | 83 | PHPUnit::assertEmpty( 84 | $this->currentUserInfo($guard), 'The user is unexpectedly authenticated.' 85 | ); 86 | 87 | return $this->visit($currentUrl); 88 | } 89 | 90 | /** 91 | * Assert that the user is authenticated as the given user. 92 | * 93 | * @param mixed $user 94 | * @param string|null $guard 95 | * @return $this 96 | */ 97 | public function assertAuthenticatedAs($user, $guard = null) 98 | { 99 | $currentUrl = $this->driver->getCurrentURL(); 100 | 101 | $expected = [ 102 | 'id' => $user->getAuthIdentifier(), 103 | 'className' => get_class($user), 104 | ]; 105 | 106 | PHPUnit::assertSame( 107 | $expected, $this->currentUserInfo($guard), 108 | 'The currently authenticated user is not who was expected.' 109 | ); 110 | 111 | return $this->visit($currentUrl); 112 | } 113 | 114 | /** 115 | * Determine if route() should use an absolute path. 116 | * 117 | * @return bool 118 | */ 119 | private function shouldUseAbsoluteRouteForAuthentication() 120 | { 121 | return config('dusk.domain') !== null; 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/Concerns/InteractsWithCookies.php: -------------------------------------------------------------------------------- 1 | addCookie($name, $value, $expiry, $options); 25 | } 26 | 27 | try { 28 | $cookie = $this->driver->manage()->getCookieNamed($name); 29 | } catch (NoSuchCookieException $e) { 30 | $cookie = null; 31 | } 32 | 33 | if ($cookie) { 34 | $decryptedValue = decrypt(rawurldecode($cookie['value']), $unserialize = false); 35 | 36 | $hasValuePrefix = strpos($decryptedValue, CookieValuePrefix::create($name, Crypt::getKey())) === 0; 37 | 38 | return $hasValuePrefix ? CookieValuePrefix::remove($decryptedValue) : $decryptedValue; 39 | } 40 | } 41 | 42 | /** 43 | * Get or set an unencrypted cookie's value. 44 | * 45 | * @param string $name 46 | * @param string|null $value 47 | * @param int|DateTimeInterface|null $expiry 48 | * @param array $options 49 | * @return $this|string|null 50 | */ 51 | public function plainCookie($name, $value = null, $expiry = null, array $options = []) 52 | { 53 | if (! is_null($value)) { 54 | return $this->addCookie($name, $value, $expiry, $options, false); 55 | } 56 | 57 | try { 58 | $cookie = $this->driver->manage()->getCookieNamed($name); 59 | } catch (NoSuchCookieException $e) { 60 | $cookie = null; 61 | } 62 | 63 | if ($cookie) { 64 | return rawurldecode($cookie['value']); 65 | } 66 | } 67 | 68 | /** 69 | * Add the given cookie. 70 | * 71 | * @param string $name 72 | * @param string $value 73 | * @param int|DateTimeInterface|null $expiry 74 | * @param array $options 75 | * @param bool $encrypt 76 | * @return $this 77 | */ 78 | public function addCookie($name, $value, $expiry = null, array $options = [], $encrypt = true) 79 | { 80 | if ($encrypt) { 81 | $prefix = CookieValuePrefix::create($name, Crypt::getKey()); 82 | 83 | $value = encrypt($prefix.$value, $serialize = false); 84 | } 85 | 86 | if ($expiry instanceof DateTimeInterface) { 87 | $expiry = $expiry->getTimestamp(); 88 | } 89 | 90 | $this->driver->manage()->addCookie( 91 | array_merge($options, compact('expiry', 'name', 'value')) 92 | ); 93 | 94 | return $this; 95 | } 96 | 97 | /** 98 | * Delete the given cookie. 99 | * 100 | * @param string $name 101 | * @return $this 102 | */ 103 | public function deleteCookie($name) 104 | { 105 | $this->driver->manage()->deleteCookieNamed($name); 106 | 107 | return $this; 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/Concerns/InteractsWithElements.php: -------------------------------------------------------------------------------- 1 | resolver->all($selector); 24 | } 25 | 26 | /** 27 | * Get the element matching the given selector. 28 | * 29 | * @param string $selector 30 | * @return \Facebook\WebDriver\Remote\RemoteWebElement|null 31 | */ 32 | public function element($selector) 33 | { 34 | return $this->resolver->find($selector); 35 | } 36 | 37 | /** 38 | * Click the link with the given text. 39 | * 40 | * @param string $link 41 | * @param string $element 42 | * @return $this 43 | */ 44 | public function clickLink($link, $element = 'a') 45 | { 46 | $this->ensurejQueryIsAvailable(); 47 | 48 | $selector = addslashes(trim($this->resolver->format("{$element}"))); 49 | 50 | $link = str_replace("'", "\\\\'", $link); 51 | 52 | $this->driver->executeScript("jQuery.find(`{$selector}:contains('{$link}'):visible`)[0].click();"); 53 | 54 | return $this; 55 | } 56 | 57 | /** 58 | * Directly get or set the value attribute of an input field. 59 | * 60 | * @param string $selector 61 | * @param string|null $value 62 | * @return $this 63 | */ 64 | public function value($selector, $value = null) 65 | { 66 | if (is_null($value)) { 67 | return $this->resolver->findOrFail($selector)->getAttribute('value'); 68 | } 69 | 70 | $selector = $this->resolver->format($selector); 71 | 72 | $this->driver->executeScript( 73 | 'document.querySelector('.json_encode($selector).').value = '.json_encode($value).';' 74 | ); 75 | 76 | return $this; 77 | } 78 | 79 | /** 80 | * Get the text of the element matching the given selector. 81 | * 82 | * @param string $selector 83 | * @return string 84 | */ 85 | public function text($selector) 86 | { 87 | return $this->resolver->findOrFail($selector)->getText(); 88 | } 89 | 90 | /** 91 | * Get the given attribute from the element matching the given selector. 92 | * 93 | * @param string $selector 94 | * @param string $attribute 95 | * @return string 96 | */ 97 | public function attribute($selector, $attribute) 98 | { 99 | return $this->resolver->findOrFail($selector)->getAttribute($attribute); 100 | } 101 | 102 | /** 103 | * Send the given keys to the element matching the given selector. 104 | * 105 | * @param string $selector 106 | * @param mixed $keys 107 | * @return $this 108 | */ 109 | public function keys($selector, ...$keys) 110 | { 111 | $this->resolver->findOrFail($selector)->sendKeys($this->parseKeys($keys)); 112 | 113 | return $this; 114 | } 115 | 116 | /** 117 | * Type the given value in the given field. 118 | * 119 | * @param string $field 120 | * @param string $value 121 | * @return $this 122 | */ 123 | public function type($field, $value) 124 | { 125 | $this->resolver->resolveForTyping($field)->clear()->sendKeys($value); 126 | 127 | return $this; 128 | } 129 | 130 | /** 131 | * Type the given value in the given field slowly. 132 | * 133 | * @param string $field 134 | * @param string $value 135 | * @param int $pause 136 | * @return $this 137 | */ 138 | public function typeSlowly($field, $value, $pause = 100) 139 | { 140 | $this->clear($field)->appendSlowly($field, $value, $pause); 141 | 142 | return $this; 143 | } 144 | 145 | /** 146 | * Type the given value in the given field without clearing it. 147 | * 148 | * @param string $field 149 | * @param string $value 150 | * @return $this 151 | */ 152 | public function append($field, $value) 153 | { 154 | $this->resolver->resolveForTyping($field)->sendKeys($value); 155 | 156 | return $this; 157 | } 158 | 159 | /** 160 | * Type the given value in the given field slowly without clearing it. 161 | * 162 | * @param string $field 163 | * @param string $value 164 | * @param int $pause 165 | * @return $this 166 | */ 167 | public function appendSlowly($field, $value, $pause = 100) 168 | { 169 | $characters = preg_split('//u', $value, -1, PREG_SPLIT_NO_EMPTY); 170 | 171 | if (is_array($characters)) { 172 | foreach ($characters as $character) { 173 | $this->append($field, $character)->pause($pause); 174 | } 175 | } 176 | 177 | return $this; 178 | } 179 | 180 | /** 181 | * Clear the given field. 182 | * 183 | * @param string $field 184 | * @return $this 185 | */ 186 | public function clear($field) 187 | { 188 | $this->resolver->resolveForTyping($field)->clear(); 189 | 190 | return $this; 191 | } 192 | 193 | /** 194 | * Select the given value or random value of a drop-down field. 195 | * 196 | * @param string $field 197 | * @param string|array|null $value 198 | * @return $this 199 | */ 200 | public function select($field, $value = null) 201 | { 202 | $element = $this->resolver->resolveForSelection($field); 203 | 204 | $options = $element->findElements(WebDriverBy::cssSelector('option:not([disabled])')); 205 | 206 | $select = $element->getTagName() === 'select' ? new WebDriverSelect($element) : null; 207 | 208 | $isMultiple = false; 209 | 210 | if (! is_null($select)) { 211 | if ($isMultiple = $select->isMultiple()) { 212 | $select->deselectAll(); 213 | } 214 | } 215 | 216 | if (func_num_args() === 1) { 217 | $options[array_rand($options)]->click(); 218 | } else { 219 | $value = collect(Arr::wrap($value))->transform(function ($value) { 220 | if (is_bool($value)) { 221 | return $value ? '1' : '0'; 222 | } 223 | 224 | return (string) $value; 225 | })->all(); 226 | 227 | foreach ($options as $option) { 228 | if (in_array((string) $option->getAttribute('value'), $value)) { 229 | $option->click(); 230 | 231 | if (! $isMultiple) { 232 | break; 233 | } 234 | } 235 | } 236 | } 237 | 238 | return $this; 239 | } 240 | 241 | /** 242 | * Select the given value of a radio button field. 243 | * 244 | * @param string $field 245 | * @param string $value 246 | * @return $this 247 | */ 248 | public function radio($field, $value) 249 | { 250 | $this->resolver->resolveForRadioSelection($field, $value)->click(); 251 | 252 | return $this; 253 | } 254 | 255 | /** 256 | * Check the given checkbox. 257 | * 258 | * @param string $field 259 | * @param string|null $value 260 | * @return $this 261 | */ 262 | public function check($field, $value = null) 263 | { 264 | $element = $this->resolver->resolveForChecking($field, $value); 265 | 266 | if (! $element->isSelected()) { 267 | $element->click(); 268 | } 269 | 270 | return $this; 271 | } 272 | 273 | /** 274 | * Uncheck the given checkbox. 275 | * 276 | * @param string $field 277 | * @param string|null $value 278 | * @return $this 279 | */ 280 | public function uncheck($field, $value = null) 281 | { 282 | $element = $this->resolver->resolveForChecking($field, $value); 283 | 284 | if ($element->isSelected()) { 285 | $element->click(); 286 | } 287 | 288 | return $this; 289 | } 290 | 291 | /** 292 | * Attach the given file to the field. 293 | * 294 | * @param string $field 295 | * @param string $path 296 | * @return $this 297 | */ 298 | public function attach($field, $path) 299 | { 300 | $element = $this->resolver->resolveForAttachment($field); 301 | 302 | $element->setFileDetector(new LocalFileDetector)->sendKeys($path); 303 | 304 | return $this; 305 | } 306 | 307 | /** 308 | * Press the button with the given text or name. 309 | * 310 | * @param string $button 311 | * @return $this 312 | */ 313 | public function press($button) 314 | { 315 | $this->resolver->resolveForButtonPress($button)->click(); 316 | 317 | return $this; 318 | } 319 | 320 | /** 321 | * Press the button with the given text or name. 322 | * 323 | * @param string $button 324 | * @param int $seconds 325 | * @return $this 326 | */ 327 | public function pressAndWaitFor($button, $seconds = 5) 328 | { 329 | $element = $this->resolver->resolveForButtonPress($button); 330 | 331 | $element->click(); 332 | 333 | return $this->waitUsing($seconds, 100, function () use ($element) { 334 | return $element->isEnabled(); 335 | }); 336 | } 337 | 338 | /** 339 | * Drag an element to another element using selectors. 340 | * 341 | * @param string $from 342 | * @param string $to 343 | * @return $this 344 | */ 345 | public function drag($from, $to) 346 | { 347 | (new WebDriverActions($this->driver))->dragAndDrop( 348 | $this->resolver->findOrFail($from), $this->resolver->findOrFail($to) 349 | )->perform(); 350 | 351 | return $this; 352 | } 353 | 354 | /** 355 | * Drag an element up. 356 | * 357 | * @param string $selector 358 | * @param int $offset 359 | * @return $this 360 | */ 361 | public function dragUp($selector, $offset) 362 | { 363 | return $this->dragOffset($selector, 0, -$offset); 364 | } 365 | 366 | /** 367 | * Drag an element down. 368 | * 369 | * @param string $selector 370 | * @param int $offset 371 | * @return $this 372 | */ 373 | public function dragDown($selector, $offset) 374 | { 375 | return $this->dragOffset($selector, 0, $offset); 376 | } 377 | 378 | /** 379 | * Drag an element to the left. 380 | * 381 | * @param string $selector 382 | * @param int $offset 383 | * @return $this 384 | */ 385 | public function dragLeft($selector, $offset) 386 | { 387 | return $this->dragOffset($selector, -$offset, 0); 388 | } 389 | 390 | /** 391 | * Drag an element to the right. 392 | * 393 | * @param string $selector 394 | * @param int $offset 395 | * @return $this 396 | */ 397 | public function dragRight($selector, $offset) 398 | { 399 | return $this->dragOffset($selector, $offset, 0); 400 | } 401 | 402 | /** 403 | * Drag an element by the given offset. 404 | * 405 | * @param string $selector 406 | * @param int $x 407 | * @param int $y 408 | * @return $this 409 | */ 410 | public function dragOffset($selector, $x = 0, $y = 0) 411 | { 412 | (new WebDriverActions($this->driver))->dragAndDropBy( 413 | $this->resolver->findOrFail($selector), $x, $y 414 | )->perform(); 415 | 416 | return $this; 417 | } 418 | 419 | /** 420 | * Accept a JavaScript dialog. 421 | * 422 | * @return $this 423 | */ 424 | public function acceptDialog() 425 | { 426 | $this->driver->switchTo()->alert()->accept(); 427 | 428 | return $this; 429 | } 430 | 431 | /** 432 | * Type the given value in an open JavaScript prompt dialog. 433 | * 434 | * @param string $value 435 | * @return $this 436 | */ 437 | public function typeInDialog($value) 438 | { 439 | $this->driver->switchTo()->alert()->sendKeys($value); 440 | 441 | return $this; 442 | } 443 | 444 | /** 445 | * Dismiss a JavaScript dialog. 446 | * 447 | * @return $this 448 | */ 449 | public function dismissDialog() 450 | { 451 | $this->driver->switchTo()->alert()->dismiss(); 452 | 453 | return $this; 454 | } 455 | } 456 | -------------------------------------------------------------------------------- /src/Concerns/InteractsWithJavascript.php: -------------------------------------------------------------------------------- 1 | map(function ($script) { 16 | return $this->driver->executeScript($script); 17 | })->all(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Concerns/InteractsWithKeyboard.php: -------------------------------------------------------------------------------- 1 | $callback(new Keyboard($this))); 20 | } 21 | 22 | /** 23 | * Parse the keys before sending to the keyboard. 24 | * 25 | * @param array $keys 26 | * @return array 27 | */ 28 | protected function parseKeys($keys) 29 | { 30 | return collect($keys)->map(function ($key) { 31 | if (is_string($key) && Str::startsWith($key, '{') && Str::endsWith($key, '}')) { 32 | $key = constant(WebDriverKeys::class.'::'.strtoupper(trim($key, '{}'))); 33 | } 34 | 35 | if (is_array($key) && Str::startsWith($key[0], '{')) { 36 | $key[0] = constant(WebDriverKeys::class.'::'.strtoupper(trim($key[0], '{}'))); 37 | } 38 | 39 | return $key; 40 | })->all(); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Concerns/InteractsWithMouse.php: -------------------------------------------------------------------------------- 1 | driver))->moveByOffset( 25 | $xOffset, $yOffset 26 | )->perform(); 27 | 28 | return $this; 29 | } 30 | 31 | /** 32 | * Move the mouse over the given selector. 33 | * 34 | * @param string $selector 35 | * @return $this 36 | */ 37 | public function mouseover($selector) 38 | { 39 | $element = $this->resolver->findOrFail($selector); 40 | 41 | $this->driver->getMouse()->mouseMove($element->getCoordinates()); 42 | 43 | return $this; 44 | } 45 | 46 | /** 47 | * Click the element at the given selector. 48 | * 49 | * @param string|null $selector 50 | * @return $this 51 | */ 52 | public function click($selector = null) 53 | { 54 | if (is_null($selector)) { 55 | (new WebDriverActions($this->driver))->click()->perform(); 56 | 57 | return $this; 58 | } 59 | 60 | foreach ($this->resolver->all($selector) as $element) { 61 | try { 62 | $element->click(); 63 | 64 | return $this; 65 | } catch (ElementClickInterceptedException $e) { 66 | // 67 | } 68 | } 69 | 70 | throw $e ?? new NoSuchElementException("Unable to locate element with selector [{$selector}]."); 71 | } 72 | 73 | /** 74 | * Click the topmost element at the given pair of coordinates. 75 | * 76 | * @param int $x 77 | * @param int $y 78 | * @return $this 79 | */ 80 | public function clickAtPoint($x, $y) 81 | { 82 | $this->driver->executeScript("document.elementFromPoint({$x}, {$y}).click()"); 83 | 84 | return $this; 85 | } 86 | 87 | /** 88 | * Click the element at the given XPath expression. 89 | * 90 | * @param string $expression 91 | * @return $this 92 | */ 93 | public function clickAtXPath($expression) 94 | { 95 | $this->driver 96 | ->findElement(WebDriverBy::xpath($expression)) 97 | ->click(); 98 | 99 | return $this; 100 | } 101 | 102 | /** 103 | * Perform a mouse click and hold the mouse button down at the given selector. 104 | * 105 | * @param string|null $selector 106 | * @return $this 107 | */ 108 | public function clickAndHold($selector = null) 109 | { 110 | if (is_null($selector)) { 111 | (new WebDriverActions($this->driver))->clickAndHold()->perform(); 112 | } else { 113 | (new WebDriverActions($this->driver))->clickAndHold( 114 | $this->resolver->findOrFail($selector) 115 | )->perform(); 116 | } 117 | 118 | return $this; 119 | } 120 | 121 | /** 122 | * Double click the element at the given selector. 123 | * 124 | * @param string|null $selector 125 | * @return $this 126 | */ 127 | public function doubleClick($selector = null) 128 | { 129 | if (is_null($selector)) { 130 | (new WebDriverActions($this->driver))->doubleClick()->perform(); 131 | } else { 132 | (new WebDriverActions($this->driver))->doubleClick( 133 | $this->resolver->findOrFail($selector) 134 | )->perform(); 135 | } 136 | 137 | return $this; 138 | } 139 | 140 | /** 141 | * Right click the element at the given selector. 142 | * 143 | * @param string|null $selector 144 | * @return $this 145 | */ 146 | public function rightClick($selector = null) 147 | { 148 | if (is_null($selector)) { 149 | (new WebDriverActions($this->driver))->contextClick()->perform(); 150 | } else { 151 | (new WebDriverActions($this->driver))->contextClick( 152 | $this->resolver->findOrFail($selector) 153 | )->perform(); 154 | } 155 | 156 | return $this; 157 | } 158 | 159 | /** 160 | * Control click the element at the given selector. 161 | * 162 | * @param string|null $selector 163 | * @return $this 164 | */ 165 | public function controlClick($selector = null) 166 | { 167 | return $this->withKeyboard(function (Keyboard $keyboard) use ($selector) { 168 | $key = OperatingSystem::onMac() ? WebDriverKeys::META : WebDriverKeys::CONTROL; 169 | 170 | $keyboard->press($key); 171 | $this->click($selector); 172 | $keyboard->release($key); 173 | }); 174 | } 175 | 176 | /** 177 | * Release the currently clicked mouse button. 178 | * 179 | * @return $this 180 | */ 181 | public function releaseMouse() 182 | { 183 | (new WebDriverActions($this->driver))->release()->perform(); 184 | 185 | return $this; 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /src/Concerns/MakesAssertions.php: -------------------------------------------------------------------------------- 1 | driver->getTitle(), 29 | "Expected title [{$title}] does not equal actual title [{$this->driver->getTitle()}]." 30 | ); 31 | 32 | return $this; 33 | } 34 | 35 | /** 36 | * Assert that the page title contains the given text. 37 | * 38 | * @param string $title 39 | * @return $this 40 | */ 41 | public function assertTitleContains($title) 42 | { 43 | PHPUnit::assertTrue( 44 | Str::contains($this->driver->getTitle(), $title), 45 | "Did not see expected text [{$title}] within title [{$this->driver->getTitle()}]." 46 | ); 47 | 48 | return $this; 49 | } 50 | 51 | /** 52 | * Assert that the given encrypted cookie is present. 53 | * 54 | * @param string $name 55 | * @param bool $decrypt 56 | * @return $this 57 | */ 58 | public function assertHasCookie($name, $decrypt = true) 59 | { 60 | $cookie = $decrypt ? $this->cookie($name) : $this->plainCookie($name); 61 | 62 | PHPUnit::assertTrue( 63 | ! is_null($cookie), 64 | "Did not find expected cookie [{$name}]." 65 | ); 66 | 67 | return $this; 68 | } 69 | 70 | /** 71 | * Assert that the given unencrypted cookie is present. 72 | * 73 | * @param string $name 74 | * @return $this 75 | */ 76 | public function assertHasPlainCookie($name) 77 | { 78 | return $this->assertHasCookie($name, false); 79 | } 80 | 81 | /** 82 | * Assert that the given encrypted cookie is not present. 83 | * 84 | * @param string $name 85 | * @param bool $decrypt 86 | * @return $this 87 | */ 88 | public function assertCookieMissing($name, $decrypt = true) 89 | { 90 | $cookie = $decrypt ? $this->cookie($name) : $this->plainCookie($name); 91 | 92 | PHPUnit::assertTrue( 93 | is_null($cookie), 94 | "Found unexpected cookie [{$name}]." 95 | ); 96 | 97 | return $this; 98 | } 99 | 100 | /** 101 | * Assert that the given unencrypted cookie is not present. 102 | * 103 | * @param string $name 104 | * @return $this 105 | */ 106 | public function assertPlainCookieMissing($name) 107 | { 108 | return $this->assertCookieMissing($name, false); 109 | } 110 | 111 | /** 112 | * Assert that an encrypted cookie has a given value. 113 | * 114 | * @param string $name 115 | * @param string $value 116 | * @param bool $decrypt 117 | * @return $this 118 | */ 119 | public function assertCookieValue($name, $value, $decrypt = true) 120 | { 121 | $actual = $decrypt ? $this->cookie($name) : $this->plainCookie($name); 122 | 123 | PHPUnit::assertEquals( 124 | $value, 125 | $actual, 126 | "Cookie [{$name}] had value [{$actual}], but expected [{$value}]." 127 | ); 128 | 129 | return $this; 130 | } 131 | 132 | /** 133 | * Assert that an unencrypted cookie has a given value. 134 | * 135 | * @param string $name 136 | * @param string $value 137 | * @return $this 138 | */ 139 | public function assertPlainCookieValue($name, $value) 140 | { 141 | return $this->assertCookieValue($name, $value, false); 142 | } 143 | 144 | /** 145 | * Assert that the given text is present on the page. 146 | * 147 | * @param string $text 148 | * @param bool $ignoreCase 149 | * @return $this 150 | */ 151 | public function assertSee($text, $ignoreCase = false) 152 | { 153 | return $this->assertSeeIn('', $text, $ignoreCase); 154 | } 155 | 156 | /** 157 | * Assert that the given text is not present on the page. 158 | * 159 | * @param string $text 160 | * @param bool $ignoreCase 161 | * @return $this 162 | */ 163 | public function assertDontSee($text, $ignoreCase = false) 164 | { 165 | return $this->assertDontSeeIn('', $text, $ignoreCase); 166 | } 167 | 168 | /** 169 | * Assert that the given text is present within the selector. 170 | * 171 | * @param string $selector 172 | * @param string $text 173 | * @param bool $ignoreCase 174 | * @return $this 175 | */ 176 | public function assertSeeIn($selector, $text, $ignoreCase = false) 177 | { 178 | $fullSelector = $this->resolver->format($selector); 179 | 180 | $element = $this->resolver->findOrFail($selector); 181 | 182 | PHPUnit::assertTrue( 183 | Str::contains($element->getText(), $text, $ignoreCase), 184 | "Did not see expected text [{$text}] within element [{$fullSelector}]." 185 | ); 186 | 187 | return $this; 188 | } 189 | 190 | /** 191 | * Assert that the given text is not present within the selector. 192 | * 193 | * @param string $selector 194 | * @param string $text 195 | * @param bool $ignoreCase 196 | * @return $this 197 | */ 198 | public function assertDontSeeIn($selector, $text, $ignoreCase = false) 199 | { 200 | $fullSelector = $this->resolver->format($selector); 201 | 202 | $element = $this->resolver->findOrFail($selector); 203 | 204 | PHPUnit::assertFalse( 205 | Str::contains($element->getText(), $text, $ignoreCase), 206 | "Saw unexpected text [{$text}] within element [{$fullSelector}]." 207 | ); 208 | 209 | return $this; 210 | } 211 | 212 | /** 213 | * Assert that any text is present within the selector. 214 | * 215 | * @param string $selector 216 | * @return $this 217 | */ 218 | public function assertSeeAnythingIn($selector) 219 | { 220 | $fullSelector = $this->resolver->format($selector); 221 | 222 | $element = $this->resolver->findOrFail($selector); 223 | 224 | PHPUnit::assertTrue( 225 | $element->getText() !== '', 226 | "Saw unexpected text [''] within element [{$fullSelector}]." 227 | ); 228 | 229 | return $this; 230 | } 231 | 232 | /** 233 | * Assert that no text is present within the selector. 234 | * 235 | * @param string $selector 236 | * @return $this 237 | */ 238 | public function assertSeeNothingIn($selector) 239 | { 240 | $fullSelector = $this->resolver->format($selector); 241 | 242 | $element = $this->resolver->findOrFail($selector); 243 | 244 | PHPUnit::assertTrue( 245 | $element->getText() === '', 246 | "Did not see expected text [''] within element [{$fullSelector}]." 247 | ); 248 | 249 | return $this; 250 | } 251 | 252 | /** 253 | * Assert that a given element is present a given amount of times. 254 | * 255 | * @param string $selector 256 | * @param int $expected 257 | * @return $this 258 | */ 259 | public function assertCount($selector, $expected) 260 | { 261 | $fullSelector = $this->resolver->format($selector); 262 | 263 | PHPUnit::assertCount( 264 | $expected, 265 | $this->resolver->all($selector), 266 | "Expected element [{$fullSelector}] exactly {$expected} times." 267 | ); 268 | 269 | return $this; 270 | } 271 | 272 | /** 273 | * Assert that the given JavaScript expression evaluates to the given value. 274 | * 275 | * @param string $expression 276 | * @param mixed $expected 277 | * @return $this 278 | */ 279 | public function assertScript($expression, $expected = true) 280 | { 281 | $expression = Str::start($expression, 'return '); 282 | 283 | PHPUnit::assertEquals( 284 | $expected, 285 | $this->driver->executeScript($expression), 286 | "JavaScript expression [{$expression}] mismatched." 287 | ); 288 | 289 | return $this; 290 | } 291 | 292 | /** 293 | * Assert that the given source code is present on the page. 294 | * 295 | * @param string $code 296 | * @return $this 297 | */ 298 | public function assertSourceHas($code) 299 | { 300 | $this->madeSourceAssertion = true; 301 | 302 | PHPUnit::assertTrue( 303 | Str::contains($this->driver->getPageSource(), $code), 304 | "Did not find expected source code [{$code}]." 305 | ); 306 | 307 | return $this; 308 | } 309 | 310 | /** 311 | * Assert that the given source code is not present on the page. 312 | * 313 | * @param string $code 314 | * @return $this 315 | */ 316 | public function assertSourceMissing($code) 317 | { 318 | $this->madeSourceAssertion = true; 319 | 320 | PHPUnit::assertFalse( 321 | Str::contains($this->driver->getPageSource(), $code), 322 | "Found unexpected source code [{$code}]." 323 | ); 324 | 325 | return $this; 326 | } 327 | 328 | /** 329 | * Assert that the given link is present on the page. 330 | * 331 | * @param string $link 332 | * @return $this 333 | */ 334 | public function assertSeeLink($link) 335 | { 336 | if ($this->resolver->prefix) { 337 | $message = "Did not see expected link [{$link}] within [{$this->resolver->prefix}]."; 338 | } else { 339 | $message = "Did not see expected link [{$link}]."; 340 | } 341 | 342 | PHPUnit::assertTrue( 343 | $this->seeLink($link), 344 | $message 345 | ); 346 | 347 | return $this; 348 | } 349 | 350 | /** 351 | * Assert that the given link is not present on the page. 352 | * 353 | * @param string $link 354 | * @return $this 355 | */ 356 | public function assertDontSeeLink($link) 357 | { 358 | if ($this->resolver->prefix) { 359 | $message = "Saw unexpected link [{$link}] within [{$this->resolver->prefix}]."; 360 | } else { 361 | $message = "Saw unexpected link [{$link}]."; 362 | } 363 | 364 | PHPUnit::assertFalse( 365 | $this->seeLink($link), 366 | $message 367 | ); 368 | 369 | return $this; 370 | } 371 | 372 | /** 373 | * Determine if the given link is visible. 374 | * 375 | * @param string $link 376 | * @return bool 377 | */ 378 | public function seeLink($link) 379 | { 380 | $this->ensurejQueryIsAvailable(); 381 | 382 | $selector = addslashes(trim($this->resolver->format('a'))); 383 | 384 | $link = str_replace("'", "\\\\'", $link); 385 | 386 | $script = << 0 && jQuery(link).is(':visible'); 389 | JS; 390 | 391 | return $this->driver->executeScript($script); 392 | } 393 | 394 | /** 395 | * Assert that the given input field has the given value. 396 | * 397 | * @param string $field 398 | * @param string $value 399 | * @return $this 400 | */ 401 | public function assertInputValue($field, $value) 402 | { 403 | PHPUnit::assertEquals( 404 | $value, 405 | $this->inputValue($field), 406 | "Expected value [{$value}] for the [{$field}] input does not equal the actual value [{$this->inputValue($field)}]." 407 | ); 408 | 409 | return $this; 410 | } 411 | 412 | /** 413 | * Assert that the given input field does not have the given value. 414 | * 415 | * @param string $field 416 | * @param string $value 417 | * @return $this 418 | */ 419 | public function assertInputValueIsNot($field, $value) 420 | { 421 | PHPUnit::assertNotEquals( 422 | $value, 423 | $this->inputValue($field), 424 | "Value [{$value}] for the [{$field}] input should not equal the actual value." 425 | ); 426 | 427 | return $this; 428 | } 429 | 430 | /** 431 | * Get the value of the given input or text area field. 432 | * 433 | * @param string $field 434 | * @return string 435 | */ 436 | public function inputValue($field) 437 | { 438 | $element = $this->resolver->resolveForTyping($field); 439 | 440 | return in_array($element->getTagName(), ['input', 'textarea']) 441 | ? $element->getAttribute('value') 442 | : $element->getText(); 443 | } 444 | 445 | /** 446 | * Assert that the given input field is present. 447 | * 448 | * @param string $field 449 | * @return $this 450 | */ 451 | public function assertInputPresent($field) 452 | { 453 | $this->assertPresent( 454 | "input[name='{$field}'], textarea[name='{$field}'], select[name='{$field}']" 455 | ); 456 | 457 | return $this; 458 | } 459 | 460 | /** 461 | * Assert that the given input field is not visible. 462 | * 463 | * @param string $field 464 | * @return $this 465 | */ 466 | public function assertInputMissing($field) 467 | { 468 | $this->assertMissing( 469 | "input[name='{$field}'], textarea[name='{$field}'], select[name='{$field}']" 470 | ); 471 | 472 | return $this; 473 | } 474 | 475 | /** 476 | * Assert that the given checkbox is checked. 477 | * 478 | * @param string $field 479 | * @param string|null $value 480 | * @return $this 481 | */ 482 | public function assertChecked($field, $value = null) 483 | { 484 | $element = $this->resolver->resolveForChecking($field, $value); 485 | 486 | PHPUnit::assertTrue( 487 | $element->isSelected(), 488 | "Expected checkbox [{$field}] to be checked, but it wasn't." 489 | ); 490 | 491 | return $this; 492 | } 493 | 494 | /** 495 | * Assert that the given checkbox is not checked. 496 | * 497 | * @param string $field 498 | * @param string|null $value 499 | * @return $this 500 | */ 501 | public function assertNotChecked($field, $value = null) 502 | { 503 | $element = $this->resolver->resolveForChecking($field, $value); 504 | 505 | PHPUnit::assertFalse( 506 | $element->isSelected(), 507 | "Checkbox [{$field}] was unexpectedly checked." 508 | ); 509 | 510 | return $this; 511 | } 512 | 513 | /** 514 | * Assert that the given checkbox is in an indeterminate state. 515 | * 516 | * @param string $field 517 | * @param string|null $value 518 | * @return $this 519 | */ 520 | public function assertIndeterminate($field, $value = null) 521 | { 522 | $this->assertNotChecked($field, $value); 523 | 524 | PHPUnit::assertSame( 525 | 'true', 526 | $this->resolver->findOrFail($field)->getAttribute('indeterminate'), 527 | "Checkbox [{$field}] was not in indeterminate state." 528 | ); 529 | 530 | return $this; 531 | } 532 | 533 | /** 534 | * Assert that the given radio field is selected. 535 | * 536 | * @param string $field 537 | * @param string $value 538 | * @return $this 539 | */ 540 | public function assertRadioSelected($field, $value) 541 | { 542 | $element = $this->resolver->resolveForRadioSelection($field, $value); 543 | 544 | PHPUnit::assertTrue( 545 | $element->isSelected(), 546 | "Expected radio [{$field}] to be selected, but it wasn't." 547 | ); 548 | 549 | return $this; 550 | } 551 | 552 | /** 553 | * Assert that the given radio field is not selected. 554 | * 555 | * @param string $field 556 | * @param string|null $value 557 | * @return $this 558 | */ 559 | public function assertRadioNotSelected($field, $value = null) 560 | { 561 | $element = $this->resolver->resolveForRadioSelection($field, $value); 562 | 563 | PHPUnit::assertFalse( 564 | $element->isSelected(), 565 | "Radio [{$field}] was unexpectedly selected." 566 | ); 567 | 568 | return $this; 569 | } 570 | 571 | /** 572 | * Assert that the given dropdown has the given value selected. 573 | * 574 | * @param string $field 575 | * @param string $value 576 | * @return $this 577 | */ 578 | public function assertSelected($field, $value) 579 | { 580 | PHPUnit::assertTrue( 581 | $this->selected($field, $value), 582 | "Expected value [{$value}] to be selected for [{$field}], but it wasn't." 583 | ); 584 | 585 | return $this; 586 | } 587 | 588 | /** 589 | * Assert that the given dropdown does not have the given value selected. 590 | * 591 | * @param string $field 592 | * @param string $value 593 | * @return $this 594 | */ 595 | public function assertNotSelected($field, $value) 596 | { 597 | PHPUnit::assertFalse( 598 | $this->selected($field, $value), 599 | "Unexpected value [{$value}] selected for [{$field}]." 600 | ); 601 | 602 | return $this; 603 | } 604 | 605 | /** 606 | * Assert that the given array of values are available to be selected. 607 | * 608 | * @param string $field 609 | * @param array $values 610 | * @return $this 611 | */ 612 | public function assertSelectHasOptions($field, array $values) 613 | { 614 | $options = $this->resolver->resolveSelectOptions($field, $values); 615 | 616 | $options = collect($options)->unique(function (RemoteWebElement $option) { 617 | return $option->getAttribute('value'); 618 | })->all(); 619 | 620 | PHPUnit::assertCount( 621 | count($values), 622 | $options, 623 | 'Expected options ['.implode(',', $values)."] for selection field [{$field}] to be available." 624 | ); 625 | 626 | return $this; 627 | } 628 | 629 | /** 630 | * Assert that the given array of values are not available to be selected. 631 | * 632 | * @param string $field 633 | * @param array $values 634 | * @return $this 635 | */ 636 | public function assertSelectMissingOptions($field, array $values) 637 | { 638 | PHPUnit::assertCount( 639 | 0, 640 | $this->resolver->resolveSelectOptions($field, $values), 641 | 'Unexpected options ['.implode(',', $values)."] for selection field [{$field}]." 642 | ); 643 | 644 | return $this; 645 | } 646 | 647 | /** 648 | * Assert that the given value is available to be selected on the given field. 649 | * 650 | * @param string $field 651 | * @param string $value 652 | * @return $this 653 | */ 654 | public function assertSelectHasOption($field, $value) 655 | { 656 | return $this->assertSelectHasOptions($field, [$value]); 657 | } 658 | 659 | /** 660 | * Assert that the given value is not available to be selected. 661 | * 662 | * @param string $field 663 | * @param string $value 664 | * @return $this 665 | */ 666 | public function assertSelectMissingOption($field, $value) 667 | { 668 | return $this->assertSelectMissingOptions($field, [$value]); 669 | } 670 | 671 | /** 672 | * Determine if the given value is selected for the given select field. 673 | * 674 | * @param string $field 675 | * @param string $value 676 | * @return bool 677 | */ 678 | public function selected($field, $value) 679 | { 680 | $options = $this->resolver->resolveSelectOptions($field, (array) $value); 681 | 682 | return collect($options)->contains(function (RemoteWebElement $option) { 683 | return $option->isSelected(); 684 | }); 685 | } 686 | 687 | /** 688 | * Assert that the element matching the given selector has the given value. 689 | * 690 | * @param string $selector 691 | * @param string $value 692 | * @return $this 693 | */ 694 | public function assertValue($selector, $value) 695 | { 696 | $fullSelector = $this->resolver->format($selector); 697 | 698 | $this->ensureElementSupportsValueAttribute( 699 | $element = $this->resolver->findOrFail($selector), 700 | $fullSelector 701 | ); 702 | 703 | $actual = $element->getAttribute('value'); 704 | 705 | PHPUnit::assertEquals( 706 | $value, 707 | $actual, 708 | "Did not see expected value [{$value}] within element [{$fullSelector}]." 709 | ); 710 | 711 | return $this; 712 | } 713 | 714 | /** 715 | * Assert that the element matching the given selector does not have the given value. 716 | * 717 | * @param string $selector 718 | * @param string $value 719 | * @return $this 720 | */ 721 | public function assertValueIsNot($selector, $value) 722 | { 723 | $fullSelector = $this->resolver->format($selector); 724 | 725 | $this->ensureElementSupportsValueAttribute( 726 | $element = $this->resolver->findOrFail($selector), 727 | $fullSelector 728 | ); 729 | 730 | $actual = $element->getAttribute('value'); 731 | 732 | PHPUnit::assertNotEquals( 733 | $value, 734 | $actual, 735 | "Saw unexpected value [{$value}] within element [{$fullSelector}]." 736 | ); 737 | 738 | return $this; 739 | } 740 | 741 | /** 742 | * Ensure the given element supports the 'value' attribute. 743 | * 744 | * @param mixed $element 745 | * @param string $fullSelector 746 | * @return void 747 | */ 748 | public function ensureElementSupportsValueAttribute($element, $fullSelector) 749 | { 750 | PHPUnit::assertTrue(in_array($element->getTagName(), [ 751 | 'textarea', 752 | 'select', 753 | 'button', 754 | 'input', 755 | 'li', 756 | 'meter', 757 | 'option', 758 | 'param', 759 | 'progress', 760 | ]), "This assertion cannot be used with the element [{$fullSelector}]."); 761 | } 762 | 763 | /** 764 | * Assert that the element matching the given selector has the given value in the provided attribute. 765 | * 766 | * @param string $selector 767 | * @param string $attribute 768 | * @param string $value 769 | * @return $this 770 | */ 771 | public function assertAttribute($selector, $attribute, $value) 772 | { 773 | $fullSelector = $this->resolver->format($selector); 774 | 775 | $actual = $this->resolver->findOrFail($selector)->getAttribute($attribute); 776 | 777 | PHPUnit::assertNotNull( 778 | $actual, 779 | "Did not see expected attribute [{$attribute}] within element [{$fullSelector}]." 780 | ); 781 | 782 | PHPUnit::assertEquals( 783 | $value, 784 | $actual, 785 | "Expected '$attribute' attribute [{$value}] does not equal actual value [$actual]." 786 | ); 787 | 788 | return $this; 789 | } 790 | 791 | /** 792 | * Assert that the element matching the given selector is missing the provided attribute. 793 | * 794 | * @param string $selector 795 | * @param string $attribute 796 | * @return $this 797 | */ 798 | public function assertAttributeMissing($selector, $attribute) 799 | { 800 | $fullSelector = $this->resolver->format($selector); 801 | 802 | $actual = $this->resolver->findOrFail($selector)->getAttribute($attribute); 803 | 804 | PHPUnit::assertNull( 805 | $actual, 806 | "Saw unexpected attribute [{$attribute}] within element [{$fullSelector}]." 807 | ); 808 | 809 | return $this; 810 | } 811 | 812 | /** 813 | * Assert that the element matching the given selector contains the given value in the provided attribute. 814 | * 815 | * @param string $selector 816 | * @param string $attribute 817 | * @param string $value 818 | * @return $this 819 | */ 820 | public function assertAttributeContains($selector, $attribute, $value) 821 | { 822 | $fullSelector = $this->resolver->format($selector); 823 | 824 | $actual = $this->resolver->findOrFail($selector)->getAttribute($attribute); 825 | 826 | PHPUnit::assertNotNull( 827 | $actual, 828 | "Did not see expected attribute [{$attribute}] within element [{$fullSelector}]." 829 | ); 830 | 831 | PHPUnit::assertStringContainsString( 832 | $value, 833 | $actual, 834 | "Attribute '$attribute' does not contain [{$value}]. Full attribute value was [$actual]." 835 | ); 836 | 837 | return $this; 838 | } 839 | 840 | /** 841 | * Assert that the element matching the given selector does not contain the given value in the provided attribute. 842 | * 843 | * @param string $selector 844 | * @param string $attribute 845 | * @param string $value 846 | * @return $this 847 | */ 848 | public function assertAttributeDoesntContain($selector, $attribute, $value) 849 | { 850 | $actual = $this->resolver->findOrFail($selector)->getAttribute($attribute); 851 | 852 | if (is_null($actual)) { 853 | return $this; 854 | } 855 | 856 | PHPUnit::assertStringNotContainsString( 857 | $value, 858 | $actual, 859 | "Attribute '$attribute' contains [{$value}]. Full attribute value was [$actual]." 860 | ); 861 | 862 | return $this; 863 | } 864 | 865 | /** 866 | * Assert that the element matching the given selector has the given value in the provided aria attribute. 867 | * 868 | * @param string $selector 869 | * @param string $attribute 870 | * @param string $value 871 | * @return $this 872 | */ 873 | public function assertAriaAttribute($selector, $attribute, $value) 874 | { 875 | return $this->assertAttribute($selector, 'aria-'.$attribute, $value); 876 | } 877 | 878 | /** 879 | * Assert that the element matching the given selector has the given value in the provided data attribute. 880 | * 881 | * @param string $selector 882 | * @param string $attribute 883 | * @param string $value 884 | * @return $this 885 | */ 886 | public function assertDataAttribute($selector, $attribute, $value) 887 | { 888 | return $this->assertAttribute($selector, 'data-'.$attribute, $value); 889 | } 890 | 891 | /** 892 | * Assert that the element matching the given selector is visible. 893 | * 894 | * @param string $selector 895 | * @return $this 896 | */ 897 | public function assertVisible($selector) 898 | { 899 | $fullSelector = $this->resolver->format($selector); 900 | 901 | PHPUnit::assertTrue( 902 | $this->resolver->findOrFail($selector)->isDisplayed(), 903 | "Element [{$fullSelector}] is not visible." 904 | ); 905 | 906 | return $this; 907 | } 908 | 909 | /** 910 | * Assert that the element matching the given selector is present. 911 | * 912 | * @param string $selector 913 | * @return $this 914 | */ 915 | public function assertPresent($selector) 916 | { 917 | $fullSelector = $this->resolver->format($selector); 918 | 919 | PHPUnit::assertTrue( 920 | ! is_null($this->resolver->find($selector)), 921 | "Element [{$fullSelector}] is not present." 922 | ); 923 | 924 | return $this; 925 | } 926 | 927 | /** 928 | * Assert that the element matching the given selector is not present in the source. 929 | * 930 | * @param string $selector 931 | * @return $this 932 | */ 933 | public function assertNotPresent($selector) 934 | { 935 | $fullSelector = $this->resolver->format($selector); 936 | 937 | PHPUnit::assertTrue( 938 | is_null($this->resolver->find($selector)), 939 | "Element [{$fullSelector}] is present." 940 | ); 941 | 942 | return $this; 943 | } 944 | 945 | /** 946 | * Assert that the element matching the given selector is not visible. 947 | * 948 | * @param string $selector 949 | * @return $this 950 | */ 951 | public function assertMissing($selector) 952 | { 953 | $fullSelector = $this->resolver->format($selector); 954 | 955 | try { 956 | $missing = ! $this->resolver->findOrFail($selector)->isDisplayed(); 957 | } catch (NoSuchElementException $e) { 958 | $missing = true; 959 | } 960 | 961 | PHPUnit::assertTrue( 962 | $missing, 963 | "Saw unexpected element [{$fullSelector}]." 964 | ); 965 | 966 | return $this; 967 | } 968 | 969 | /** 970 | * Assert that a JavaScript dialog with the given message has been opened. 971 | * 972 | * @param string $message 973 | * @return $this 974 | */ 975 | public function assertDialogOpened($message) 976 | { 977 | $actualMessage = $this->driver->switchTo()->alert()->getText(); 978 | 979 | PHPUnit::assertEquals( 980 | $message, 981 | $actualMessage, 982 | "Expected dialog message [{$message}] does not equal actual message [{$actualMessage}]." 983 | ); 984 | 985 | return $this; 986 | } 987 | 988 | /** 989 | * Assert that the given field is enabled. 990 | * 991 | * @param string $field 992 | * @return $this 993 | */ 994 | public function assertEnabled($field) 995 | { 996 | $element = $this->resolver->resolveForField($field); 997 | 998 | PHPUnit::assertTrue( 999 | $element->isEnabled(), 1000 | "Expected element [{$field}] to be enabled, but it wasn't." 1001 | ); 1002 | 1003 | return $this; 1004 | } 1005 | 1006 | /** 1007 | * Assert that the given field is disabled. 1008 | * 1009 | * @param string $field 1010 | * @return $this 1011 | */ 1012 | public function assertDisabled($field) 1013 | { 1014 | $element = $this->resolver->resolveForField($field); 1015 | 1016 | PHPUnit::assertFalse( 1017 | $element->isEnabled(), 1018 | "Expected element [{$field}] to be disabled, but it wasn't." 1019 | ); 1020 | 1021 | return $this; 1022 | } 1023 | 1024 | /** 1025 | * Assert that the given button is enabled. 1026 | * 1027 | * @param string $button 1028 | * @return $this 1029 | */ 1030 | public function assertButtonEnabled($button) 1031 | { 1032 | $element = $this->resolver->resolveForButtonPress($button); 1033 | 1034 | PHPUnit::assertTrue( 1035 | $element->isEnabled(), 1036 | "Expected button [{$button}] to be enabled, but it wasn't." 1037 | ); 1038 | 1039 | return $this; 1040 | } 1041 | 1042 | /** 1043 | * Assert that the given button is disabled. 1044 | * 1045 | * @param string $button 1046 | * @return $this 1047 | */ 1048 | public function assertButtonDisabled($button) 1049 | { 1050 | $element = $this->resolver->resolveForButtonPress($button); 1051 | 1052 | PHPUnit::assertFalse( 1053 | $element->isEnabled(), 1054 | "Expected button [{$button}] to be disabled, but it wasn't." 1055 | ); 1056 | 1057 | return $this; 1058 | } 1059 | 1060 | /** 1061 | * Assert that the given field is focused. 1062 | * 1063 | * @param string $field 1064 | * @return $this 1065 | */ 1066 | public function assertFocused($field) 1067 | { 1068 | $element = $this->resolver->resolveForField($field); 1069 | 1070 | PHPUnit::assertTrue( 1071 | $this->driver->switchTo()->activeElement()->equals($element), 1072 | "Expected element [{$field}] to be focused, but it wasn't." 1073 | ); 1074 | 1075 | return $this; 1076 | } 1077 | 1078 | /** 1079 | * Assert that the given field is not focused. 1080 | * 1081 | * @param string $field 1082 | * @return $this 1083 | */ 1084 | public function assertNotFocused($field) 1085 | { 1086 | $element = $this->resolver->resolveForField($field); 1087 | 1088 | PHPUnit::assertFalse( 1089 | $this->driver->switchTo()->activeElement()->equals($element), 1090 | "Expected element [{$field}] not to be focused, but it was." 1091 | ); 1092 | 1093 | return $this; 1094 | } 1095 | 1096 | /** 1097 | * Assert that the Vue component's attribute at the given key has the given value. 1098 | * 1099 | * @param string $key 1100 | * @param mixed $value 1101 | * @param string|null $componentSelector 1102 | * @return $this 1103 | */ 1104 | public function assertVue($key, $value, $componentSelector = null) 1105 | { 1106 | $formattedValue = json_encode($value); 1107 | 1108 | PHPUnit::assertEquals( 1109 | $value, 1110 | $this->vueAttribute($componentSelector, $key), 1111 | "Did not see expected value [{$formattedValue}] at the key [{$key}]." 1112 | ); 1113 | 1114 | return $this; 1115 | } 1116 | 1117 | /** 1118 | * Assert that a given Vue component data property does not match the given value. 1119 | * 1120 | * @param string $key 1121 | * @param mixed $value 1122 | * @param string|null $componentSelector 1123 | * @return $this 1124 | */ 1125 | public function assertVueIsNot($key, $value, $componentSelector = null) 1126 | { 1127 | $formattedValue = json_encode($value); 1128 | 1129 | PHPUnit::assertNotEquals( 1130 | $value, 1131 | $this->vueAttribute($componentSelector, $key), 1132 | "Saw unexpected value [{$formattedValue}] at the key [{$key}]." 1133 | ); 1134 | 1135 | return $this; 1136 | } 1137 | 1138 | /** 1139 | * Assert that a given Vue component data propertys is an array and contains the given value. 1140 | * 1141 | * @param string $key 1142 | * @param string $value 1143 | * @param string|null $componentSelector 1144 | * @return $this 1145 | */ 1146 | public function assertVueContains($key, $value, $componentSelector = null) 1147 | { 1148 | $attribute = $this->vueAttribute($componentSelector, $key); 1149 | 1150 | PHPUnit::assertIsArray( 1151 | $attribute, 1152 | "The attribute for key [{$key}] is not an array." 1153 | ); 1154 | 1155 | PHPUnit::assertContains($value, $attribute); 1156 | 1157 | return $this; 1158 | } 1159 | 1160 | /** 1161 | * Assert that a given Vue component data property is an array and does not contain the given value. 1162 | * 1163 | * @param string $key 1164 | * @param string $value 1165 | * @param string|null $componentSelector 1166 | * @return $this 1167 | */ 1168 | public function assertVueDoesntContain($key, $value, $componentSelector = null) 1169 | { 1170 | return $this->assertVueDoesNotContain($key, $value, $componentSelector); 1171 | } 1172 | 1173 | /** 1174 | * Assert that a given Vue component data property is an array and does not contain the given value. 1175 | * 1176 | * @param string $key 1177 | * @param string $value 1178 | * @param string|null $componentSelector 1179 | * @return $this 1180 | */ 1181 | public function assertVueDoesNotContain($key, $value, $componentSelector = null) 1182 | { 1183 | $attribute = $this->vueAttribute($componentSelector, $key); 1184 | 1185 | PHPUnit::assertIsArray( 1186 | $attribute, 1187 | "The attribute for key [{$key}] is not an array." 1188 | ); 1189 | 1190 | PHPUnit::assertNotContains($value, $attribute); 1191 | 1192 | return $this; 1193 | } 1194 | 1195 | /** 1196 | * Retrieve the value of the Vue component's attribute at the given key. 1197 | * 1198 | * @param string $componentSelector 1199 | * @param string $key 1200 | * @return mixed 1201 | */ 1202 | public function vueAttribute($componentSelector, $key) 1203 | { 1204 | $fullSelector = $this->resolver->format($componentSelector); 1205 | 1206 | return $this->driver->executeScript( 1207 | "var el = document.querySelector('".$fullSelector."');". 1208 | "if (typeof el.__vue__ !== 'undefined')". 1209 | ' return el.__vue__.'.$key.';'. 1210 | 'try {'. 1211 | ' var attr = el.__vueParentComponent.ctx.'.$key.';'. 1212 | " if (typeof attr !== 'undefined')". 1213 | ' return attr;'. 1214 | '} catch (e) {}'. 1215 | 'return el.__vueParentComponent.setupState.'.$key.';' 1216 | ); 1217 | } 1218 | } 1219 | -------------------------------------------------------------------------------- /src/Concerns/MakesUrlAssertions.php: -------------------------------------------------------------------------------- 1 | driver->getCurrentURL()); 22 | 23 | $currentUrl = sprintf( 24 | '%s://%s%s%s', 25 | $segments['scheme'], 26 | $segments['host'], 27 | Arr::get($segments, 'port', '') ? ':'.$segments['port'] : '', 28 | Arr::get($segments, 'path', '') 29 | ); 30 | 31 | PHPUnit::assertThat( 32 | $currentUrl, new RegularExpression('/^'.$pattern.'$/u'), 33 | "Actual URL [{$this->driver->getCurrentURL()}] does not equal expected URL [{$url}]." 34 | ); 35 | 36 | return $this; 37 | } 38 | 39 | /** 40 | * Assert that the current URL scheme matches the given scheme. 41 | * 42 | * @param string $scheme 43 | * @return $this 44 | */ 45 | public function assertSchemeIs($scheme) 46 | { 47 | $pattern = str_replace('\*', '.*', preg_quote($scheme, '/')); 48 | 49 | $actual = parse_url($this->driver->getCurrentURL(), PHP_URL_SCHEME) ?? ''; 50 | 51 | PHPUnit::assertThat( 52 | $actual, new RegularExpression('/^'.$pattern.'$/u'), 53 | "Actual scheme [{$actual}] does not equal expected scheme [{$pattern}]." 54 | ); 55 | 56 | return $this; 57 | } 58 | 59 | /** 60 | * Assert that the current URL scheme does not match the given scheme. 61 | * 62 | * @param string $scheme 63 | * @return $this 64 | */ 65 | public function assertSchemeIsNot($scheme) 66 | { 67 | $actual = parse_url($this->driver->getCurrentURL(), PHP_URL_SCHEME) ?? ''; 68 | 69 | PHPUnit::assertNotEquals( 70 | $scheme, $actual, 71 | "Scheme [{$scheme}] should not equal the actual value." 72 | ); 73 | 74 | return $this; 75 | } 76 | 77 | /** 78 | * Assert that the current URL host matches the given host. 79 | * 80 | * @param string $host 81 | * @return $this 82 | */ 83 | public function assertHostIs($host) 84 | { 85 | $pattern = str_replace('\*', '.*', preg_quote($host, '/')); 86 | 87 | $actual = parse_url($this->driver->getCurrentURL(), PHP_URL_HOST) ?? ''; 88 | 89 | PHPUnit::assertThat( 90 | $actual, new RegularExpression('/^'.$pattern.'$/u'), 91 | "Actual host [{$actual}] does not equal expected host [{$pattern}]." 92 | ); 93 | 94 | return $this; 95 | } 96 | 97 | /** 98 | * Assert that the current URL host does not match the given host. 99 | * 100 | * @param string $host 101 | * @return $this 102 | */ 103 | public function assertHostIsNot($host) 104 | { 105 | $actual = parse_url($this->driver->getCurrentURL(), PHP_URL_HOST) ?? ''; 106 | 107 | PHPUnit::assertNotEquals( 108 | $host, $actual, 109 | "Host [{$host}] should not equal the actual value." 110 | ); 111 | 112 | return $this; 113 | } 114 | 115 | /** 116 | * Assert that the current URL port matches the given port. 117 | * 118 | * @param string $port 119 | * @return $this 120 | */ 121 | public function assertPortIs($port) 122 | { 123 | $pattern = str_replace('\*', '.*', preg_quote($port, '/')); 124 | 125 | $actual = (string) parse_url($this->driver->getCurrentURL(), PHP_URL_PORT) ?? ''; 126 | 127 | PHPUnit::assertThat( 128 | $actual, new RegularExpression('/^'.$pattern.'$/u'), 129 | "Actual port [{$actual}] does not equal expected port [{$pattern}]." 130 | ); 131 | 132 | return $this; 133 | } 134 | 135 | /** 136 | * Assert that the current URL port does not match the given port. 137 | * 138 | * @param string $port 139 | * @return $this 140 | */ 141 | public function assertPortIsNot($port) 142 | { 143 | $actual = parse_url($this->driver->getCurrentURL(), PHP_URL_PORT) ?? ''; 144 | 145 | PHPUnit::assertNotEquals( 146 | $port, $actual, 147 | "Port [{$port}] should not equal the actual value." 148 | ); 149 | 150 | return $this; 151 | } 152 | 153 | /** 154 | * Assert that the current URL path begins with the given path. 155 | * 156 | * @param string $path 157 | * @return $this 158 | */ 159 | public function assertPathBeginsWith($path) 160 | { 161 | $actualPath = parse_url($this->driver->getCurrentURL(), PHP_URL_PATH) ?? ''; 162 | 163 | PHPUnit::assertStringStartsWith( 164 | $path, $actualPath, 165 | "Actual path [{$actualPath}] does not begin with expected path [{$path}]." 166 | ); 167 | 168 | return $this; 169 | } 170 | 171 | /** 172 | * Assert that the current URL path ends with the given path. 173 | * 174 | * @param string $path 175 | * @return $this 176 | */ 177 | public function assertPathEndsWith($path) 178 | { 179 | $actualPath = parse_url($this->driver->getCurrentURL(), PHP_URL_PATH) ?? ''; 180 | 181 | PHPUnit::assertStringEndsWith( 182 | $path, $actualPath, 183 | "Actual path [{$actualPath}] does not end with expected path [{$path}]." 184 | ); 185 | 186 | return $this; 187 | } 188 | 189 | /** 190 | * Assert that the current URL path contains the given path. 191 | * 192 | * @param string $path 193 | * @return $this 194 | */ 195 | public function assertPathContains($path) 196 | { 197 | $actualPath = parse_url($this->driver->getCurrentURL(), PHP_URL_PATH) ?? ''; 198 | 199 | PHPUnit::assertStringContainsString( 200 | $path, $actualPath, 201 | "Actual path [{$actualPath}] does not contain the expected string [{$path}]." 202 | ); 203 | 204 | return $this; 205 | } 206 | 207 | /** 208 | * Assert that the current path matches the given path. 209 | * 210 | * @param string $path 211 | * @return $this 212 | */ 213 | public function assertPathIs($path) 214 | { 215 | $pattern = str_replace('\*', '.*', preg_quote($path, '/')); 216 | 217 | $actualPath = parse_url($this->driver->getCurrentURL(), PHP_URL_PATH) ?? ''; 218 | 219 | PHPUnit::assertThat( 220 | $actualPath, new RegularExpression('/^'.$pattern.'$/u'), 221 | "Actual path [{$actualPath}] does not equal expected path [{$path}]." 222 | ); 223 | 224 | return $this; 225 | } 226 | 227 | /** 228 | * Assert that the current path does not match the given path. 229 | * 230 | * @param string $path 231 | * @return $this 232 | */ 233 | public function assertPathIsNot($path) 234 | { 235 | $actualPath = parse_url($this->driver->getCurrentURL(), PHP_URL_PATH) ?? ''; 236 | 237 | PHPUnit::assertNotEquals( 238 | $path, $actualPath, 239 | "Path [{$path}] should not equal the actual value." 240 | ); 241 | 242 | return $this; 243 | } 244 | 245 | /** 246 | * Assert that the current URL matches the given named route's URL. 247 | * 248 | * @param string $route 249 | * @param array $parameters 250 | * @return $this 251 | */ 252 | public function assertRouteIs($route, $parameters = []) 253 | { 254 | return $this->assertPathIs(route($route, $parameters, false)); 255 | } 256 | 257 | /** 258 | * Assert that the given query string parameter is present and has a given value. 259 | * 260 | * @param string $name 261 | * @param string|null $value 262 | * @return $this 263 | */ 264 | public function assertQueryStringHas($name, $value = null) 265 | { 266 | $output = $this->assertHasQueryStringParameter($name); 267 | 268 | if (is_null($value)) { 269 | return $this; 270 | } 271 | 272 | $parsedOutputName = is_array($output[$name]) ? implode(',', $output[$name]) : $output[$name]; 273 | 274 | $parsedValue = is_array($value) ? implode(',', $value) : $value; 275 | 276 | PHPUnit::assertEquals( 277 | $value, $output[$name], 278 | "Query string parameter [{$name}] had value [{$parsedOutputName}], but expected [{$parsedValue}]." 279 | ); 280 | 281 | return $this; 282 | } 283 | 284 | /** 285 | * Assert that the given query string parameter is missing. 286 | * 287 | * @param string $name 288 | * @return $this 289 | */ 290 | public function assertQueryStringMissing($name) 291 | { 292 | $parsedUrl = parse_url($this->driver->getCurrentURL()); 293 | 294 | if (! array_key_exists('query', $parsedUrl)) { 295 | PHPUnit::assertTrue(true); 296 | 297 | return $this; 298 | } 299 | 300 | parse_str($parsedUrl['query'], $output); 301 | 302 | PHPUnit::assertArrayNotHasKey( 303 | $name, $output, 304 | "Found unexpected query string parameter [{$name}] in [".$this->driver->getCurrentURL().'].' 305 | ); 306 | 307 | return $this; 308 | } 309 | 310 | /** 311 | * Assert that the URL's current hash fragment matches the given fragment. 312 | * 313 | * @param string $fragment 314 | * @return $this 315 | */ 316 | public function assertFragmentIs($fragment) 317 | { 318 | $pattern = preg_quote($fragment, '/'); 319 | 320 | $actualFragment = (string) parse_url($this->driver->executeScript('return window.location.href;'), PHP_URL_FRAGMENT); 321 | 322 | PHPUnit::assertThat( 323 | $actualFragment, new RegularExpression('/^'.str_replace('\*', '.*', $pattern).'$/u'), 324 | "Actual fragment [{$actualFragment}] does not equal expected fragment [{$fragment}]." 325 | ); 326 | 327 | return $this; 328 | } 329 | 330 | /** 331 | * Assert that the URL's current hash fragment begins with the given fragment. 332 | * 333 | * @param string $fragment 334 | * @return $this 335 | */ 336 | public function assertFragmentBeginsWith($fragment) 337 | { 338 | $actualFragment = (string) parse_url($this->driver->executeScript('return window.location.href;'), PHP_URL_FRAGMENT); 339 | 340 | PHPUnit::assertStringStartsWith( 341 | $fragment, $actualFragment, 342 | "Actual fragment [$actualFragment] does not begin with expected fragment [$fragment]." 343 | ); 344 | 345 | return $this; 346 | } 347 | 348 | /** 349 | * Assert that the URL's current hash fragment does not match the given fragment. 350 | * 351 | * @param string $fragment 352 | * @return $this 353 | */ 354 | public function assertFragmentIsNot($fragment) 355 | { 356 | $actualFragment = (string) parse_url($this->driver->executeScript('return window.location.href;'), PHP_URL_FRAGMENT); 357 | 358 | PHPUnit::assertNotEquals( 359 | $fragment, $actualFragment, 360 | "Fragment [{$fragment}] should not equal the actual value." 361 | ); 362 | 363 | return $this; 364 | } 365 | 366 | /** 367 | * Assert that the given query string parameter is present. 368 | * 369 | * @param string $name 370 | * @return array 371 | */ 372 | protected function assertHasQueryStringParameter($name) 373 | { 374 | $parsedUrl = parse_url($this->driver->getCurrentURL()); 375 | 376 | PHPUnit::assertArrayHasKey( 377 | 'query', $parsedUrl, 378 | 'Did not see expected query string in ['.$this->driver->getCurrentURL().'].' 379 | ); 380 | 381 | parse_str($parsedUrl['query'], $output); 382 | 383 | PHPUnit::assertArrayHasKey( 384 | $name, $output, 385 | "Did not see expected query string parameter [{$name}] in [".$this->driver->getCurrentURL().'].' 386 | ); 387 | 388 | return $output; 389 | } 390 | } 391 | -------------------------------------------------------------------------------- /src/Concerns/ProvidesBrowser.php: -------------------------------------------------------------------------------- 1 | createBrowsersFor($callback); 68 | 69 | try { 70 | $callback(...$browsers->all()); 71 | } catch (Exception $e) { 72 | $this->captureFailuresFor($browsers); 73 | $this->storeSourceLogsFor($browsers); 74 | 75 | throw $e; 76 | } catch (Throwable $e) { 77 | $this->captureFailuresFor($browsers); 78 | $this->storeSourceLogsFor($browsers); 79 | 80 | throw $e; 81 | } finally { 82 | $this->storeConsoleLogsFor($browsers); 83 | 84 | static::$browsers = $this->closeAllButPrimary($browsers); 85 | } 86 | } 87 | 88 | /** 89 | * Create the browser instances needed for the given callback. 90 | * 91 | * @param \Closure $callback 92 | * @return array 93 | * 94 | * @throws \ReflectionException 95 | */ 96 | protected function createBrowsersFor(Closure $callback) 97 | { 98 | if (count(static::$browsers) === 0) { 99 | static::$browsers = collect([$this->newBrowser($this->createWebDriver())]); 100 | } 101 | 102 | $additional = $this->browsersNeededFor($callback) - 1; 103 | 104 | for ($i = 0; $i < $additional; $i++) { 105 | static::$browsers->push($this->newBrowser($this->createWebDriver())); 106 | } 107 | 108 | return static::$browsers; 109 | } 110 | 111 | /** 112 | * Create a new Browser instance. 113 | * 114 | * @param \Facebook\WebDriver\Remote\RemoteWebDriver $driver 115 | * @return \Laravel\Dusk\Browser 116 | */ 117 | protected function newBrowser($driver) 118 | { 119 | return new Browser($driver); 120 | } 121 | 122 | /** 123 | * Get the number of browsers needed for a given callback. 124 | * 125 | * @param \Closure $callback 126 | * @return int 127 | * 128 | * @throws \ReflectionException 129 | */ 130 | protected function browsersNeededFor(Closure $callback) 131 | { 132 | return (new ReflectionFunction($callback))->getNumberOfParameters(); 133 | } 134 | 135 | /** 136 | * Capture failure screenshots for each browser. 137 | * 138 | * @param \Illuminate\Support\Collection $browsers 139 | * @return void 140 | */ 141 | protected function captureFailuresFor($browsers) 142 | { 143 | $browsers->each(function ($browser, $key) { 144 | if (property_exists($browser, 'fitOnFailure') && $browser->fitOnFailure) { 145 | $browser->fitContent(); 146 | } 147 | 148 | $name = $this->getCallerName(); 149 | 150 | $browser->screenshot('failure-'.$name.'-'.$key); 151 | }); 152 | } 153 | 154 | /** 155 | * Store the console output for the given browsers. 156 | * 157 | * @param \Illuminate\Support\Collection $browsers 158 | * @return void 159 | */ 160 | protected function storeConsoleLogsFor($browsers) 161 | { 162 | $browsers->each(function ($browser, $key) { 163 | $name = $this->getCallerName(); 164 | 165 | $browser->storeConsoleLog($name.'-'.$key); 166 | }); 167 | } 168 | 169 | /** 170 | * Store the source code for the given browsers (if necessary). 171 | * 172 | * @param \Illuminate\Support\Collection $browsers 173 | * @return void 174 | */ 175 | protected function storeSourceLogsFor($browsers) 176 | { 177 | $browsers->each(function ($browser, $key) { 178 | if (property_exists($browser, 'madeSourceAssertion') && 179 | $browser->madeSourceAssertion) { 180 | $browser->storeSource($this->getCallerName().'-'.$key); 181 | } 182 | }); 183 | } 184 | 185 | /** 186 | * Close all of the browsers except the primary (first) one. 187 | * 188 | * @param \Illuminate\Support\Collection $browsers 189 | * @return \Illuminate\Support\Collection 190 | */ 191 | protected function closeAllButPrimary($browsers) 192 | { 193 | $browsers->slice(1)->each->quit(); 194 | 195 | return $browsers->take(1); 196 | } 197 | 198 | /** 199 | * Close all of the active browsers. 200 | * 201 | * @return void 202 | */ 203 | public static function closeAll() 204 | { 205 | Collection::make(static::$browsers)->each->quit(); 206 | 207 | static::$browsers = collect(); 208 | } 209 | 210 | /** 211 | * Create the remote web driver instance. 212 | * 213 | * @return \Facebook\WebDriver\Remote\RemoteWebDriver 214 | * 215 | * @throws \Exception 216 | */ 217 | protected function createWebDriver() 218 | { 219 | return retry(5, function () { 220 | return $this->driver(); 221 | }, 50); 222 | } 223 | 224 | /** 225 | * Get the browser caller name. 226 | * 227 | * @return string 228 | */ 229 | protected function getCallerName() 230 | { 231 | $name = version_compare(Version::id(), '10', '>=') 232 | ? $this->name() 233 | : $this->getName(false); // @phpstan-ignore-line 234 | 235 | $parts = array_filter([ 236 | str_replace('\\', '_', get_class($this)), 237 | $name, 238 | str_replace(['\\', DIRECTORY_SEPARATOR, ' '], ['', '', '_'], $this->dataName()), 239 | ], fn ($part) => $part !== ''); 240 | 241 | return substr(implode('_', $parts), -140); 242 | } 243 | 244 | /** 245 | * Create the RemoteWebDriver instance. 246 | * 247 | * @return \Facebook\WebDriver\Remote\RemoteWebDriver 248 | */ 249 | abstract protected function driver(); 250 | } 251 | -------------------------------------------------------------------------------- /src/Concerns/WaitsForElements.php: -------------------------------------------------------------------------------- 1 | waitFor($selector, $seconds)->with($selector, $callback); 29 | } 30 | 31 | /** 32 | * Wait for the given selector to become visible. 33 | * 34 | * @param string $selector 35 | * @param int|null $seconds 36 | * @return $this 37 | * 38 | * @throws \Facebook\WebDriver\Exception\TimeoutException 39 | */ 40 | public function waitFor($selector, $seconds = null) 41 | { 42 | $message = $this->formatTimeOutMessage('Waited %s seconds for selector', $selector); 43 | 44 | return $this->waitUsing($seconds, 100, function () use ($selector) { 45 | return $this->resolver->findOrFail($selector)->isDisplayed(); 46 | }, $message); 47 | } 48 | 49 | /** 50 | * Wait for the given selector to be removed. 51 | * 52 | * @param string $selector 53 | * @param int|null $seconds 54 | * @return $this 55 | * 56 | * @throws \Facebook\WebDriver\Exception\TimeoutException 57 | */ 58 | public function waitUntilMissing($selector, $seconds = null) 59 | { 60 | $message = $this->formatTimeOutMessage('Waited %s seconds for removal of selector', $selector); 61 | 62 | return $this->waitUsing($seconds, 100, function () use ($selector) { 63 | try { 64 | $missing = ! $this->resolver->findOrFail($selector)->isDisplayed(); 65 | } catch (NoSuchElementException $e) { 66 | $missing = true; 67 | } 68 | 69 | return $missing; 70 | }, $message); 71 | } 72 | 73 | /** 74 | * Wait for the given text to be removed. 75 | * 76 | * @param string $text 77 | * @param int|null $seconds 78 | * @return $this 79 | * 80 | * @throws \Facebook\WebDriver\Exception\TimeoutException 81 | */ 82 | public function waitUntilMissingText($text, $seconds = null) 83 | { 84 | $text = Arr::wrap($text); 85 | 86 | $message = $this->formatTimeOutMessage('Waited %s seconds for removal of text', implode("', '", $text)); 87 | 88 | return $this->waitUsing($seconds, 100, function () use ($text) { 89 | return ! Str::contains($this->resolver->findOrFail('')->getText(), $text); 90 | }, $message); 91 | } 92 | 93 | /** 94 | * Wait for the given text to become visible. 95 | * 96 | * @param array|string $text 97 | * @param int|null $seconds 98 | * @param bool $ignoreCase 99 | * @return $this 100 | * 101 | * @throws \Facebook\WebDriver\Exception\TimeoutException 102 | */ 103 | public function waitForText($text, $seconds = null, $ignoreCase = false) 104 | { 105 | $text = Arr::wrap($text); 106 | 107 | $message = $this->formatTimeOutMessage('Waited %s seconds for text', implode("', '", $text)); 108 | 109 | return $this->waitUsing($seconds, 100, function () use ($text, $ignoreCase) { 110 | return Str::contains($this->resolver->findOrFail('')->getText(), $text, $ignoreCase); 111 | }, $message); 112 | } 113 | 114 | /** 115 | * Wait for the given text to become visible inside the given selector. 116 | * 117 | * @param string $selector 118 | * @param array|string $text 119 | * @param int|null $seconds 120 | * @param bool $ignoreCase 121 | * @return $this 122 | * 123 | * @throws \Facebook\WebDriver\Exception\TimeoutException 124 | */ 125 | public function waitForTextIn($selector, $text, $seconds = null, $ignoreCase = false) 126 | { 127 | $message = 'Waited %s seconds for text "'.$this->escapePercentCharacters($text).'" in selector '.$selector; 128 | 129 | return $this->waitUsing($seconds, 100, function () use ($selector, $text, $ignoreCase) { 130 | return $this->assertSeeIn($selector, $text, $ignoreCase); 131 | }, $message); 132 | } 133 | 134 | /** 135 | * Wait for the given link to become visible. 136 | * 137 | * @param string $link 138 | * @param int|null $seconds 139 | * @return $this 140 | * 141 | * @throws \Facebook\WebDriver\Exception\TimeoutException 142 | */ 143 | public function waitForLink($link, $seconds = null) 144 | { 145 | $message = $this->formatTimeOutMessage('Waited %s seconds for link', $link); 146 | 147 | return $this->waitUsing($seconds, 100, function () use ($link) { 148 | return $this->seeLink($link); 149 | }, $message); 150 | } 151 | 152 | /** 153 | * Wait for an input field to become visible. 154 | * 155 | * @param string $field 156 | * @param int|null $seconds 157 | * @return $this 158 | */ 159 | public function waitForInput($field, $seconds = null) 160 | { 161 | return $this->waitFor("input[name='{$field}'], textarea[name='{$field}'], select[name='{$field}']", $seconds); 162 | } 163 | 164 | /** 165 | * Wait for the given location. 166 | * 167 | * @param string $path 168 | * @param int|null $seconds 169 | * @return $this 170 | * 171 | * @throws \Facebook\WebDriver\Exception\TimeoutException 172 | */ 173 | public function waitForLocation($path, $seconds = null) 174 | { 175 | $message = $this->formatTimeOutMessage('Waited %s seconds for location', $path); 176 | 177 | return Str::startsWith($path, ['http://', 'https://']) 178 | ? $this->waitUntil('`${location.protocol}//${location.host}${location.pathname}` == \''.$path.'\'', $seconds, $message) 179 | : $this->waitUntil("window.location.pathname == '{$path}'", $seconds, $message); 180 | } 181 | 182 | /** 183 | * Wait for the given location using a named route. 184 | * 185 | * @param string $route 186 | * @param array $parameters 187 | * @param int|null $seconds 188 | * @return $this 189 | * 190 | * @throws \Facebook\WebDriver\Exception\TimeoutException 191 | */ 192 | public function waitForRoute($route, $parameters = [], $seconds = null) 193 | { 194 | return $this->waitForLocation(route($route, $parameters, false), $seconds); 195 | } 196 | 197 | /** 198 | * Wait until an element is enabled. 199 | * 200 | * @param string $selector 201 | * @param int|null $seconds 202 | * @return $this 203 | */ 204 | public function waitUntilEnabled($selector, $seconds = null) 205 | { 206 | $message = $this->formatTimeOutMessage('Waited %s seconds for element to be enabled', $selector); 207 | 208 | $this->waitUsing($seconds, 100, function () use ($selector) { 209 | return $this->resolver->findOrFail($selector)->isEnabled(); 210 | }, $message); 211 | 212 | return $this; 213 | } 214 | 215 | /** 216 | * Wait until an element is disabled. 217 | * 218 | * @param string $selector 219 | * @param int|null $seconds 220 | * @return $this 221 | */ 222 | public function waitUntilDisabled($selector, $seconds = null) 223 | { 224 | $message = $this->formatTimeOutMessage('Waited %s seconds for element to be disabled', $selector); 225 | 226 | $this->waitUsing($seconds, 100, function () use ($selector) { 227 | return ! $this->resolver->findOrFail($selector)->isEnabled(); 228 | }, $message); 229 | 230 | return $this; 231 | } 232 | 233 | /** 234 | * Wait until the given script returns true. 235 | * 236 | * @param string $script 237 | * @param int|null $seconds 238 | * @param string|null $message 239 | * @return $this 240 | * 241 | * @throws \Facebook\WebDriver\Exception\TimeoutException 242 | */ 243 | public function waitUntil($script, $seconds = null, $message = null) 244 | { 245 | if (! Str::startsWith($script, 'return ')) { 246 | $script = 'return '.$script; 247 | } 248 | 249 | if (! Str::endsWith($script, ';')) { 250 | $script = $script.';'; 251 | } 252 | 253 | return $this->waitUsing($seconds, 100, function () use ($script) { 254 | return $this->driver->executeScript($script); 255 | }, $message); 256 | } 257 | 258 | /** 259 | * Wait until the Vue component's attribute at the given key has the given value. 260 | * 261 | * @param string $key 262 | * @param string $value 263 | * @param string|null $componentSelector 264 | * @param int|null $seconds 265 | * @return $this 266 | */ 267 | public function waitUntilVue($key, $value, $componentSelector = null, $seconds = null) 268 | { 269 | $this->waitUsing($seconds, 100, function () use ($key, $value, $componentSelector) { 270 | return $value == $this->vueAttribute($componentSelector, $key); 271 | }); 272 | 273 | return $this; 274 | } 275 | 276 | /** 277 | * Wait until the Vue component's attribute at the given key does not have the given value. 278 | * 279 | * @param string $key 280 | * @param string $value 281 | * @param string|null $componentSelector 282 | * @param int|null $seconds 283 | * @return $this 284 | */ 285 | public function waitUntilVueIsNot($key, $value, $componentSelector = null, $seconds = null) 286 | { 287 | $this->waitUsing($seconds, 100, function () use ($key, $value, $componentSelector) { 288 | return $value != $this->vueAttribute($componentSelector, $key); 289 | }); 290 | 291 | return $this; 292 | } 293 | 294 | /** 295 | * Wait for a JavaScript dialog to open. 296 | * 297 | * @param int|null $seconds 298 | * @return $this 299 | */ 300 | public function waitForDialog($seconds = null) 301 | { 302 | $seconds = is_null($seconds) ? static::$waitSeconds : $seconds; 303 | 304 | $this->driver->wait($seconds, 100)->until( 305 | WebDriverExpectedCondition::alertIsPresent(), "Waited {$seconds} seconds for dialog." 306 | ); 307 | 308 | return $this; 309 | } 310 | 311 | /** 312 | * Wait for the current page to reload. 313 | * 314 | * @param \Closure|null $callback 315 | * @param int|null $seconds 316 | * @return $this 317 | * 318 | * @throws \Facebook\WebDriver\Exception\TimeoutException 319 | */ 320 | public function waitForReload($callback = null, $seconds = null) 321 | { 322 | $token = Str::random(); 323 | 324 | $this->driver->executeScript("window['{$token}'] = {};"); 325 | 326 | if ($callback) { 327 | $callback($this); 328 | } 329 | 330 | return $this->waitUsing($seconds, 100, function () use ($token) { 331 | return $this->driver->executeScript("return typeof window['{$token}'] === 'undefined';"); 332 | }, 'Waited %s seconds for page reload.'); 333 | } 334 | 335 | /** 336 | * Click an element and wait for the page to reload. 337 | * 338 | * @param string|null $selector 339 | * @param int|null $seconds 340 | * @return $this 341 | */ 342 | public function clickAndWaitForReload($selector = null, $seconds = null) 343 | { 344 | return $this->waitForReload(function ($browser) use ($selector) { 345 | $browser->click($selector); 346 | }, $seconds); 347 | } 348 | 349 | /** 350 | * Wait for the given event type to occur on a target. 351 | * 352 | * @param string $type 353 | * @param string|null $target 354 | * @param int|null $seconds 355 | * @return $this 356 | * 357 | * @throws \Facebook\WebDriver\Exception\TimeoutException 358 | */ 359 | public function waitForEvent($type, $target = null, $seconds = null) 360 | { 361 | $seconds = is_null($seconds) ? static::$waitSeconds : $seconds; 362 | 363 | if ($target !== 'document' && $target !== 'window') { 364 | $target = $this->resolver->findOrFail($target ?? ''); 365 | } 366 | 367 | $this->driver->manage()->timeouts()->setScriptTimeout($seconds); 368 | 369 | try { 370 | $this->driver->executeAsyncScript( 371 | 'eval(arguments[0]).addEventListener(arguments[1], () => arguments[2](), { once: true });', 372 | [$target, $type] 373 | ); 374 | } catch (ScriptTimeoutException $e) { 375 | throw new TimeoutException("Waited {$seconds} seconds for event [{$type}]."); 376 | } 377 | 378 | return $this; 379 | } 380 | 381 | /** 382 | * Wait for the given callback to be true. 383 | * 384 | * @param int|null $seconds 385 | * @param int $interval 386 | * @param \Closure $callback 387 | * @param string|null $message 388 | * @return $this 389 | * 390 | * @throws \Facebook\WebDriver\Exception\TimeoutException 391 | */ 392 | public function waitUsing($seconds, $interval, Closure $callback, $message = null) 393 | { 394 | $seconds = is_null($seconds) ? static::$waitSeconds : $seconds; 395 | 396 | $this->pause($interval); 397 | 398 | $this->driver->wait($seconds, $interval)->until( 399 | function ($driver) use ($callback) { 400 | try { 401 | return $callback(); 402 | } catch (Exception $e) { 403 | return false; 404 | } 405 | }, 406 | $message ? sprintf($message, $seconds) : "Waited {$seconds} seconds for callback." 407 | ); 408 | 409 | return $this; 410 | } 411 | 412 | /** 413 | * Prepare custom TimeoutException message for sprintf(). 414 | * 415 | * @param string $message 416 | * @param string $expected 417 | * @return string 418 | */ 419 | protected function formatTimeOutMessage($message, $expected) 420 | { 421 | return $message.' ['.$this->escapePercentCharacters($expected).'].'; 422 | } 423 | 424 | /** 425 | * Escape percent characters in preparation for sending the given message to "sprintf". 426 | * 427 | * @param string $message 428 | * @return string 429 | */ 430 | protected function escapePercentCharacters($message) 431 | { 432 | return str_replace('%', '%%', $message); 433 | } 434 | } 435 | -------------------------------------------------------------------------------- /src/Console/ChromeDriverCommand.php: -------------------------------------------------------------------------------- 1 | '2.20', 46 | 44 => '2.20', 47 | 45 => '2.20', 48 | 46 => '2.21', 49 | 47 => '2.21', 50 | 48 => '2.21', 51 | 49 => '2.22', 52 | 50 => '2.22', 53 | 51 => '2.23', 54 | 52 => '2.24', 55 | 53 => '2.26', 56 | 54 => '2.27', 57 | 55 => '2.28', 58 | 56 => '2.29', 59 | 57 => '2.29', 60 | 58 => '2.31', 61 | 59 => '2.32', 62 | 60 => '2.33', 63 | 61 => '2.34', 64 | 62 => '2.35', 65 | 63 => '2.36', 66 | 64 => '2.37', 67 | 65 => '2.38', 68 | 66 => '2.40', 69 | 67 => '2.41', 70 | 68 => '2.42', 71 | 69 => '2.44', 72 | ]; 73 | 74 | /** 75 | * Path to the bin directory. 76 | * 77 | * @var string 78 | */ 79 | protected $directory = __DIR__.'/../../bin/'; 80 | 81 | /** 82 | * Execute the console command. 83 | * 84 | * @return void 85 | */ 86 | public function handle() 87 | { 88 | $version = $this->version(); 89 | 90 | $all = $this->option('all'); 91 | 92 | $currentOS = OperatingSystem::id(); 93 | 94 | foreach (OperatingSystem::all() as $os) { 95 | if ($all || ($os === $currentOS)) { 96 | $archive = $this->download($version, $os); 97 | 98 | $binary = $this->extract($archive); 99 | 100 | $this->rename($binary, $os); 101 | } 102 | } 103 | 104 | $message = 'ChromeDriver %s successfully installed for version %s.'; 105 | 106 | $this->components->info(sprintf($message, $all ? 'binaries' : 'binary', $version)); 107 | } 108 | 109 | /** 110 | * Get the desired ChromeDriver version. 111 | * 112 | * @return string 113 | */ 114 | protected function version() 115 | { 116 | $version = $this->argument('version'); 117 | 118 | if ($this->option('detect')) { 119 | $version = $this->detectChromeVersion(OperatingSystem::id()); 120 | } 121 | 122 | if (! $version) { 123 | return $this->latestVersion(); 124 | } 125 | 126 | if (! ctype_digit($version)) { 127 | return $version; 128 | } 129 | 130 | $version = (int) $version; 131 | 132 | if ($version < 70) { 133 | return $this->legacyVersions[$version]; 134 | } elseif ($version < 115) { 135 | return $this->fetchChromeVersionFromUrl($version); 136 | } 137 | 138 | $milestones = $this->resolveChromeVersionsPerMilestone(); 139 | 140 | return $milestones['milestones'][$version]['version'] 141 | ?? throw new Exception('Could not determine the ChromeDriver version.'); 142 | } 143 | 144 | /** 145 | * Get the latest stable ChromeDriver version. 146 | * 147 | * @return string 148 | */ 149 | protected function latestVersion() 150 | { 151 | $versions = json_decode($this->getUrl('https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json'), true); 152 | 153 | return $versions['channels']['Stable']['version'] 154 | ?? throw new Exception('Could not get the latest ChromeDriver version.'); 155 | } 156 | 157 | /** 158 | * Detect the installed Chrome / Chromium major version. 159 | * 160 | * @param string $os 161 | * @return int|bool 162 | */ 163 | protected function detectChromeVersion($os) 164 | { 165 | foreach (OperatingSystem::chromeVersionCommands($os) as $command) { 166 | $process = Process::fromShellCommandline($command); 167 | 168 | $process->run(); 169 | 170 | preg_match('/(\d+)(\.\d+){3}/', $process->getOutput(), $matches); 171 | 172 | if (! isset($matches[1])) { 173 | continue; 174 | } 175 | 176 | return $matches[1]; 177 | } 178 | 179 | $this->components->error('Chrome version could not be detected.'); 180 | 181 | return false; 182 | } 183 | 184 | /** 185 | * Download the ChromeDriver archive. 186 | * 187 | * @param string $version 188 | * @param string $os 189 | * @return string 190 | */ 191 | protected function download($version, $os) 192 | { 193 | $url = $this->resolveChromeDriverDownloadUrl($version, $os); 194 | 195 | $resource = Utils::tryFopen($archive = $this->directory.'chromedriver.zip', 'w'); 196 | 197 | $client = new Client(); 198 | 199 | $response = $client->get($url, array_merge([ 200 | 'sink' => $resource, 201 | 'verify' => $this->option('ssl-no-verify') === false, 202 | ], array_filter([ 203 | 'proxy' => $this->option('proxy'), 204 | ]))); 205 | 206 | if ($response->getStatusCode() < 200 || $response->getStatusCode() > 299) { 207 | throw new Exception("Unable to download ChromeDriver from [{$url}]."); 208 | } 209 | 210 | return $archive; 211 | } 212 | 213 | /** 214 | * Extract the ChromeDriver binary from the archive and delete the archive. 215 | * 216 | * @param string $archive 217 | * @return string 218 | * 219 | * @throws \Exception 220 | */ 221 | protected function extract($archive) 222 | { 223 | $zip = new ZipArchive; 224 | 225 | $zip->open($archive); 226 | 227 | $binary = null; 228 | 229 | for ($fileIndex = 0; $fileIndex < $zip->numFiles; $fileIndex++) { 230 | $filename = $zip->getNameIndex($fileIndex); 231 | 232 | if (Str::startsWith(basename($filename), 'chromedriver')) { 233 | $binary = $filename; 234 | 235 | $zip->extractTo($this->directory, $binary); 236 | 237 | break; 238 | } 239 | } 240 | 241 | $zip->close(); 242 | 243 | unlink($archive); 244 | 245 | if (! $binary) { 246 | throw new Exception('Could not extract the ChromeDriver binary.'); 247 | } 248 | 249 | return $binary; 250 | } 251 | 252 | /** 253 | * Rename the ChromeDriver binary and make it executable. 254 | * 255 | * @param string $binary 256 | * @param string $os 257 | * @return void 258 | */ 259 | protected function rename($binary, $os) 260 | { 261 | $binary = str_replace(DIRECTORY_SEPARATOR, '/', $binary); 262 | 263 | $newName = Str::contains($binary, '/') 264 | ? Str::after(str_replace('chromedriver', 'chromedriver-'.$os, $binary), '/') 265 | : str_replace('chromedriver', 'chromedriver-'.$os, $binary); 266 | 267 | rename($this->directory.$binary, $this->directory.$newName); 268 | 269 | chmod($this->directory.$newName, 0755); 270 | } 271 | 272 | /** 273 | * Get the Chrome version from URL. 274 | * 275 | * @return string 276 | */ 277 | protected function fetchChromeVersionFromUrl(int $version) 278 | { 279 | return trim((string) $this->getUrl( 280 | sprintf('https://chromedriver.storage.googleapis.com/LATEST_RELEASE_%d', $version) 281 | )); 282 | } 283 | 284 | /** 285 | * Get the Chrome versions per milestone. 286 | * 287 | * @return array 288 | */ 289 | protected function resolveChromeVersionsPerMilestone() 290 | { 291 | return json_decode( 292 | $this->getUrl('https://googlechromelabs.github.io/chrome-for-testing/latest-versions-per-milestone-with-downloads.json'), true 293 | ); 294 | } 295 | 296 | /** 297 | * Resolve the download URL. 298 | * 299 | * @return string 300 | * 301 | * @throws \Exception 302 | */ 303 | protected function resolveChromeDriverDownloadUrl(string $version, string $os) 304 | { 305 | $slug = OperatingSystem::chromeDriverSlug($os, $version); 306 | 307 | if (version_compare($version, '115.0', '<')) { 308 | return sprintf('https://chromedriver.storage.googleapis.com/%s/chromedriver_%s.zip', $version, $slug); 309 | } 310 | 311 | $milestone = (int) $version; 312 | 313 | $versions = $this->resolveChromeVersionsPerMilestone(); 314 | 315 | /** @var array $chromedrivers */ 316 | $chromedrivers = $versions['milestones'][$milestone]['downloads']['chromedriver'] 317 | ?? throw new Exception('Could not get the ChromeDriver version.'); 318 | 319 | return collect($chromedrivers)->firstWhere('platform', $slug)['url'] 320 | ?? throw new Exception('Could not get the ChromeDriver version.'); 321 | } 322 | 323 | /** 324 | * Get the contents of a URL using the 'proxy' and 'ssl-no-verify' command options. 325 | * 326 | * @return string 327 | * 328 | * @throws \Exception 329 | */ 330 | protected function getUrl(string $url) 331 | { 332 | $client = new Client(); 333 | 334 | $response = $client->get($url, array_merge([ 335 | 'verify' => $this->option('ssl-no-verify') === false, 336 | ], array_filter([ 337 | 'proxy' => $this->option('proxy'), 338 | ]))); 339 | 340 | if ($response->getStatusCode() < 200 || $response->getStatusCode() > 299) { 341 | throw new Exception("Unable to fetch contents from [{$url}]."); 342 | } 343 | 344 | return (string) $response->getBody(); 345 | } 346 | } 347 | -------------------------------------------------------------------------------- /src/Console/ComponentCommand.php: -------------------------------------------------------------------------------- 1 | rootNamespace(), '', $name); 52 | 53 | return $this->laravel->basePath().'/tests'.str_replace('\\', '/', $name).'.php'; 54 | } 55 | 56 | /** 57 | * Get the default namespace for the class. 58 | * 59 | * @param string $rootNamespace 60 | * @return string 61 | */ 62 | protected function getDefaultNamespace($rootNamespace) 63 | { 64 | return $rootNamespace.'\Browser\Components'; 65 | } 66 | 67 | /** 68 | * Get the root namespace for the class. 69 | * 70 | * @return string 71 | */ 72 | protected function rootNamespace() 73 | { 74 | return 'Tests'; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Console/Concerns/InteractsWithTestingFrameworks.php: -------------------------------------------------------------------------------- 1 | ignoreValidationErrors(); 54 | } 55 | 56 | /** 57 | * Execute the console command. 58 | * 59 | * @return mixed 60 | */ 61 | public function handle() 62 | { 63 | $this->purgeScreenshots(); 64 | 65 | $this->purgeConsoleLogs(); 66 | 67 | $this->purgeSourceLogs(); 68 | 69 | $options = collect($_SERVER['argv']) 70 | ->slice(2) 71 | ->diff([ 72 | '--browse', '--without-tty', 73 | '--quiet', '-q', 74 | '--verbose', '-v', '-vv', '-vvv', 75 | '--no-interaction', '-n', 76 | ]) 77 | ->values() 78 | ->all(); 79 | 80 | return $this->withDuskEnvironment(function () use ($options) { 81 | $process = (new Process(array_merge( 82 | $this->binary(), $this->phpunitArguments($options) 83 | ), null, $this->env()))->setTimeout(null); 84 | 85 | try { 86 | $process->setTty(! $this->option('without-tty')); 87 | } catch (RuntimeException $e) { 88 | $this->output->writeln('Warning: '.$e->getMessage()); 89 | } 90 | 91 | try { 92 | return $process->run(function ($type, $line) { 93 | $this->output->write($line); 94 | }); 95 | } catch (ProcessSignaledException $e) { 96 | if (extension_loaded('pcntl') && $e->getSignal() !== SIGINT) { 97 | throw $e; 98 | } 99 | } 100 | }); 101 | } 102 | 103 | /** 104 | * Get the PHP binary to execute. 105 | * 106 | * @return array 107 | */ 108 | protected function binary() 109 | { 110 | $binaryPath = 'vendor/phpunit/phpunit/phpunit'; 111 | 112 | if ($this->usingPest()) { 113 | $binaryPath = 'vendor/pestphp/pest/bin/pest'; 114 | } 115 | 116 | if ('phpdbg' === PHP_SAPI) { 117 | return [PHP_BINARY, '-qrr', $binaryPath]; 118 | } 119 | 120 | return [PHP_BINARY, $binaryPath]; 121 | } 122 | 123 | /** 124 | * Get the array of arguments for running PHPUnit. 125 | * 126 | * @param array $options 127 | * @return array 128 | */ 129 | protected function phpunitArguments($options) 130 | { 131 | if ($this->shouldUseCollisionPrinter()) { 132 | $options[] = '--no-output'; 133 | } 134 | 135 | $options = array_values(array_filter($options, function ($option) { 136 | return ! Str::startsWith($option, ['--env=', '--pest', '--ansi', '--no-ansi']); 137 | })); 138 | 139 | if (! file_exists($file = base_path('phpunit.dusk.xml'))) { 140 | $file = base_path('phpunit.dusk.xml.dist'); 141 | } 142 | 143 | if (version_compare(Version::id(), '10.0', '>=')) { 144 | if ($this->option('ansi')) { 145 | $options[] = '--colors=always'; 146 | } 147 | 148 | if ($this->option('no-ansi')) { 149 | $options[] = '--colors=never'; 150 | } 151 | } 152 | 153 | return array_merge(['-c', $file], $options); 154 | } 155 | 156 | /** 157 | * Get the PHP binary environment variables. 158 | * 159 | * @return array|null 160 | */ 161 | protected function env() 162 | { 163 | $variables = []; 164 | 165 | if ($this->option('browse') && ! isset($_ENV['CI']) && ! isset($_SERVER['CI'])) { 166 | $variables['DUSK_HEADLESS_DISABLED'] = true; 167 | } 168 | 169 | if ($this->shouldUseCollisionPrinter()) { 170 | $variables['COLLISION_PRINTER'] = 'DefaultPrinter'; 171 | } 172 | 173 | return $variables; 174 | } 175 | 176 | /** 177 | * Determine if Collision's printer should be used. 178 | * 179 | * @return bool 180 | */ 181 | protected function shouldUseCollisionPrinter() 182 | { 183 | return ! $this->usingPest() 184 | && class_exists(EnsurePrinterIsRegisteredSubscriber::class) 185 | && version_compare(Version::id(), '10.0', '>='); 186 | } 187 | 188 | /** 189 | * Purge the failure screenshots. 190 | * 191 | * @return void 192 | */ 193 | protected function purgeScreenshots() 194 | { 195 | $this->purgeDebuggingFiles( 196 | base_path('tests/Browser/screenshots'), 'failure-*' 197 | ); 198 | } 199 | 200 | /** 201 | * Purge the console logs. 202 | * 203 | * @return void 204 | */ 205 | protected function purgeConsoleLogs() 206 | { 207 | $this->purgeDebuggingFiles( 208 | base_path('tests/Browser/console'), '*.log' 209 | ); 210 | } 211 | 212 | /** 213 | * Purge the source logs. 214 | * 215 | * @return void 216 | */ 217 | protected function purgeSourceLogs() 218 | { 219 | $this->purgeDebuggingFiles( 220 | base_path('tests/Browser/source'), '*.txt' 221 | ); 222 | } 223 | 224 | /** 225 | * Purge debugging files based on path and patterns. 226 | * 227 | * @param string $path 228 | * @param string $patterns 229 | * @return void 230 | */ 231 | protected function purgeDebuggingFiles($path, $patterns) 232 | { 233 | if (! is_dir($path)) { 234 | return; 235 | } 236 | 237 | $files = Finder::create()->files() 238 | ->in($path) 239 | ->name($patterns); 240 | 241 | foreach ($files as $file) { 242 | @unlink($file->getRealPath()); 243 | } 244 | } 245 | 246 | /** 247 | * Run the given callback with the Dusk configuration files. 248 | * 249 | * @param \Closure $callback 250 | * @return mixed 251 | */ 252 | protected function withDuskEnvironment($callback) 253 | { 254 | $this->setupDuskEnvironment(); 255 | 256 | try { 257 | return $callback(); 258 | } finally { 259 | $this->teardownDuskEnviroment(); 260 | } 261 | } 262 | 263 | /** 264 | * Setup the Dusk environment. 265 | * 266 | * @return void 267 | */ 268 | protected function setupDuskEnvironment() 269 | { 270 | if (file_exists(base_path($this->duskFile()))) { 271 | if (file_exists(base_path('.env')) && 272 | file_get_contents(base_path('.env')) !== file_get_contents(base_path($this->duskFile()))) { 273 | $this->backupEnvironment(); 274 | } 275 | 276 | $this->refreshEnvironment(); 277 | } 278 | 279 | $this->writeConfiguration(); 280 | 281 | $this->setupSignalHandler(); 282 | } 283 | 284 | /** 285 | * Backup the current environment file. 286 | * 287 | * @return void 288 | */ 289 | protected function backupEnvironment() 290 | { 291 | copy(base_path('.env'), base_path('.env.backup')); 292 | 293 | copy(base_path($this->duskFile()), base_path('.env')); 294 | } 295 | 296 | /** 297 | * Refresh the current environment variables. 298 | * 299 | * @return void 300 | */ 301 | protected function refreshEnvironment() 302 | { 303 | Dotenv::createMutable(base_path())->load(); 304 | } 305 | 306 | /** 307 | * Write the Dusk PHPUnit configuration. 308 | * 309 | * @return void 310 | */ 311 | protected function writeConfiguration() 312 | { 313 | if (! file_exists($file = base_path('phpunit.dusk.xml')) && 314 | ! file_exists(base_path('phpunit.dusk.xml.dist'))) { 315 | copy(realpath(__DIR__.'/../../stubs/phpunit.xml'), $file); 316 | 317 | return; 318 | } 319 | 320 | $this->hasPhpUnitConfiguration = true; 321 | } 322 | 323 | /** 324 | * Setup the SIGINT signal handler for CTRL+C exits. 325 | * 326 | * @return void 327 | */ 328 | protected function setupSignalHandler() 329 | { 330 | if (extension_loaded('pcntl')) { 331 | pcntl_async_signals(true); 332 | 333 | pcntl_signal(SIGINT, function () { 334 | $this->teardownDuskEnviroment(); 335 | }); 336 | } 337 | } 338 | 339 | /** 340 | * Restore the original environment. 341 | * 342 | * @return void 343 | */ 344 | protected function teardownDuskEnviroment() 345 | { 346 | $this->removeConfiguration(); 347 | 348 | if (file_exists(base_path($this->duskFile())) && file_exists(base_path('.env.backup'))) { 349 | $this->restoreEnvironment(); 350 | } 351 | } 352 | 353 | /** 354 | * Remove the Dusk PHPUnit configuration. 355 | * 356 | * @return void 357 | */ 358 | protected function removeConfiguration() 359 | { 360 | if (! $this->hasPhpUnitConfiguration && file_exists($file = base_path('phpunit.dusk.xml'))) { 361 | unlink($file); 362 | } 363 | } 364 | 365 | /** 366 | * Restore the backed-up environment file. 367 | * 368 | * @return void 369 | */ 370 | protected function restoreEnvironment() 371 | { 372 | copy(base_path('.env.backup'), base_path('.env')); 373 | 374 | unlink(base_path('.env.backup')); 375 | } 376 | 377 | /** 378 | * Get the name of the Dusk file for the environment. 379 | * 380 | * @return string 381 | */ 382 | protected function duskFile() 383 | { 384 | if (file_exists(base_path($file = '.env.dusk.'.$this->laravel->environment()))) { 385 | return $file; 386 | } 387 | 388 | return '.env.dusk'; 389 | } 390 | } 391 | -------------------------------------------------------------------------------- /src/Console/DuskFailsCommand.php: -------------------------------------------------------------------------------- 1 | createScreenshotsDirectory(); 46 | } 47 | 48 | if (! is_dir(base_path('tests/Browser/console'))) { 49 | $this->createConsoleDirectory(); 50 | } 51 | 52 | if (! is_dir(base_path('tests/Browser/source'))) { 53 | $this->createSourceDirectory(); 54 | } 55 | 56 | $stubs = [ 57 | 'HomePage.stub' => base_path('tests/Browser/Pages/HomePage.php'), 58 | 'DuskTestCase.stub' => base_path('tests/DuskTestCase.php'), 59 | 'Page.stub' => base_path('tests/Browser/Pages/Page.php'), 60 | ]; 61 | 62 | if ($this->usingPest()) { 63 | $stubs['ExampleTest.pest.stub'] = base_path('tests/Browser/ExampleTest.php'); 64 | 65 | $contents = file_get_contents(base_path('tests/Pest.php')); 66 | 67 | if (str_contains($contents, 'uses(')) { 68 | $contents = str_replace('in('Browser'); 75 | EOT, $contents); 76 | } else { 77 | $contents = str_replace('extend(Tests\DuskTestCase::class) 81 | // ->use(Illuminate\Foundation\Testing\DatabaseMigrations::class) 82 | ->in('Browser'); 83 | EOT, $contents); 84 | } 85 | 86 | file_put_contents(base_path('tests/Pest.php'), $contents); 87 | } else { 88 | $stubs['ExampleTest.stub'] = base_path('tests/Browser/ExampleTest.php'); 89 | } 90 | 91 | foreach ($stubs as $stub => $file) { 92 | if (! is_file($file)) { 93 | copy(__DIR__.'/../../stubs/'.$stub, $file); 94 | } 95 | } 96 | 97 | $baseTestCase = file_get_contents(base_path('tests/DuskTestCase.php')); 98 | 99 | if (! trait_exists(\Tests\CreatesApplication::class)) { 100 | file_put_contents(base_path('tests/DuskTestCase.php'), str_replace(<<<'EOT' 101 | { 102 | use CreatesApplication; 103 | 104 | EOT, <<<'EOT' 105 | { 106 | EOT, 107 | $baseTestCase, 108 | )); 109 | } 110 | 111 | $this->components->info('Dusk scaffolding installed successfully.'); 112 | 113 | $this->components->task('Downloading ChromeDriver binaries...', function () { 114 | $driverCommandArgs = []; 115 | 116 | if ($this->option('proxy')) { 117 | $driverCommandArgs['--proxy'] = $this->option('proxy'); 118 | } 119 | 120 | if ($this->option('ssl-no-verify')) { 121 | $driverCommandArgs['--ssl-no-verify'] = true; 122 | } 123 | 124 | $this->call('dusk:chrome-driver', $driverCommandArgs); 125 | }); 126 | } 127 | 128 | /** 129 | * Create the screenshots directory. 130 | * 131 | * @return void 132 | */ 133 | protected function createScreenshotsDirectory() 134 | { 135 | mkdir(base_path('tests/Browser/screenshots'), 0755, true); 136 | 137 | file_put_contents(base_path('tests/Browser/screenshots/.gitignore'), '* 138 | !.gitignore 139 | '); 140 | } 141 | 142 | /** 143 | * Create the console directory. 144 | * 145 | * @return void 146 | */ 147 | protected function createConsoleDirectory() 148 | { 149 | mkdir(base_path('tests/Browser/console'), 0755, true); 150 | 151 | file_put_contents(base_path('tests/Browser/console/.gitignore'), '* 152 | !.gitignore 153 | '); 154 | } 155 | 156 | /** 157 | * Create the source directory. 158 | * 159 | * @return void 160 | */ 161 | protected function createSourceDirectory() 162 | { 163 | mkdir(base_path('tests/Browser/source'), 0755, true); 164 | 165 | file_put_contents(base_path('tests/Browser/source/.gitignore'), '* 166 | !.gitignore 167 | '); 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /src/Console/MakeCommand.php: -------------------------------------------------------------------------------- 1 | usingPest() 43 | ? __DIR__.'/stubs/test.pest.stub' 44 | : __DIR__.'/stubs/test.stub'; 45 | } 46 | 47 | /** 48 | * Get the destination class path. 49 | * 50 | * @param string $name 51 | * @return string 52 | */ 53 | protected function getPath($name) 54 | { 55 | $name = Str::replaceFirst($this->rootNamespace(), '', $name); 56 | 57 | return $this->laravel->basePath().'/tests'.str_replace('\\', '/', $name).'.php'; 58 | } 59 | 60 | /** 61 | * Get the default namespace for the class. 62 | * 63 | * @param string $rootNamespace 64 | * @return string 65 | */ 66 | protected function getDefaultNamespace($rootNamespace) 67 | { 68 | return $rootNamespace.'\Browser'; 69 | } 70 | 71 | /** 72 | * Get the root namespace for the class. 73 | * 74 | * @return string 75 | */ 76 | protected function rootNamespace() 77 | { 78 | return 'Tests'; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Console/PageCommand.php: -------------------------------------------------------------------------------- 1 | argument('name'); 44 | 45 | $baseClass = 'Tests\Browser\Pages\Page'; 46 | 47 | if (! Str::contains($pageName, '/') && class_exists($baseClass)) { 48 | return $result; 49 | } elseif (! class_exists($baseClass)) { 50 | $baseClass = 'Laravel\Dusk\Page'; 51 | } 52 | 53 | $lineEndingCount = [ 54 | "\r\n" => substr_count($result, "\r\n"), 55 | "\r" => substr_count($result, "\r"), 56 | "\n" => substr_count($result, "\n"), 57 | ]; 58 | 59 | $eol = array_keys($lineEndingCount, max($lineEndingCount))[0]; 60 | 61 | return str_replace( 62 | 'use Laravel\Dusk\Browser;'.$eol, 63 | 'use Laravel\Dusk\Browser;'.$eol."use {$baseClass};".$eol, 64 | $result 65 | ); 66 | } 67 | 68 | /** 69 | * Get the stub file for the generator. 70 | * 71 | * @return string 72 | */ 73 | protected function getStub() 74 | { 75 | return __DIR__.'/stubs/page.stub'; 76 | } 77 | 78 | /** 79 | * Get the destination class path. 80 | * 81 | * @param string $name 82 | * @return string 83 | */ 84 | protected function getPath($name) 85 | { 86 | $name = Str::replaceFirst($this->rootNamespace(), '', $name); 87 | 88 | return $this->laravel->basePath().'/tests'.str_replace('\\', '/', $name).'.php'; 89 | } 90 | 91 | /** 92 | * Get the default namespace for the class. 93 | * 94 | * @param string $rootNamespace 95 | * @return string 96 | */ 97 | protected function getDefaultNamespace($rootNamespace) 98 | { 99 | return $rootNamespace.'\Browser\Pages'; 100 | } 101 | 102 | /** 103 | * Get the root namespace for the class. 104 | * 105 | * @return string 106 | */ 107 | protected function rootNamespace() 108 | { 109 | return 'Tests'; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/Console/PurgeCommand.php: -------------------------------------------------------------------------------- 1 | ignoreValidationErrors(); 37 | } 38 | 39 | /** 40 | * Execute the console command. 41 | * 42 | * @return mixed 43 | */ 44 | public function handle() 45 | { 46 | $this->purgeScreenshots(); 47 | $this->purgeConsoleLogs(); 48 | $this->purgeSourceLogs(); 49 | } 50 | 51 | /** 52 | * Purge the failure screenshots. 53 | * 54 | * @return void 55 | */ 56 | protected function purgeScreenshots() 57 | { 58 | $this->purgeDebuggingFiles( 59 | 'tests/Browser/screenshots', 'failure-*' 60 | ); 61 | } 62 | 63 | /** 64 | * Purge the console logs. 65 | * 66 | * @return void 67 | */ 68 | protected function purgeConsoleLogs() 69 | { 70 | $this->purgeDebuggingFiles( 71 | 'tests/Browser/console', '*.log' 72 | ); 73 | } 74 | 75 | /** 76 | * Purge the source logs. 77 | * 78 | * @return void 79 | */ 80 | protected function purgeSourceLogs() 81 | { 82 | $this->purgeDebuggingFiles( 83 | 'tests/Browser/source', '*.txt' 84 | ); 85 | } 86 | 87 | /** 88 | * Purge debugging files based on path and patterns. 89 | * 90 | * @param string $relativePath 91 | * @param string $patterns 92 | * @return void 93 | */ 94 | protected function purgeDebuggingFiles($relativePath, $patterns) 95 | { 96 | $path = base_path($relativePath); 97 | 98 | if (! is_dir($path)) { 99 | $this->components->warn( 100 | "Unable to purge missing directory [{$relativePath}].", OutputInterface::VERBOSITY_DEBUG 101 | ); 102 | 103 | return; 104 | } 105 | 106 | $files = Finder::create()->files() 107 | ->in($path) 108 | ->name($patterns); 109 | 110 | foreach ($files as $file) { 111 | @unlink($file->getRealPath()); 112 | } 113 | 114 | $this->components->info("Purged \"{$patterns}\" from [{$relativePath}]."); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/Console/stubs/component.stub: -------------------------------------------------------------------------------- 1 | assertVisible($this->selector()); 24 | } 25 | 26 | /** 27 | * Get the element shortcuts for the component. 28 | * 29 | * @return array 30 | */ 31 | public function elements(): array 32 | { 33 | return [ 34 | '@element' => '#selector', 35 | ]; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Console/stubs/page.stub: -------------------------------------------------------------------------------- 1 | assertPathIs($this->url()); 23 | } 24 | 25 | /** 26 | * Get the element shortcuts for the page. 27 | * 28 | * @return array 29 | */ 30 | public function elements(): array 31 | { 32 | return [ 33 | '@element' => '#selector', 34 | ]; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Console/stubs/test.pest.stub: -------------------------------------------------------------------------------- 1 | browse(function (Browser $browser) { 7 | $browser->visit('/') 8 | ->assertSee('Laravel'); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /src/Console/stubs/test.stub: -------------------------------------------------------------------------------- 1 | browse(function (Browser $browser) { 17 | $browser->visit('/') 18 | ->assertSee('Laravel'); 19 | }); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Dusk.php: -------------------------------------------------------------------------------- 1 | register(DuskServiceProvider::class); 26 | } 27 | } 28 | 29 | /** 30 | * Determine if Dusk may run in this environment. 31 | * 32 | * @param array $options 33 | * @return bool 34 | * 35 | * @throws \InvalidArgumentException 36 | */ 37 | protected static function duskEnvironment($options) 38 | { 39 | if (! isset($options['environments'])) { 40 | return false; 41 | } 42 | 43 | if (is_string($options['environments'])) { 44 | $options['environments'] = [$options['environments']]; 45 | } 46 | 47 | if (! is_array($options['environments'])) { 48 | throw new InvalidArgumentException('Dusk environments must be listed as an array.'); 49 | } 50 | 51 | return app()->environment(...$options['environments']); 52 | } 53 | 54 | /** 55 | * Set the Dusk selector (@dusk) HTML attribute. 56 | * 57 | * @param string $attribute 58 | * @return void 59 | */ 60 | public static function selectorHtmlAttribute(string $attribute) 61 | { 62 | static::$selectorHtmlAttribute = $attribute; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/DuskServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->environment('production')) { 18 | Route::group(array_filter([ 19 | 'prefix' => config('dusk.path', '_dusk'), 20 | 'domain' => config('dusk.domain', null), 21 | 'middleware' => config('dusk.middleware', 'web'), 22 | ]), function () { 23 | Route::get('/login/{userId}/{guard?}', [ 24 | 'uses' => 'Laravel\Dusk\Http\Controllers\UserController@login', 25 | 'as' => 'dusk.login', 26 | ]); 27 | 28 | Route::get('/logout/{guard?}', [ 29 | 'uses' => 'Laravel\Dusk\Http\Controllers\UserController@logout', 30 | 'as' => 'dusk.logout', 31 | ]); 32 | 33 | Route::get('/user/{guard?}', [ 34 | 'uses' => 'Laravel\Dusk\Http\Controllers\UserController@user', 35 | 'as' => 'dusk.user', 36 | ]); 37 | }); 38 | } 39 | 40 | if ($this->app->runningInConsole()) { 41 | $this->commands([ 42 | Console\InstallCommand::class, 43 | Console\DuskCommand::class, 44 | Console\DuskFailsCommand::class, 45 | Console\MakeCommand::class, 46 | Console\PageCommand::class, 47 | Console\PurgeCommand::class, 48 | Console\ComponentCommand::class, 49 | Console\ChromeDriverCommand::class, 50 | ]); 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/ElementResolver.php: -------------------------------------------------------------------------------- 1 | 33 | */ 34 | public $elements = []; 35 | 36 | /** 37 | * The button finding methods. 38 | * 39 | * @var array 40 | */ 41 | protected $buttonFinders = [ 42 | 'findById', 43 | 'findButtonBySelector', 44 | 'findButtonByName', 45 | 'findButtonByValue', 46 | 'findButtonByText', 47 | ]; 48 | 49 | /** 50 | * Create a new element resolver instance. 51 | * 52 | * @param \Facebook\WebDriver\Remote\RemoteWebDriver $driver 53 | * @param string $prefix 54 | * @return void 55 | */ 56 | public function __construct($driver, $prefix = 'body') 57 | { 58 | $this->driver = $driver; 59 | $this->prefix = trim($prefix); 60 | } 61 | 62 | /** 63 | * Set the page elements the resolver should use as shortcuts. 64 | * 65 | * @param array $elements 66 | * @return $this 67 | */ 68 | public function pageElements(array $elements) 69 | { 70 | $this->elements = $elements; 71 | 72 | return $this; 73 | } 74 | 75 | /** 76 | * Resolve the element for a given input "field". 77 | * 78 | * @param string $field 79 | * @return \Facebook\WebDriver\Remote\RemoteWebElement 80 | * 81 | * @throws \Exception 82 | */ 83 | public function resolveForTyping($field) 84 | { 85 | if (! is_null($element = $this->findById($field))) { 86 | return $element; 87 | } 88 | 89 | return $this->firstOrFail([ 90 | "input[name='{$field}']", "textarea[name='{$field}']", $field, 91 | ]); 92 | } 93 | 94 | /** 95 | * Resolve the element for a given select "field". 96 | * 97 | * @param string $field 98 | * @return \Facebook\WebDriver\Remote\RemoteWebElement 99 | * 100 | * @throws \Exception 101 | */ 102 | public function resolveForSelection($field) 103 | { 104 | if (! is_null($element = $this->findById($field))) { 105 | return $element; 106 | } 107 | 108 | return $this->firstOrFail([ 109 | "select[name='{$field}']", $field, 110 | ]); 111 | } 112 | 113 | /** 114 | * Resolve all the options with the given value on the select field. 115 | * 116 | * @param string $field 117 | * @param array $values 118 | * @return \Facebook\WebDriver\Remote\RemoteWebElement[] 119 | * 120 | * @throws \Exception 121 | */ 122 | public function resolveSelectOptions($field, array $values) 123 | { 124 | $options = $this->resolveForSelection($field) 125 | ->findElements(WebDriverBy::tagName('option')); 126 | 127 | if (empty($options)) { 128 | return []; 129 | } 130 | 131 | return array_filter($options, function ($option) use ($values) { 132 | return in_array($option->getAttribute('value'), $values); 133 | }); 134 | } 135 | 136 | /** 137 | * Resolve the element for a given radio "field" / value. 138 | * 139 | * @param string $field 140 | * @param string|null $value 141 | * @return \Facebook\WebDriver\Remote\RemoteWebElement 142 | * 143 | * @throws \Exception 144 | * @throws \InvalidArgumentException 145 | */ 146 | public function resolveForRadioSelection($field, $value = null) 147 | { 148 | if (! is_null($element = $this->findById($field))) { 149 | return $element; 150 | } 151 | 152 | if (is_null($value)) { 153 | throw new InvalidArgumentException( 154 | "No value was provided for radio button [{$field}]." 155 | ); 156 | } 157 | 158 | return $this->firstOrFail([ 159 | "input[type=radio][name='{$field}'][value='{$value}']", $field, 160 | ]); 161 | } 162 | 163 | /** 164 | * Resolve the element for a given checkbox "field". 165 | * 166 | * @param string|null $field 167 | * @param string|null $value 168 | * @return \Facebook\WebDriver\Remote\RemoteWebElement 169 | * 170 | * @throws \Exception 171 | */ 172 | public function resolveForChecking($field, $value = null) 173 | { 174 | if (! is_null($element = $this->findById($field))) { 175 | return $element; 176 | } 177 | 178 | $selector = 'input[type=checkbox]'; 179 | 180 | if (! is_null($field)) { 181 | $selector .= "[name='{$field}']"; 182 | } 183 | 184 | if (! is_null($value)) { 185 | $selector .= "[value='{$value}']"; 186 | } 187 | 188 | return $this->firstOrFail([ 189 | $selector, $field, 190 | ]); 191 | } 192 | 193 | /** 194 | * Resolve the element for a given file "field". 195 | * 196 | * @param string $field 197 | * @return \Facebook\WebDriver\Remote\RemoteWebElement 198 | * 199 | * @throws \Exception 200 | */ 201 | public function resolveForAttachment($field) 202 | { 203 | if (! is_null($element = $this->findById($field))) { 204 | return $element; 205 | } 206 | 207 | return $this->firstOrFail([ 208 | "input[type=file][name='{$field}']", $field, 209 | ]); 210 | } 211 | 212 | /** 213 | * Resolve the element for a given "field". 214 | * 215 | * @param string $field 216 | * @return \Facebook\WebDriver\Remote\RemoteWebElement 217 | * 218 | * @throws \Exception 219 | */ 220 | public function resolveForField($field) 221 | { 222 | if (! is_null($element = $this->findById($field))) { 223 | return $element; 224 | } 225 | 226 | return $this->firstOrFail([ 227 | "input[name='{$field}']", "textarea[name='{$field}']", 228 | "select[name='{$field}']", "button[name='{$field}']", $field, 229 | ]); 230 | } 231 | 232 | /** 233 | * Resolve the element for a given button. 234 | * 235 | * @param string $button 236 | * @return \Facebook\WebDriver\Remote\RemoteWebElement 237 | * 238 | * @throws \InvalidArgumentException 239 | */ 240 | public function resolveForButtonPress($button) 241 | { 242 | foreach ($this->buttonFinders as $method) { 243 | if (! is_null($element = $this->{$method}($button))) { 244 | return $element; 245 | } 246 | } 247 | 248 | throw new InvalidArgumentException( 249 | "Unable to locate button [{$button}]." 250 | ); 251 | } 252 | 253 | /** 254 | * Resolve the element for a given button by selector. 255 | * 256 | * @param string $button 257 | * @return \Facebook\WebDriver\Remote\RemoteWebElement|null 258 | */ 259 | protected function findButtonBySelector($button) 260 | { 261 | if (! is_null($element = $this->find($button))) { 262 | return $element; 263 | } 264 | } 265 | 266 | /** 267 | * Resolve the element for a given button by name. 268 | * 269 | * @param string $button 270 | * @return \Facebook\WebDriver\Remote\RemoteWebElement|null 271 | */ 272 | protected function findButtonByName($button) 273 | { 274 | if (! is_null($element = $this->find("input[type=submit][name='{$button}']")) || 275 | ! is_null($element = $this->find("input[type=button][value='{$button}']")) || 276 | ! is_null($element = $this->find("button[name='{$button}']"))) { 277 | return $element; 278 | } 279 | } 280 | 281 | /** 282 | * Resolve the element for a given button by value. 283 | * 284 | * @param string $button 285 | * @return \Facebook\WebDriver\Remote\RemoteWebElement|null 286 | */ 287 | protected function findButtonByValue($button) 288 | { 289 | foreach ($this->all('input[type=submit]') as $element) { 290 | if ($element->getAttribute('value') === $button) { 291 | return $element; 292 | } 293 | } 294 | } 295 | 296 | /** 297 | * Resolve the element for a given button by text. 298 | * 299 | * @param string $button 300 | * @return \Facebook\WebDriver\Remote\RemoteWebElement|null 301 | */ 302 | protected function findButtonByText($button) 303 | { 304 | foreach ($this->all('button') as $element) { 305 | if (Str::contains($element->getText(), $button)) { 306 | return $element; 307 | } 308 | } 309 | } 310 | 311 | /** 312 | * Attempt to find the selector by ID. 313 | * 314 | * @param string $selector 315 | * @return \Facebook\WebDriver\Remote\RemoteWebElement|null 316 | */ 317 | protected function findById($selector) 318 | { 319 | if (preg_match('/^#[\w\-:]+$/', $selector)) { 320 | return $this->driver->findElement(WebDriverBy::id(substr($selector, 1))); 321 | } 322 | } 323 | 324 | /** 325 | * Find an element by the given selector or return null. 326 | * 327 | * @param string $selector 328 | * @return \Facebook\WebDriver\Remote\RemoteWebElement|null 329 | */ 330 | public function find($selector) 331 | { 332 | try { 333 | return $this->findOrFail($selector); 334 | } catch (Exception $e) { 335 | // 336 | } 337 | } 338 | 339 | /** 340 | * Get the first element matching the given selectors. 341 | * 342 | * @param array $selectors 343 | * @return \Facebook\WebDriver\Remote\RemoteWebElement 344 | * 345 | * @throws \Exception 346 | */ 347 | public function firstOrFail($selectors) 348 | { 349 | foreach ((array) $selectors as $selector) { 350 | try { 351 | return $this->findOrFail($selector); 352 | } catch (Exception $e) { 353 | // 354 | } 355 | } 356 | 357 | throw $e; 358 | } 359 | 360 | /** 361 | * Find an element by the given selector or throw an exception. 362 | * 363 | * @param string $selector 364 | * @return \Facebook\WebDriver\Remote\RemoteWebElement 365 | */ 366 | public function findOrFail($selector) 367 | { 368 | if (! is_null($element = $this->findById($selector))) { 369 | return $element; 370 | } 371 | 372 | return $this->driver->findElement( 373 | WebDriverBy::cssSelector($this->format($selector)) 374 | ); 375 | } 376 | 377 | /** 378 | * Find the elements by the given selector or return an empty array. 379 | * 380 | * @param string $selector 381 | * @return \Facebook\WebDriver\Remote\RemoteWebElement[] 382 | */ 383 | public function all($selector) 384 | { 385 | try { 386 | return $this->driver->findElements( 387 | WebDriverBy::cssSelector($this->format($selector)) 388 | ); 389 | } catch (Exception $e) { 390 | // 391 | } 392 | 393 | return []; 394 | } 395 | 396 | /** 397 | * Format the given selector with the current prefix. 398 | * 399 | * @param string $selector 400 | * @return string 401 | */ 402 | public function format($selector) 403 | { 404 | $sortedElements = collect($this->elements)->sortByDesc(function ($element, $key) { 405 | return strlen($key); 406 | })->toArray(); 407 | 408 | $selector = str_replace( 409 | array_keys($sortedElements), array_values($sortedElements), $originalSelector = $selector 410 | ); 411 | 412 | if (Str::startsWith($selector, '@') && $selector === $originalSelector) { 413 | $selector = preg_replace('/@(\S+)/', '['.Dusk::$selectorHtmlAttribute.'="$1"]', $selector); 414 | } 415 | 416 | return trim($this->prefix.' '.$selector); 417 | } 418 | } 419 | -------------------------------------------------------------------------------- /src/Http/Controllers/UserController.php: -------------------------------------------------------------------------------- 1 | user(); 20 | 21 | if (! $user) { 22 | return []; 23 | } 24 | 25 | return [ 26 | 'id' => $user->getAuthIdentifier(), 27 | 'className' => get_class($user), 28 | ]; 29 | } 30 | 31 | /** 32 | * Login using the given user ID / email. 33 | * 34 | * @param string $userId 35 | * @param string|null $guard 36 | * @return \Illuminate\Http\Response 37 | */ 38 | public function login($userId, $guard = null) 39 | { 40 | $guard = $guard ?: config('auth.defaults.guard'); 41 | 42 | $provider = Auth::guard($guard)->getProvider(); 43 | 44 | $user = Str::contains($userId, '@') 45 | ? $provider->retrieveByCredentials(['email' => $userId]) 46 | : $provider->retrieveById($userId); 47 | 48 | Auth::guard($guard)->login($user); 49 | 50 | return response(status: 204); 51 | } 52 | 53 | /** 54 | * Log the user out of the application. 55 | * 56 | * @param string|null $guard 57 | * @return \Illuminate\Http\Response 58 | */ 59 | public function logout($guard = null) 60 | { 61 | $guard = $guard ?: config('auth.defaults.guard'); 62 | 63 | Auth::guard($guard)->logout(); 64 | 65 | Session::forget('password_hash_'.$guard); 66 | 67 | return response(status: 204); 68 | } 69 | 70 | /** 71 | * Get the model for the given guard. 72 | * 73 | * @param string $guard 74 | * @return string 75 | */ 76 | protected function modelForGuard($guard) 77 | { 78 | $provider = config("auth.guards.{$guard}.provider"); 79 | 80 | return config("auth.providers.{$provider}.model"); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Keyboard.php: -------------------------------------------------------------------------------- 1 | browser = $browser; 33 | } 34 | 35 | /** 36 | * Press the key using keyboard. 37 | * 38 | * @return $this 39 | */ 40 | public function press($key) 41 | { 42 | $this->pressKey($key); 43 | 44 | return $this; 45 | } 46 | 47 | /** 48 | * Release the given pressed key. 49 | * 50 | * @return $this 51 | */ 52 | public function release($key) 53 | { 54 | $this->releaseKey($key); 55 | 56 | return $this; 57 | } 58 | 59 | /** 60 | * Type the given keys using keyboard. 61 | * 62 | * @param string|array $keys 63 | * @return $this 64 | */ 65 | public function type($keys) 66 | { 67 | $this->sendKeys($keys); 68 | 69 | return $this; 70 | } 71 | 72 | /** 73 | * Pause for the given amount of milliseconds. 74 | * 75 | * @param int $milliseconds 76 | * @return $this 77 | */ 78 | public function pause($milliseconds) 79 | { 80 | $this->browser->pause($milliseconds); 81 | 82 | return $this; 83 | } 84 | 85 | /** 86 | * Dynamically call a method on the keyboard. 87 | * 88 | * @param string $method 89 | * @param array $parameters 90 | * @return mixed 91 | * 92 | * @throws \BadMethodCallException 93 | */ 94 | public function __call($method, $parameters) 95 | { 96 | if (static::hasMacro($method)) { 97 | return $this->macroCall($method, $parameters); 98 | } 99 | 100 | $keyboard = $this->browser->driver->getKeyboard(); 101 | 102 | if (method_exists($keyboard, $method)) { 103 | $response = $keyboard->{$method}(...$parameters); 104 | 105 | if ($response === $keyboard) { 106 | return $this; 107 | } else { 108 | return $response; 109 | } 110 | } 111 | 112 | throw new BadMethodCallException("Call to undefined keyboard method [{$method}]."); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/OperatingSystem.php: -------------------------------------------------------------------------------- 1 | }> 14 | */ 15 | protected static $platforms = [ 16 | 'linux' => [ 17 | 'slug' => 'linux64', 18 | 'commands' => [ 19 | '/usr/bin/google-chrome --version', 20 | '/usr/bin/chromium-browser --version', 21 | '/usr/bin/chromium --version', 22 | '/usr/bin/google-chrome-stable --version', 23 | ], 24 | ], 25 | 'mac' => [ 26 | 'slug' => 'mac-x64', 27 | 'commands' => [ 28 | '/Applications/Google\ Chrome\ for\ Testing.app/Contents/MacOS/Google\ Chrome\ for\ Testing --version', 29 | '/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --version', 30 | ], 31 | ], 32 | 'mac-intel' => [ 33 | 'slug' => 'mac-x64', 34 | 'commands' => [ 35 | '/Applications/Google\ Chrome\ for\ Testing.app/Contents/MacOS/Google\ Chrome\ for\ Testing --version', 36 | '/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --version', 37 | ], 38 | ], 39 | 'mac-arm' => [ 40 | 'slug' => 'mac-arm64', 41 | 'commands' => [ 42 | '/Applications/Google\ Chrome\ for\ Testing.app/Contents/MacOS/Google\ Chrome\ for\ Testing --version', 43 | '/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --version', 44 | ], 45 | ], 46 | 'win' => [ 47 | 'slug' => 'win32', 48 | 'commands' => [ 49 | 'reg query "HKEY_CURRENT_USER\Software\Google\Chrome\BLBeacon" /v version', 50 | ], 51 | ], 52 | ]; 53 | 54 | /** 55 | * Resolve the Chrome version commands for the given operating system. 56 | * 57 | * @param string $operatingSystem 58 | * @return array 59 | */ 60 | public static function chromeVersionCommands($operatingSystem) 61 | { 62 | $commands = static::$platforms[$operatingSystem]['commands'] ?? null; 63 | 64 | if (is_null($commands)) { 65 | throw new InvalidArgumentException("Unable to find commands for Operating System [{$operatingSystem}]"); 66 | } 67 | 68 | return $commands; 69 | } 70 | 71 | /** 72 | * Resolve the ChromeDriver slug for the given operating system. 73 | * 74 | * @param string $operatingSystem 75 | * @param string|null $version 76 | * @return string 77 | */ 78 | public static function chromeDriverSlug($operatingSystem, $version = null) 79 | { 80 | $slug = static::$platforms[$operatingSystem]['slug'] ?? null; 81 | 82 | if (is_null($slug)) { 83 | throw new InvalidArgumentException("Unable to find ChromeDriver slug for Operating System [{$operatingSystem}]"); 84 | } 85 | 86 | if (! is_null($version) && version_compare($version, '115.0', '<')) { 87 | if ($slug === 'mac-arm64') { 88 | return version_compare($version, '106.0.5249', '<') ? 'mac64_m1' : 'mac_arm64'; 89 | } elseif ($slug === 'mac-x64') { 90 | return 'mac64'; 91 | } 92 | } 93 | 94 | return $slug; 95 | } 96 | 97 | /** 98 | * Get all supported operating systems. 99 | * 100 | * @return array 101 | */ 102 | public static function all() 103 | { 104 | return array_keys(static::$platforms); 105 | } 106 | 107 | /** 108 | * Get the current operating system identifier. 109 | * 110 | * @return string 111 | */ 112 | public static function id() 113 | { 114 | if (static::onWindows()) { 115 | return 'win'; 116 | } elseif (static::onMac()) { 117 | return static::macArchitectureId(); 118 | } 119 | 120 | return 'linux'; 121 | } 122 | 123 | /** 124 | * Determine if the operating system is Windows or Windows Subsystem for Linux. 125 | * 126 | * @return bool 127 | */ 128 | public static function onWindows() 129 | { 130 | return PHP_OS === 'WINNT' || Str::contains(php_uname(), 'Microsoft'); 131 | } 132 | 133 | /** 134 | * Determine if the operating system is macOS. 135 | * 136 | * @return bool 137 | */ 138 | public static function onMac() 139 | { 140 | return PHP_OS === 'Darwin'; 141 | } 142 | 143 | /** 144 | * Get the current macOS platform architecture. 145 | * 146 | * @return string 147 | */ 148 | public static function macArchitectureId() 149 | { 150 | switch (php_uname('m')) { 151 | case 'arm64': 152 | return 'mac-arm'; 153 | case 'x86_64': 154 | return 'mac-intel'; 155 | default: 156 | return 'mac'; 157 | } 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/Page.php: -------------------------------------------------------------------------------- 1 | baseUrl(); 26 | 27 | Browser::$storeScreenshotsAt = base_path('tests/Browser/screenshots'); 28 | 29 | Browser::$storeConsoleLogAt = base_path('tests/Browser/console'); 30 | 31 | Browser::$storeSourceAt = base_path('tests/Browser/source'); 32 | 33 | Browser::$userResolver = function () { 34 | return $this->user(); 35 | }; 36 | } 37 | 38 | /** 39 | * Create the RemoteWebDriver instance. 40 | * 41 | * @return \Facebook\WebDriver\Remote\RemoteWebDriver 42 | */ 43 | protected function driver() 44 | { 45 | return RemoteWebDriver::create( 46 | $_ENV['DUSK_DRIVER_URL'] ?? env('DUSK_DRIVER_URL') ?? 'http://localhost:9515', 47 | DesiredCapabilities::chrome() 48 | ); 49 | } 50 | 51 | /** 52 | * Determine the application's base URL. 53 | * 54 | * @return string 55 | */ 56 | protected function baseUrl() 57 | { 58 | return rtrim(config('app.url'), '/'); 59 | } 60 | 61 | /** 62 | * Return the default user to authenticate. 63 | * 64 | * @return \App\User|int|null 65 | * 66 | * @throws \Exception 67 | */ 68 | protected function user() 69 | { 70 | throw new Exception('User resolver has not been set.'); 71 | } 72 | 73 | /** 74 | * Determine whether the Dusk command has disabled headless mode. 75 | */ 76 | protected function hasHeadlessDisabled(): bool 77 | { 78 | return isset($_SERVER['DUSK_HEADLESS_DISABLED']) || 79 | isset($_ENV['DUSK_HEADLESS_DISABLED']); 80 | } 81 | 82 | /** 83 | * Determine if the browser window should start maximized. 84 | */ 85 | protected function shouldStartMaximized(): bool 86 | { 87 | return isset($_SERVER['DUSK_START_MAXIMIZED']) || 88 | isset($_ENV['DUSK_START_MAXIMIZED']); 89 | } 90 | 91 | /** 92 | * Determine if the tests are running within Laravel Sail. 93 | * 94 | * @return bool 95 | */ 96 | protected static function runningInSail() 97 | { 98 | return isset($_ENV['LARAVEL_SAIL']) && $_ENV['LARAVEL_SAIL'] == '1'; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /stubs/DuskTestCase.stub: -------------------------------------------------------------------------------- 1 | addArguments(collect([ 33 | $this->shouldStartMaximized() ? '--start-maximized' : '--window-size=1920,1080', 34 | '--disable-search-engine-choice-screen', 35 | '--disable-smooth-scrolling', 36 | ])->unless($this->hasHeadlessDisabled(), function (Collection $items) { 37 | return $items->merge([ 38 | '--disable-gpu', 39 | '--headless=new', 40 | ]); 41 | })->all()); 42 | 43 | return RemoteWebDriver::create( 44 | $_ENV['DUSK_DRIVER_URL'] ?? env('DUSK_DRIVER_URL') ?? 'http://localhost:9515', 45 | DesiredCapabilities::chrome()->setCapability( 46 | ChromeOptions::CAPABILITY, $options 47 | ) 48 | ); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /stubs/ExampleTest.pest.stub: -------------------------------------------------------------------------------- 1 | browse(function (Browser $browser) { 7 | $browser->visit('/') 8 | ->assertSee('Laravel'); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /stubs/ExampleTest.stub: -------------------------------------------------------------------------------- 1 | browse(function (Browser $browser) { 17 | $browser->visit('/') 18 | ->assertSee('Laravel'); 19 | }); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /stubs/HomePage.stub: -------------------------------------------------------------------------------- 1 | 29 | */ 30 | public function elements(): array 31 | { 32 | return [ 33 | '@element' => '#selector', 34 | ]; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /stubs/Page.stub: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | public static function siteElements(): array 15 | { 16 | return [ 17 | '@element' => '#selector', 18 | ]; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /stubs/phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 12 | ./tests/Browser 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /workbench/resources/views/wait-for-text-in.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /workbench/routes/web.php: -------------------------------------------------------------------------------- 1 |