├── 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 |
2 |
3 |
4 |
5 |
6 |
7 |
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 |
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 |