├── src
├── Support
│ ├── Facades
│ │ └── .gitkeep
│ ├── helpers.php
│ ├── Debugger.php
│ ├── Selector.php
│ ├── ArtisanExecutableFinder.php
│ ├── BackgroundProcess.php
│ ├── ResponseQueue.php
│ ├── ProcessManager.php
│ └── Broker.php
├── Contracts
│ ├── BrowserCommand.php
│ └── ValueCommand.php
├── Browser
│ ├── Commands
│ │ ├── QuitBrowser.php
│ │ ├── Navigate
│ │ │ ├── Back.php
│ │ │ ├── Forward.php
│ │ │ └── Refresh.php
│ │ ├── Window
│ │ │ ├── CloseBrowserWindow.php
│ │ │ ├── Maximize.php
│ │ │ ├── SetPosition.php
│ │ │ ├── Resize.php
│ │ │ ├── OpenNewWindow.php
│ │ │ └── FitContent.php
│ │ ├── Concerns
│ │ │ ├── InteractsWithBrowserInstance.php
│ │ │ ├── UsesSelectors.php
│ │ │ └── NormalizesStoragePaths.php
│ │ ├── Dialogs
│ │ │ ├── AcceptDialog.php
│ │ │ ├── DismissDialog.php
│ │ │ └── TypeInDialog.php
│ │ ├── Cookies
│ │ │ ├── DeleteAllCookies.php
│ │ │ ├── DeleteCookie.php
│ │ │ └── AddCookie.php
│ │ ├── Manage
│ │ │ ├── GetPageSource.php
│ │ │ ├── SwitchTo.php
│ │ │ └── GetLog.php
│ │ ├── Mouse
│ │ │ ├── ReleaseMouse.php
│ │ │ ├── MoveByOffset.php
│ │ │ ├── MouseOver.php
│ │ │ ├── DoubleClick.php
│ │ │ ├── RightClick.php
│ │ │ ├── ClickAndHold.php
│ │ │ ├── DragAndDrop.php
│ │ │ └── DragAndDropBy.php
│ │ ├── SendRemoteWebDriverResponse.php
│ │ ├── Elements
│ │ │ ├── Clear.php
│ │ │ ├── ClickRadio.php
│ │ │ ├── GetText.php
│ │ │ ├── GetAttribute.php
│ │ │ ├── Attach.php
│ │ │ ├── GetValue.php
│ │ │ ├── GetSelected.php
│ │ │ ├── SetValue.php
│ │ │ ├── CheckOrUncheck.php
│ │ │ ├── Click.php
│ │ │ ├── SendKeys.php
│ │ │ └── Select.php
│ │ ├── ExecuteScript.php
│ │ ├── Assertions
│ │ │ ├── Concerns
│ │ │ │ ├── ChecksElementVisibility.php
│ │ │ │ └── DecryptsCookies.php
│ │ │ ├── AssertTitle.php
│ │ │ ├── AssertSourceHas.php
│ │ │ ├── AssertSourceMissing.php
│ │ │ ├── AssertTitleContains.php
│ │ │ ├── AssertScript.php
│ │ │ ├── AssertSeeAnythingIn.php
│ │ │ ├── AssertSeeNothingIn.php
│ │ │ ├── AssertCookieMissing.php
│ │ │ ├── AssertSeeIn.php
│ │ │ ├── AssertDontSeeIn.php
│ │ │ ├── AssertDialogOpened.php
│ │ │ ├── AssertAttributeMissing.php
│ │ │ ├── AssertOptionSelectionState.php
│ │ │ ├── AssertHasCookie.php
│ │ │ ├── AssertSelectionState.php
│ │ │ ├── AssertOptionPresence.php
│ │ │ ├── AssertLinkVisibility.php
│ │ │ ├── AssertVue.php
│ │ │ ├── BrowserAssertionCommand.php
│ │ │ ├── AssertInputValue.php
│ │ │ ├── AssertQueryStringHas.php
│ │ │ ├── AssertHasClass.php
│ │ │ ├── AssertAttribute.php
│ │ │ ├── AssertValue.php
│ │ │ ├── AssertUrl.php
│ │ │ └── AssertElementStatus.php
│ │ ├── Wait
│ │ │ ├── WaitForUrl.php
│ │ │ ├── WaitForEvent.php
│ │ │ └── WaitUsing.php
│ │ ├── TakeScreenshot.php
│ │ ├── BrowserCommand.php
│ │ └── Visit.php
│ ├── Helpers
│ │ ├── Livewire.php
│ │ └── Vue.php
│ ├── Concerns
│ │ ├── ExecutesCommands.php
│ │ ├── ExecutesNavigateCommands.php
│ │ ├── ExecutesDialogCommands.php
│ │ ├── ExecutesManageCommands.php
│ │ ├── ExecutesBrowserCommands.php
│ │ ├── ExecutesCookieCommands.php
│ │ ├── ExecutesWaitCommands.php
│ │ ├── ExecutesWindowCommands.php
│ │ ├── ExecutesMouseCommands.php
│ │ ├── ExecutesElementCommands.php
│ │ └── ExecutesAssertionCommands.php
│ ├── ManagedDriver.php
│ ├── PendingWait.php
│ ├── RemoteWebDriverProcess.php
│ ├── RemoteWebDriverBroker.php
│ ├── SeleniumDriverProcess.php
│ └── BrowserManager.php
├── Exceptions
│ ├── UnableToInstantiateCommandFromData.php
│ ├── UnexpectedCommandType.php
│ └── WebDriverNotRunningException.php
├── IO
│ ├── Commands
│ │ ├── Notice.php
│ │ └── ThrowException.php
│ ├── Command.php
│ ├── CommandIO.php
│ └── CommandBuffer.php
├── Console
│ └── Commands
│ │ ├── DriveCommand.php
│ │ ├── ServeCommand.php
│ │ └── GenerateCommandHelpersCommand.php
├── Concerns
│ └── SendsAndReceivesCommands.php
├── RunsBrowserTests.php
├── Http
│ ├── WebServerProcess.php
│ ├── WebServerBroker.php
│ ├── Commands
│ │ ├── SendWebResponse.php
│ │ └── HandleWebRequest.php
│ └── Relays
│ │ └── LocalHttpCommandRelay.php
├── Browser.php
└── Providers
│ └── DawnServiceProvider.php
├── testbench.yaml
├── tests
├── resources
│ └── test-upload.jpg
├── views
│ ├── see.blade.php
│ ├── reload.blade.php
│ ├── upload.blade.php
│ ├── basic-alpine.blade.php
│ ├── layout.blade.php
│ ├── mouse.blade.php
│ └── resolvers
│ │ └── buttons.blade.php
├── Feature
│ ├── SeeAssertionsTest.php
│ ├── MouseTest.php
│ ├── BrowserTest.php
│ ├── UploadTest.php
│ ├── ElementResolutionTest.php
│ └── WaitTest.php
└── TestCase.php
├── .idea
├── vcs.xml
├── .gitignore
├── scopes
│ └── Browser_Commands.xml
├── phpunit.xml
├── modules.xml
├── laravel-idea-personal.xml
├── php-test-framework.xml
├── laravel-idea.xml
├── watcherTasks.xml
└── inspectionProfiles
│ └── Project_Default.xml
├── .gitignore
├── .codeclimate.yml
├── config.php
├── bin
└── pre-commit.sh
├── .github
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
└── workflows
│ ├── update-changelog.yml
│ ├── php-cs-fixer.yml
│ ├── coverage.yml
│ └── phpunit.yml
├── CHANGELOG.md
├── phpunit.xml
├── LICENSE
├── composer.json
├── config
└── dawn.php
├── .php-cs-fixer.dist.php
└── README.md
/src/Support/Facades/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/testbench.yaml:
--------------------------------------------------------------------------------
1 | providers:
2 | - Glhd\Dawn\Providers\DawnServiceProvider
3 |
--------------------------------------------------------------------------------
/tests/resources/test-upload.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/glhd/dawn/HEAD/tests/resources/test-upload.jpg
--------------------------------------------------------------------------------
/src/Support/helpers.php:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 | # Datasource local storage ignored files
5 | /dataSources/
6 | /dataSources.local.xml
7 | # Editor-based HTTP Client requests
8 | /httpRequests/
9 |
--------------------------------------------------------------------------------
/src/Contracts/BrowserCommand.php:
--------------------------------------------------------------------------------
1 |
5 | Visible Link
6 |
7 |
8 | Hidden Link
9 |
10 | @endsection
11 |
--------------------------------------------------------------------------------
/.idea/scopes/Browser_Commands.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/tests/views/reload.blade.php:
--------------------------------------------------------------------------------
1 | @extends('layout')
2 |
3 | @section('content')
4 |
{{ Str::random() }}
5 | Reload
6 | @endsection
7 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | vendor/
2 | composer.phar
3 | composer.lock
4 | .phpunit.result.cache
5 | .php-cs-fixer.cache
6 |
7 | .DS_Store
8 | .phpstorm.meta.php
9 | _ide_helper.php
10 |
11 | node_modules
12 | mix-manifest.json
13 | yarn-error.log
14 |
15 | dusk/
16 |
--------------------------------------------------------------------------------
/.codeclimate.yml:
--------------------------------------------------------------------------------
1 | version: "2"
2 | exclude_patterns:
3 | - ".github/"
4 | - ".idea/"
5 | - "stubs/"
6 | - "tests/"
7 | - "**/vendor/"
8 | - "**/node_modules/"
9 | - "*.md"
10 | - ".*.yml"
11 | - "LICENSE"
12 | - "composer.json"
13 | - "phpunit.xml"
14 |
--------------------------------------------------------------------------------
/config.php:
--------------------------------------------------------------------------------
1 |
5 | @csrf
6 |
7 | Upload
8 |
9 | @endsection
10 |
--------------------------------------------------------------------------------
/.idea/phpunit.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/src/Contracts/ValueCommand.php:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/src/Browser/Commands/QuitBrowser.php:
--------------------------------------------------------------------------------
1 | quitAll();
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/tests/views/basic-alpine.blade.php:
--------------------------------------------------------------------------------
1 | @extends('layout')
2 |
3 | @section('content')
4 |
5 |
6 |
7 | Hello
8 |
9 |
10 | @endsection
11 |
--------------------------------------------------------------------------------
/.idea/laravel-idea-personal.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Navigate/Back.php:
--------------------------------------------------------------------------------
1 | navigate()->back();
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/Support/Debugger.php:
--------------------------------------------------------------------------------
1 | callback) {
17 | call_user_func($this->callback, $message);
18 | }
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Navigate/Forward.php:
--------------------------------------------------------------------------------
1 | navigate()->forward();
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Navigate/Refresh.php:
--------------------------------------------------------------------------------
1 | navigate()->refresh();
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Window/CloseBrowserWindow.php:
--------------------------------------------------------------------------------
1 | close();
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Window/Maximize.php:
--------------------------------------------------------------------------------
1 | manage()->window()->maximize();
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Concerns/InteractsWithBrowserInstance.php:
--------------------------------------------------------------------------------
1 | browser_id = $browser->id;
14 |
15 | return $this;
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Dialogs/AcceptDialog.php:
--------------------------------------------------------------------------------
1 | switchTo()->alert()->accept();
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Dialogs/DismissDialog.php:
--------------------------------------------------------------------------------
1 | switchTo()->alert()->dismiss();
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Cookies/DeleteAllCookies.php:
--------------------------------------------------------------------------------
1 | manage()->deleteAllCookies();
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/Browser/Helpers/Livewire.php:
--------------------------------------------------------------------------------
1 | executeScript('return !! window.Livewire.components.initialRenderIsFinished');
14 | };
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/.idea/php-test-framework.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/src/Exceptions/UnableToInstantiateCommandFromData.php:
--------------------------------------------------------------------------------
1 | debug($this->message);
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/.idea/laravel-idea.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Manage/GetPageSource.php:
--------------------------------------------------------------------------------
1 | getPageSource();
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Mouse/ReleaseMouse.php:
--------------------------------------------------------------------------------
1 | driver))->release()->perform();
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/src/Exceptions/UnexpectedCommandType.php:
--------------------------------------------------------------------------------
1 | command),
16 | get_debug_type($result)
17 | ));
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Cookies/DeleteCookie.php:
--------------------------------------------------------------------------------
1 | manage()->deleteCookieNamed($this->name);
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Dialogs/TypeInDialog.php:
--------------------------------------------------------------------------------
1 | switchTo()->alert()->sendKeys($this->value);
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/Browser/Commands/SendRemoteWebDriverResponse.php:
--------------------------------------------------------------------------------
1 | addToResponseQueue($this->request_id, $this->response);
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/tests/Feature/SeeAssertionsTest.php:
--------------------------------------------------------------------------------
1 | openBrowser()
18 | ->visit('/')
19 | ->assertSeeLink('Visible Link')
20 | ->assertDontSeeLink('Hidden Link');
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/tests/views/layout.blade.php:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Dawn
6 |
7 |
8 |
9 |
10 |
11 |
12 | @yield('content')
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Elements/Clear.php:
--------------------------------------------------------------------------------
1 | resolver->resolveForTyping($this->selector)->clear();
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Elements/ClickRadio.php:
--------------------------------------------------------------------------------
1 | resolver->resolveForRadioSelection($this->selector)->click();
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/Browser/Commands/ExecuteScript.php:
--------------------------------------------------------------------------------
1 | scripts = collect((array) $scripts);
15 | }
16 |
17 | protected function executeWithBrowser(BrowserManager $manager)
18 | {
19 | $this->scripts->each(fn($script) => $manager->executeScript($script));
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/Exceptions/WebDriverNotRunningException.php:
--------------------------------------------------------------------------------
1 | getMessage();
15 |
16 | parent::__construct($message, previous: $previous);
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Window/SetPosition.php:
--------------------------------------------------------------------------------
1 | manage()
20 | ->window()
21 | ->setPosition(new WebDriverPoint($this->x, $this->y));
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Window/Resize.php:
--------------------------------------------------------------------------------
1 | manage()
20 | ->window()
21 | ->setSize(new WebDriverDimension($this->width, $this->height));
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Mouse/MoveByOffset.php:
--------------------------------------------------------------------------------
1 | driver))
20 | ->moveByOffset($this->x, $this->y)
21 | ->perform();
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/Console/Commands/DriveCommand.php:
--------------------------------------------------------------------------------
1 | argument('url'));
20 | } catch (Throwable $exception) {
21 | $this->line(new ThrowException($exception));
22 | }
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/bin/pre-commit.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | # Move into project root
4 | BIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5 | cd "$BIN_DIR"
6 | cd ..
7 |
8 | # Exit on errors
9 | set -e
10 |
11 | CHANGED_FILES=$(git diff --cached --name-only --diff-filter=ACM -- '***.php')
12 |
13 | if [[ -z "$CHANGED_FILES" ]]; then
14 | echo 'No changed files'
15 | exit 0
16 | fi
17 |
18 | if [[ -x vendor/bin/php-cs-fixer ]]; then
19 | vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php $CHANGED_FILES
20 | git add $CHANGED_FILES
21 | else
22 | echo 'PHP-CS-Fixer is not installed'
23 | exit 1
24 | fi
25 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: bug
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **What version does this affect?**
14 | - Laravel Version: [e.g. 5.8.0]
15 | - Package Version: [e.g. 1.5.0]
16 |
17 | **To Reproduce**
18 | Steps to reproduce the behavior:
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Additional context**
24 | Add any other context about the problem here.
25 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Elements/GetText.php:
--------------------------------------------------------------------------------
1 | resolver->findOrFail($this->selector)->getText();
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Assertions/Concerns/ChecksElementVisibility.php:
--------------------------------------------------------------------------------
1 | executeScript($script, [$element]);
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/Browser/Concerns/ExecutesCommands.php:
--------------------------------------------------------------------------------
1 | getWindowHandles();
14 |
15 | $manager->switchTo()->newWindow(WebDriverTargetLocator::WINDOW_TYPE_WINDOW);
16 |
17 | $new_handles = array_diff($manager->getWindowHandles(), $existing_handles);
18 |
19 | return reset($new_handles);
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Elements/GetAttribute.php:
--------------------------------------------------------------------------------
1 | resolver->findOrFail($this->selector)
21 | ->getAttribute($this->attribute);
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/Browser/ManagedDriver.php:
--------------------------------------------------------------------------------
1 | resolver = new ElementResolver($this->driver);
22 | }
23 |
24 | public function __call(string $name, array $arguments)
25 | {
26 | return $this->forwardDecoratedCallTo($this->driver, $name, $arguments);
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Wait/WaitForUrl.php:
--------------------------------------------------------------------------------
1 | url = url($url);
18 | }
19 |
20 | protected function executeWithBrowser(\Glhd\Dawn\Browser\BrowserManager $manager)
21 | {
22 | $manager->wait($this->timeout, $this->interval)
23 | ->until(WebDriverExpectedCondition::urlIs($this->url));
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/Support/Selector.php:
--------------------------------------------------------------------------------
1 | getMechanism()) {
23 | 'css selector' => $selector->getValue(),
24 | default => "{$selector->getMechanism()} '{$selector->getValue()}'",
25 | };
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Elements/Attach.php:
--------------------------------------------------------------------------------
1 | resolver->resolveForAttachment($this->selector);
21 |
22 | $element->setFileDetector((new LocalFileDetector()))->sendKeys($this->path);
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest a new feature idea or improvement
4 | title: ''
5 | labels: enhancement
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
19 | **Additional context**
20 | Add any other context or screenshots about the feature request here.
21 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Concerns/UsesSelectors.php:
--------------------------------------------------------------------------------
1 | selector;
18 | }
19 |
20 | if (is_string($selector)) {
21 | $selector = WebDriverBy::cssSelector($selector);
22 | }
23 |
24 | return $selector;
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/Browser/Commands/TakeScreenshot.php:
--------------------------------------------------------------------------------
1 | filename = $this->prepareAndNormalizeStoragePath(
17 | filename: $filename,
18 | directory: config('dawn.storage_screenshots', resource_path('dawn/screenshots')),
19 | );
20 | }
21 |
22 | protected function executeWithBrowser(BrowserManager $manager)
23 | {
24 | $manager->takeScreenshot($this->filename);
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Mouse/MouseOver.php:
--------------------------------------------------------------------------------
1 | by = $this->selector($by);
19 | }
20 |
21 | protected function executeWithBrowser(BrowserManager $manager)
22 | {
23 | $element = $manager->findElement($this->by);
24 |
25 | $manager->getMouse()->mouseMove($element->getCoordinates());
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Concerns/NormalizesStoragePaths.php:
--------------------------------------------------------------------------------
1 | isPathAbsolute($filename)) {
14 | $filename = rtrim($directory, '/').'/'.ltrim($filename, '/');
15 | }
16 |
17 | $fs->ensureDirectoryExists($fs->dirname($filename));
18 |
19 | return $filename;
20 | }
21 |
22 | protected function isPathAbsolute(string $path): bool
23 | {
24 | return (bool) preg_match('#([a-z]:)?[/\\\\]|[a-z][a-z0-9+.-]*://#Ai', $path);
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Elements/GetValue.php:
--------------------------------------------------------------------------------
1 | resolver->findOrFail($this->selector);
23 |
24 | return $element->getAttribute('value');
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/Console/Commands/ServeCommand.php:
--------------------------------------------------------------------------------
1 | argument('public_path') ?? public_path(),
21 | host: $this->argument('host'),
22 | port: (int) $this->argument('port'),
23 | );
24 | } catch (Throwable $exception) {
25 | $this->line(new ThrowException($exception));
26 | }
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Mouse/DoubleClick.php:
--------------------------------------------------------------------------------
1 | selector)
20 | ? $this->selector
21 | : $manager->resolver->findOrFail($this->selector);
22 |
23 | (new WebDriverActions($manager->driver))
24 | ->doubleClick($element)
25 | ->perform();
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Mouse/RightClick.php:
--------------------------------------------------------------------------------
1 | selector)
20 | ? $this->selector
21 | : $manager->resolver->findOrFail($this->selector);
22 |
23 | (new WebDriverActions($manager->driver))
24 | ->contextClick($element)
25 | ->perform();
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Mouse/ClickAndHold.php:
--------------------------------------------------------------------------------
1 | selector)
20 | ? $this->selector
21 | : $manager->resolver->findOrFail($this->selector);
22 |
23 | (new WebDriverActions($manager->driver))
24 | ->clickAndHold($element)
25 | ->perform();
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/Concerns/SendsAndReceivesCommands.php:
--------------------------------------------------------------------------------
1 | io->sendCommand($command);
18 |
19 | return $this;
20 | }
21 |
22 | public function sendNotice(string $message): static
23 | {
24 | return $this->sendCommand(new Notice($message));
25 | }
26 |
27 | public function sendException(string|Throwable $exception): static
28 | {
29 | return $this->sendCommand(new ThrowException($exception));
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Mouse/DragAndDrop.php:
--------------------------------------------------------------------------------
1 | driver))
21 | ->dragAndDrop(
22 | source: $manager->resolver->findOrFail($this->from),
23 | target: $manager->resolver->findOrFail($this->to),
24 | )
25 | ->perform();
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/Browser/Concerns/ExecutesNavigateCommands.php:
--------------------------------------------------------------------------------
1 | command(new Back());
19 | }
20 |
21 | public function forward(): static
22 | {
23 | return $this->command(new Forward());
24 | }
25 |
26 | public function refresh(): static
27 | {
28 | return $this->command(new Refresh());
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/.github/workflows/update-changelog.yml:
--------------------------------------------------------------------------------
1 | name: Update Changelog
2 |
3 | on:
4 | release:
5 | types: [ published ]
6 |
7 | jobs:
8 | update-publish:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - uses: actions/checkout@v2
12 | with:
13 | repository: ${{ github.event.repository.full_name }}
14 | ref: 'main'
15 |
16 | - name: Update changelog
17 | uses: thomaseizinger/keep-a-changelog-new-release@v1
18 | with:
19 | version: ${{ github.event.release.tag_name }}
20 |
21 | - name: Commit changelog back to repo
22 | uses: EndBug/add-and-commit@v8
23 | with:
24 | add: 'CHANGELOG.md'
25 | message: ${{ github.event.release.tag_name }}
26 | env:
27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
28 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Mouse/DragAndDropBy.php:
--------------------------------------------------------------------------------
1 | driver))
22 | ->dragAndDropBy(
23 | source: $manager->resolver->findOrFail($this->selector),
24 | x_offset: $this->x,
25 | y_offset: $this->y
26 | )
27 | ->perform();
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/src/Browser/Commands/BrowserCommand.php:
--------------------------------------------------------------------------------
1 | browser_manager->switchToBrowser($this->browser_id);
20 |
21 | $server->respond($this, $this->executeWithBrowser($server->browser_manager));
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Assertions/AssertTitle.php:
--------------------------------------------------------------------------------
1 | actual = $manager->getTitle();
21 | }
22 |
23 | protected function performAssertions(RemoteWebDriverBroker $broker): void
24 | {
25 | Assert::assertEquals(
26 | $this->expected,
27 | $this->actual,
28 | "Expected title [{$this->expected}] does not equal actual title [{$this->actual}]."
29 | );
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/tests/Feature/MouseTest.php:
--------------------------------------------------------------------------------
1 | openBrowser()
20 | ->visit('/')
21 | ->assertSeeIn('#status', 'N/A')
22 | ->clickAndHold('#click-and-hold')
23 | ->assertSeeIn('#status', 'Mouse Down')
24 | ->releaseMouse()
25 | ->assertSeeIn('#status', 'Mouse Up')
26 | ->doubleClick('#double-click')
27 | ->assertSeeIn('#status', '2')
28 | ->rightClick('#right-click')
29 | ->assertSeeIn('#status', 'Right Click');
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Assertions/AssertSourceHas.php:
--------------------------------------------------------------------------------
1 | haystack = $manager->getPageSource();
21 | }
22 |
23 | protected function performAssertions(RemoteWebDriverBroker $broker): void
24 | {
25 | Assert::assertStringContainsString(
26 | $this->needle,
27 | $this->haystack,
28 | "Did not find expected source code [{$this->needle}]."
29 | );
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Assertions/AssertSourceMissing.php:
--------------------------------------------------------------------------------
1 | haystack = $manager->getPageSource();
21 | }
22 |
23 | protected function performAssertions(RemoteWebDriverBroker $broker): void
24 | {
25 | Assert::assertStringNotContainsString(
26 | $this->needle,
27 | $this->haystack,
28 | "Found unexpected source code [{$this->needle}]."
29 | );
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Manage/SwitchTo.php:
--------------------------------------------------------------------------------
1 | locator) {
27 | 'frame' => $manager->switchTo()->frame($manager->resolver->findOrFail($this->selector)),
28 | default => $manager->switchTo()->{$this->locator}(),
29 | };
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/Support/ArtisanExecutableFinder.php:
--------------------------------------------------------------------------------
1 | cwd ??= base_path();
14 | }
15 |
16 | public function find(?string $in = null): ?string
17 | {
18 | $in ??= $this->cwd;
19 |
20 | if (file_exists("{$in}/composer.json")) {
21 | if (file_exists($path = "{$in}/artisan")) {
22 | return $path;
23 | }
24 | if (App::runningUnitTests() && file_exists($path = "{$in}/testbench")) {
25 | return $path;
26 | }
27 | }
28 |
29 | $parent = dirname($in);
30 | if ($parent !== $in) {
31 | return $this->find($parent);
32 | }
33 |
34 | return null;
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/tests/Feature/BrowserTest.php:
--------------------------------------------------------------------------------
1 | openBrowser();
18 |
19 | $browser
20 | ->visit('/')
21 | ->script('window.greeting = "Hello";')
22 | ->type('.name', 'Chris')
23 | ->press('.hello')
24 | ->assertDialogOpened('Hello Chris!')
25 | ->acceptDialog();
26 |
27 | $this->assertEquals('Chris', $browser->value('.name'));
28 |
29 | $browser->value('.name', 'Tim')
30 | ->press('.hello')
31 | ->assertDialogOpened('Hello Tim!');
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Assertions/AssertTitleContains.php:
--------------------------------------------------------------------------------
1 | actual = $manager->getTitle();
21 | }
22 |
23 | protected function performAssertions(RemoteWebDriverBroker $broker): void
24 | {
25 | Assert::assertStringContainsString(
26 | $this->expected,
27 | $this->actual,
28 | "Did not see expected text [{$this->expected}] within title [{$this->actual}]."
29 | );
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/Browser/Concerns/ExecutesDialogCommands.php:
--------------------------------------------------------------------------------
1 | command(new AcceptDialog());
19 | }
20 |
21 | public function dismissDialog(): static
22 | {
23 | return $this->command(new DismissDialog());
24 | }
25 |
26 | public function typeInDialog(string $value): static
27 | {
28 | return $this->command(new TypeInDialog($value));
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/RunsBrowserTests.php:
--------------------------------------------------------------------------------
1 | browsers->push($browser = $this->app->make(Browser::class));
16 |
17 | return $browser;
18 | }
19 |
20 | protected function setUpRunsBrowserTests(): void
21 | {
22 | $this->browsers = new Collection();
23 |
24 | // We want URLs in Dawn tests to go thru the Dawn proxy
25 | app(UrlGenerator::class)
26 | ->forceRootUrl(app(WebServerBroker::class)->url());
27 | }
28 |
29 | protected function tearDownRunsBrowserTests(): void
30 | {
31 | $this->browsers->each(fn(Browser $browser) => $browser->quit());
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/tests/views/mouse.blade.php:
--------------------------------------------------------------------------------
1 | @extends('layout')
2 |
3 | @section('content')
4 |
5 |
6 |
Click and Hold
7 |
Double Click
8 |
Right Click
9 |
23 |
24 | @endsection
25 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Assertions/Concerns/DecryptsCookies.php:
--------------------------------------------------------------------------------
1 | actual) {
13 | return null;
14 | }
15 |
16 | $value = $this->actual->getValue();
17 |
18 | if (! $this->decrypt) {
19 | return $value;
20 | }
21 |
22 | $decrypted = decrypt(rawurldecode($value), unserialize: false);
23 |
24 | return $this->shouldRemoveValuePrefix($decrypted)
25 | ? CookieValuePrefix::remove($decrypted)
26 | : $decrypted;
27 | }
28 |
29 | protected function shouldRemoveValuePrefix($decrypted): bool
30 | {
31 | return str_starts_with($decrypted, CookieValuePrefix::create($this->name, Crypt::getKey()));
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Elements/GetSelected.php:
--------------------------------------------------------------------------------
1 | resolver->resolveSelectOptions($this->selector, (array) $this->value);
25 |
26 | return collect($options)->contains(fn(RemoteWebElement $option) => $option->isSelected());
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes will be documented in this file following the [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
4 | format. This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5 |
6 | ## [Unreleased]
7 |
8 | ## [0.0.2] - 2022-09-23
9 |
10 | ## [0.0.1] - 2022-09-22
11 |
12 | ## [0.0.1]
13 |
14 | # Keep a Changelog Syntax
15 |
16 | - `Added` for new features.
17 | - `Changed` for changes in existing functionality.
18 | - `Deprecated` for soon-to-be removed features.
19 | - `Removed` for now removed features.
20 | - `Fixed` for any bug fixes.
21 | - `Security` in case of vulnerabilities.
22 |
23 | [Unreleased]: https://github.com/glhd/dawn/compare/0.0.2...HEAD
24 |
25 | [0.0.2]: https://github.com/glhd/dawn/compare/0.0.1...0.0.2
26 |
27 | [0.0.1]: https://github.com/glhd/dawn/compare/0.0.1...0.0.1
28 |
29 | [0.0.1]: https://github.com/glhd/dawn/compare/0.0.1...0.0.1
30 |
--------------------------------------------------------------------------------
/src/Browser/Helpers/Vue.php:
--------------------------------------------------------------------------------
1 | manager->executeScript($this->vueAttributeScript($key), [$element]);
18 | }
19 |
20 | protected function vueAttributeScript(string $key): string
21 | {
22 | return <<command(new GetLog($filename, $log_type));
20 | }
21 |
22 | public function getPageSource(): ?string
23 | {
24 | return $this->command(new GetPageSource());
25 | }
26 |
27 | public function switchTo(string $locator, WebDriverBy|string|null $selector = null): static
28 | {
29 | return $this->command(new SwitchTo($locator, $selector));
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/IO/Command.php:
--------------------------------------------------------------------------------
1 | id ??= (string) Str::uuid();
33 |
34 | return base64_encode(serialize($this))."\n";
35 | }
36 |
37 | public function __toString(): string
38 | {
39 | return $this->toData();
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/Browser/Concerns/ExecutesBrowserCommands.php:
--------------------------------------------------------------------------------
1 | command(new ExecuteScript($scripts));
20 | }
21 |
22 | public function quitBrowser(): static
23 | {
24 | return $this->command(new QuitBrowser());
25 | }
26 |
27 | public function takeScreenshot(string $filename): static
28 | {
29 | return $this->command(new TakeScreenshot($filename));
30 | }
31 |
32 | public function visit(string $url): static
33 | {
34 | return $this->command(new Visit($url));
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Window/FitContent.php:
--------------------------------------------------------------------------------
1 | switchTo()->defaultContent();
16 |
17 | if (! $html = $manager->findElement(WebDriverBy::tagName('html'))) {
18 | return;
19 | }
20 |
21 | [$width, $height] = $this->getDimensions($html);
22 |
23 | if ($width > 0 && $height > 0) {
24 | $manager->manage()
25 | ->window()
26 | ->setSize(new WebDriverDimension($width, $height));
27 | }
28 | }
29 |
30 | protected function getDimensions(RemoteWebElement $html): array
31 | {
32 | $size = $html->getSize();
33 |
34 | return [$size->getWidth(), $size->getHeight()];
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Elements/SetValue.php:
--------------------------------------------------------------------------------
1 | resolver->format($this->selector()));
24 | $value = Js::from($this->value);
25 |
26 | $script = <<executeScript($script);
35 |
36 | return null;
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/Browser/Concerns/ExecutesCookieCommands.php:
--------------------------------------------------------------------------------
1 | command(new AddCookie($name, $value, $expiry, $options, $encrypt));
20 | }
21 |
22 | public function deleteAllCookies(): static
23 | {
24 | return $this->command(new DeleteAllCookies());
25 | }
26 |
27 | public function deleteCookie(string $name): static
28 | {
29 | return $this->command(new DeleteCookie($name));
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Assertions/AssertScript.php:
--------------------------------------------------------------------------------
1 | expression, 'return ');
26 |
27 | $this->actual = $manager->executeScript($expression);
28 | }
29 |
30 | protected function performAssertions(RemoteWebDriverBroker $broker): void
31 | {
32 | Assert::assertEquals(
33 | $this->expected,
34 | $this->actual,
35 | "JavaScript expression [{$this->expression}] mismatched."
36 | );
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/Support/BackgroundProcess.php:
--------------------------------------------------------------------------------
1 | loop ??= Loop::get();
24 |
25 | $stdin ??= new ReadableResourceStream(STDIN, $this->loop);
26 | $stdout ??= new WritableResourceStream(STDOUT, $this->loop);
27 |
28 | $this->io = new CommandIO($this, $stdin, $stdout);
29 |
30 | $this->loop->addSignal(SIGTERM, fn() => $this->stop());
31 | }
32 |
33 | protected function stop(): void
34 | {
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Assertions/AssertSeeAnythingIn.php:
--------------------------------------------------------------------------------
1 | resolver->findOrFail($this->selector());
25 |
26 | $this->actual = $element->getText();
27 | }
28 |
29 | protected function performAssertions(RemoteWebDriverBroker $broker): void
30 | {
31 | $selector = $this->selector()->getValue();
32 |
33 | Assert::assertTrue(
34 | '' !== $this->actual,
35 | "Saw unexpected text [''] within element [{$selector}]."
36 | );
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Assertions/AssertSeeNothingIn.php:
--------------------------------------------------------------------------------
1 | resolver->findOrFail($this->selector());
25 |
26 | $this->actual = $element->getText();
27 | }
28 |
29 | protected function performAssertions(RemoteWebDriverBroker $broker): void
30 | {
31 | $selector = $this->selector()->getValue();
32 |
33 | Assert::assertTrue(
34 | '' === $this->actual,
35 | "Did not see expected text [''] within element [{$selector}]."
36 | );
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/Http/WebServerProcess.php:
--------------------------------------------------------------------------------
1 | relay = new LocalHttpCommandRelay(
26 | loop: $this->loop,
27 | io: $this->io,
28 | public_path: $public_path ?? getcwd(),
29 | host: $host,
30 | port: $port,
31 | );
32 |
33 | $this->sendNotice('HTTP server is running.');
34 | }
35 |
36 | public function stop(): void
37 | {
38 | $this->relay->stop();
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/phpunit.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
10 | ./src
11 |
12 |
13 |
14 |
15 | ./tests
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Cookies/AddCookie.php:
--------------------------------------------------------------------------------
1 | name, Crypt::getKey());
22 | $this->value = encrypt($prefix.$this->value, false);
23 | }
24 | }
25 |
26 | protected function executeWithBrowser(BrowserManager $manager)
27 | {
28 | $manager->manage()->addCookie(array_merge($this->options, [
29 | 'name' => $this->name,
30 | 'value' => $this->value,
31 | 'expiry' => $this->expiry instanceof DateTimeInterface
32 | ? $this->expiry->getTimestamp()
33 | : $this->expiry,
34 | ]));
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Galahad
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 all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Assertions/AssertCookieMissing.php:
--------------------------------------------------------------------------------
1 | actual = $manager->manage()->getCookieNamed($this->name);
28 | } catch (NoSuchCookieException) {
29 | $this->actual = null;
30 | }
31 | }
32 |
33 | protected function performAssertions(RemoteWebDriverBroker $broker): void
34 | {
35 | $value = $this->getValue();
36 |
37 | Assert::assertNull($value, "Found unexpected cookie [{$this->name}].");
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Assertions/AssertSeeIn.php:
--------------------------------------------------------------------------------
1 | resolver->findOrFail($this->selector());
26 |
27 | $this->haystack = $element->getText();
28 | }
29 |
30 | protected function performAssertions(RemoteWebDriverBroker $broker): void
31 | {
32 | $selector = $this->selector()->getValue();
33 |
34 | Assert::assertStringContainsString(
35 | $this->needle,
36 | $this->haystack,
37 | "Did not see expected text [{$this->needle}] within element [{$selector}]."
38 | );
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/Browser/PendingWait.php:
--------------------------------------------------------------------------------
1 | browser->waitUsing($this->seconds, $this->interval, $this->wait, $this->message);
30 | }
31 |
32 | public function __call(string $name, array $arguments)
33 | {
34 | $this->wait();
35 |
36 | return $this->forwardCallTo($this->browser, $name, $arguments);
37 | }
38 |
39 | public function __destruct()
40 | {
41 | $this->wait();
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Assertions/AssertDontSeeIn.php:
--------------------------------------------------------------------------------
1 | resolver->findOrFail($this->selector());
26 |
27 | $this->haystack = $element->getText();
28 | }
29 |
30 | protected function performAssertions(RemoteWebDriverBroker $broker): void
31 | {
32 | $selector = $this->selector()->getValue();
33 |
34 | Assert::assertStringNotContainsString(
35 | $this->needle,
36 | $this->haystack,
37 | "Saw unexpected text [{$this->needle}] within element [{$selector}]."
38 | );
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Assertions/AssertDialogOpened.php:
--------------------------------------------------------------------------------
1 | actual = $manager->switchTo()->alert()->getText();
23 | } catch (NoSuchAlertException) {
24 | $this->actual = null;
25 | }
26 | }
27 |
28 | protected function performAssertions(RemoteWebDriverBroker $broker): void
29 | {
30 | Assert::assertNotNull($this->actual, 'No dialog opened.');
31 |
32 | if ($this->expected) {
33 | Assert::assertEquals(
34 | $this->expected,
35 | $this->actual,
36 | "Expected dialog message [{$this->expected}] does not equal actual message [{$this->actual}]."
37 | );
38 | }
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Assertions/AssertAttributeMissing.php:
--------------------------------------------------------------------------------
1 | resolver->findOrFail($this->selector);
26 |
27 | $this->missing = (null === $element->getAttribute($this->attribute));
28 | }
29 |
30 | protected function performAssertions(RemoteWebDriverBroker $broker): void
31 | {
32 | $selector = $this->selector()->getValue();
33 |
34 | Assert::assertTrue(
35 | $this->missing,
36 | "Saw unexpected attribute [{$this->attribute}] within element [{$selector}]."
37 | );
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Elements/CheckOrUncheck.php:
--------------------------------------------------------------------------------
1 | resolver->resolveForChecking($this->selector);
24 |
25 | if (
26 | $this->needsToBeUnchecked($element)
27 | || $this->needsToBeChecked($element)
28 | ) {
29 | $element->click();
30 | }
31 | }
32 |
33 | protected function needsToBeChecked(RemoteWebElement $element): bool
34 | {
35 | return $element->isSelected() && ! $this->check;
36 | }
37 |
38 | protected function needsToBeUnchecked(RemoteWebElement $element): bool
39 | {
40 | return ! $element->isSelected() && $this->check;
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/Http/WebServerBroker.php:
--------------------------------------------------------------------------------
1 | host}:{$this->port}";
25 | }
26 |
27 | public function stop(): void
28 | {
29 | $this->debug('Stopping web server');
30 |
31 | $this->flushIncomingMessageStream();
32 |
33 | $this->process->signal(SIGTERM);
34 | }
35 |
36 | protected function startBackgroundProcess(InputStream $stdin): Process
37 | {
38 | $process = $this->artisan(['dawn:serve', $this->host, $this->port, public_path()], $stdin);
39 |
40 | register_shutdown_function(fn() => $process->stop(1, SIGKILL));
41 |
42 | $process->start();
43 |
44 | return $process;
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/Browser/Concerns/ExecutesWaitCommands.php:
--------------------------------------------------------------------------------
1 | command(new WaitForEvent($event, $selector, $seconds));
22 | }
23 |
24 | public function waitForUrl(string $url, ?int $timeout = null, ?int $interval = null): static
25 | {
26 | return $this->command(new WaitForUrl($url, $timeout, $interval));
27 | }
28 |
29 | public function waitUsing(?int $seconds, int $interval, Closure|WebDriverExpectedCondition $wait, ?string $message = null): static
30 | {
31 | return $this->command(new WaitUsing($seconds, $interval, $wait, $message));
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/.github/workflows/php-cs-fixer.yml:
--------------------------------------------------------------------------------
1 | name: Code Style
2 |
3 | on: [ pull_request, push ]
4 |
5 | jobs:
6 | coverage:
7 | runs-on: ubuntu-latest
8 |
9 | name: Run code style checks
10 |
11 | steps:
12 | - name: Checkout code
13 | uses: actions/checkout@v2
14 |
15 | - name: Setup PHP
16 | uses: shivammathur/setup-php@v2
17 | with:
18 | php-version: 8.1
19 | extensions: dom, curl, libxml, mbstring, zip, pcntl, bcmath, intl, iconv
20 |
21 | - name: Cache dependencies
22 | uses: actions/cache@v2
23 | with:
24 | path: |
25 | vendor
26 | ${{ steps.composer-cache-files-dir.outputs.dir }}
27 | key: ${{ runner.os }}-composer-${{ hashFiles('composer.json') }}
28 | restore-keys: |
29 | ${{ runner.os }}-composer-
30 |
31 | - name: Install dependencies
32 | env:
33 | COMPOSER_DISCARD_CHANGES: true
34 | run: composer require --no-suggest --no-progress --no-interaction --prefer-dist --update-with-all-dependencies "laravel/framework:^9.0" "orchestra/testbench:^7.0"
35 |
36 | - name: Run PHP CS Fixer
37 | run: ./vendor/bin/php-cs-fixer fix --diff --dry-run
38 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Manage/GetLog.php:
--------------------------------------------------------------------------------
1 | filename = $this->prepareAndNormalizeStoragePath(
26 | filename: $filename,
27 | directory: config('dawn.storage_logs', resource_path('dawn/logs')),
28 | );
29 | }
30 |
31 | protected function executeWithBrowser(BrowserManager $manager)
32 | {
33 | if (! in_array($manager->getCapabilities()->getBrowserName(), static::$supported)) {
34 | return;
35 | }
36 |
37 | if (empty($logs = $manager->manage()->getLog($this->log_type))) {
38 | return;
39 | }
40 |
41 | file_put_contents($this->filename, json_encode($logs, JSON_PRETTY_PRINT));
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Elements/Click.php:
--------------------------------------------------------------------------------
1 | wait) {
26 | $html = $manager->findElement(WebDriverBy::tagName('html'));
27 | }
28 |
29 | // If we haven't been provided a selector, then just click wherever the mouse happens to be
30 | if (null === $this->selector) {
31 | (new WebDriverActions($manager->driver))->click()->perform();
32 | } else {
33 | $manager->resolver->{$this->resolver}($this->selector())->click();
34 | }
35 |
36 | if ($this->wait) {
37 | $manager->wait()->until(WebDriverExpectedCondition::stalenessOf($html));
38 | }
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/tests/Feature/UploadTest.php:
--------------------------------------------------------------------------------
1 | file('upload');
22 | });
23 |
24 | $browser = $this->openBrowser()->visit('/');
25 |
26 | // Photo by [Chris Henry](https://unsplash.com/@chrishenryphoto) on [Unsplash](https://unsplash.com/photos/E77SjOPCE5Y)
27 | // We're using a decently-sized image to test larger file handling
28 | $browser->attach('#upload-input', __DIR__.'/../resources/test-upload.jpg');
29 | $browser->clickAndWaitForReload('button');
30 |
31 | $this->assertInstanceOf(UploadedFile::class, $file);
32 | $this->assertEquals('test-upload.jpg', $file->getClientOriginalName());
33 | $this->assertEquals('image/jpeg', $file->getClientMimeType());
34 | $this->assertEquals(filesize(__DIR__.'/../resources/test-upload.jpg'), $file->getSize());
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/Browser/RemoteWebDriverProcess.php:
--------------------------------------------------------------------------------
1 | browser_manager = new BrowserManager($connect);
29 |
30 | $this->sendNotice('Web driver server is running.');
31 | }
32 |
33 | public function respond(Command $request, $response = null): static
34 | {
35 | return $this->sendCommand(new SendRemoteWebDriverResponse($request->id, $response));
36 | }
37 |
38 | protected function stop(): void
39 | {
40 | $this->sendNotice('Stopping web driver server...');
41 |
42 | $this->browser_manager->stop();
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Assertions/AssertOptionSelectionState.php:
--------------------------------------------------------------------------------
1 | resolver->resolveSelectOptions($this->selector, (array) $this->value);
29 |
30 | $this->selected = collect($options)->contains(fn(RemoteWebElement $option) => $option->isSelected());
31 | }
32 |
33 | protected function performAssertions(RemoteWebDriverBroker $broker): void
34 | {
35 | $selector = $this->selector()->getValue();
36 |
37 | Assert::assertEquals(
38 | $this->expected,
39 | $this->selected,
40 | sprintf($this->message, $this->value, $selector),
41 | );
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Wait/WaitForEvent.php:
--------------------------------------------------------------------------------
1 | manage()->timeouts()->setScriptTimeout($this->seconds ?? 5);
21 |
22 | $target = in_array($this->selector, ['document', 'window'])
23 | ? $this->selector
24 | : $manager->resolver->findOrFail($this->selector);
25 |
26 | $manager->executeAsyncScript($this->waitScript(), [
27 | $target,
28 | $this->event,
29 | ]);
30 | }
31 |
32 | protected function waitScript(): string
33 | {
34 | // Using `eval()` here accounts for cases where we're listinging
35 | // on the document or window objects. The third argument is provided
36 | // by `executeAsyncScript` (the callback).
37 |
38 | return << callback(), { once: true });
43 | JS;
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/tests/Feature/ElementResolutionTest.php:
--------------------------------------------------------------------------------
1 | openBrowser()
18 | ->visit('/')
19 | ->assertSeeIn('#clicked', 'N/A')
20 | ->clickButton('#button-with-id')
21 | ->assertSeeIn('#clicked', 'button with id')
22 | ->clickButton('.button-with-class')
23 | ->assertSeeIn('#clicked', 'button with class')
24 | ->clickButton('submit-input')
25 | ->assertSeeIn('#clicked', 'submit input')
26 | ->clickButton('submit-input')
27 | ->assertSeeIn('#clicked', 'submit input')
28 | ->clickButton('button-input')
29 | ->assertSeeIn('#clicked', 'button input')
30 | ->clickButton('button-with-name')
31 | ->assertSeeIn('#clicked', 'button with name')
32 | ->clickButton('submit button by value 1')
33 | ->assertSeeIn('#clicked', 'submit button by value 1')
34 | ->clickButton('submit button by value 2')
35 | ->assertSeeIn('#clicked', 'submit button by value 2')
36 | ->clickButton('Button With Text')
37 | ->assertSeeIn('#clicked', 'button with text');
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/tests/views/resolvers/buttons.blade.php:
--------------------------------------------------------------------------------
1 | @extends('layout')
2 |
3 | @section('content')
4 |
51 | @endsection
52 |
--------------------------------------------------------------------------------
/src/Browser/Concerns/ExecutesWindowCommands.php:
--------------------------------------------------------------------------------
1 | command(new CloseBrowserWindow());
22 | }
23 |
24 | public function fitContent(): static
25 | {
26 | return $this->command(new FitContent());
27 | }
28 |
29 | public function maximize(): static
30 | {
31 | return $this->command(new Maximize());
32 | }
33 |
34 | public function openNewWindow(): static
35 | {
36 | return $this->command(new OpenNewWindow());
37 | }
38 |
39 | public function resize(int $width, int $height): static
40 | {
41 | return $this->command(new Resize($width, $height));
42 | }
43 |
44 | public function setPosition(int $x, int $y): static
45 | {
46 | return $this->command(new SetPosition($x, $y));
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Assertions/AssertHasCookie.php:
--------------------------------------------------------------------------------
1 | actual = $manager->manage()->getCookieNamed($this->name);
29 | } catch (NoSuchCookieException) {
30 | $this->actual = null;
31 | }
32 | }
33 |
34 | protected function performAssertions(RemoteWebDriverBroker $broker): void
35 | {
36 | $value = $this->getValue();
37 |
38 | Assert::assertNotNull($value, "Did not find expected cookie [{$this->name}].");
39 |
40 | if (null !== $this->expected) {
41 | Assert::assertEquals(
42 | $value,
43 | $this->expected,
44 | "Cookie [{$this->name}] had value [{$value}], but expected [{$this->expected}]."
45 | );
46 | }
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Assertions/AssertSelectionState.php:
--------------------------------------------------------------------------------
1 | resolver->{$this->resolver}($this->selector(), $this->value);
32 |
33 | $this->selected = $element->isSelected();
34 | $this->indeterminate = 'true' === $element->getAttribute('indeterminate');
35 | }
36 |
37 | protected function performAssertions(RemoteWebDriverBroker $broker): void
38 | {
39 | $selector = $this->selector()->getValue();
40 |
41 | Assert::assertEquals(
42 | $this->expected,
43 | $this->selected,
44 | sprintf($this->message, $selector),
45 | );
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/src/Support/ResponseQueue.php:
--------------------------------------------------------------------------------
1 | queue = new Collection();
18 | }
19 |
20 | public function push(string $request_id, $response): static
21 | {
22 | $this->queue->put($request_id, $response);
23 |
24 | return $this;
25 | }
26 |
27 | public function waitForResponse(string $request_id, float $timeout = 10): mixed
28 | {
29 | $this->loop->addPeriodicTimer(0.1, function($timer) use ($request_id) {
30 | if ($this->queue->has($request_id)) {
31 | $this->loop->cancelTimer($timer);
32 | $this->loop->stop();
33 | }
34 | });
35 |
36 | $timed_out = false;
37 | $this->loop->addTimer($timeout, function() use (&$timed_out) {
38 | $timed_out = true;
39 | $this->loop->stop();
40 | });
41 |
42 | while (! $this->queue->has($request_id)) {
43 | if ($timed_out) {
44 | throw new RuntimeException('Background process timed out.');
45 | }
46 | $this->loop->run();
47 | }
48 |
49 | $response = $this->queue->pull($request_id);
50 |
51 | if ($response instanceof Throwable) {
52 | throw $response;
53 | }
54 |
55 | return $response;
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Assertions/AssertOptionPresence.php:
--------------------------------------------------------------------------------
1 | resolver->resolveSelectOptions($this->selector, $this->options);
29 |
30 | $this->count = collect($options)
31 | ->unique(fn(RemoteWebElement $option) => $option->getAttribute('value'))
32 | ->count();
33 | }
34 |
35 | protected function performAssertions(RemoteWebDriverBroker $broker): void
36 | {
37 | $selector = $this->selector()->getValue();
38 |
39 | $expected = $this->expected
40 | ? count($this->options)
41 | : 0;
42 |
43 | Assert::assertEquals(
44 | $expected,
45 | $this->count,
46 | sprintf($this->message, implode(', ', $this->options), $selector),
47 | );
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Assertions/AssertLinkVisibility.php:
--------------------------------------------------------------------------------
1 | partial
29 | ? WebDriverBy::partialLinkText($this->text)
30 | : WebDriverBy::linkText($this->text);
31 |
32 | $manager->findElement($selector);
33 |
34 | $this->actual = true; // $this->isElementVisible($manager, $link);
35 | } catch (Throwable) {
36 | $this->actual = false;
37 | }
38 | }
39 |
40 | protected function performAssertions(RemoteWebDriverBroker $broker): void
41 | {
42 | $message = $this->expected
43 | ? "Did not see expected link [{$this->text}]."
44 | : "Saw unexpected link [{$this->text}].";
45 |
46 | Assert::assertEquals(
47 | $this->expected,
48 | $this->actual,
49 | $message
50 | );
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/tests/Feature/WaitTest.php:
--------------------------------------------------------------------------------
1 | openBrowser()->visit('/');
18 |
19 | $original = $browser->getText('#random');
20 |
21 | $browser->clickAndWaitForReload('#reload');
22 |
23 | $reloaded = $browser->getText('#random');
24 |
25 | $this->assertNotEquals($original, $reloaded);
26 | }
27 |
28 | public function test_location_waits(): void
29 | {
30 | Route::get('a', function() {
31 | return response('Go to B ', headers: ['Content-Type' => 'text/html']);
32 | });
33 |
34 | Route::get('b', function() {
35 | return response('Go to C ', headers: ['Content-Type' => 'text/html']);
36 | });
37 |
38 | Route::get('c', function() {
39 | return response('Go to A ', headers: ['Content-Type' => 'text/html']);
40 | });
41 |
42 | $this->expectNotToPerformAssertions();
43 |
44 | $this->openBrowser()
45 | ->visit('/a')
46 | ->clickLink('Go to B')
47 | ->waitForLocation('/b')
48 | ->clickLink('Go to C')
49 | ->waitForLocation('/c')
50 | ->clickLink('Go to A')
51 | ->waitForLocation('/a');
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Assertions/AssertVue.php:
--------------------------------------------------------------------------------
1 | resolver->findOrFail($this->selector ?? '');
29 |
30 | $this->actual = (new Vue($manager))->attribute($element, $this->key);
31 | }
32 |
33 | protected function performAssertions(RemoteWebDriverBroker $broker): void
34 | {
35 | [$assertion, $message] = $this->getAssertion();
36 |
37 | $assertion($this->value, $this->actual, sprintf($message, json_encode($this->value), $this->key));
38 | }
39 |
40 | protected function getAssertion(): array
41 | {
42 | if ($this->not) {
43 | return [Assert::assertNotEquals(...), 'Saw unexpected value [%s] at the key [%s].'];
44 | }
45 |
46 | return [Assert::assertEquals(...), 'Did not see expected value [%s] at the key [%s].'];
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Assertions/BrowserAssertionCommand.php:
--------------------------------------------------------------------------------
1 | browser_manager->switchToBrowser($this->browser_id);
27 | $this->loadData($context->browser_manager);
28 | $context->sendCommand($this);
29 | $context->respond($this);
30 | } catch (Throwable $exception) {
31 | $context->sendException($exception);
32 | }
33 | }
34 |
35 | // If we're executing in the main process, we'll run the assertions
36 | if ($context instanceof RemoteWebDriverBroker) {
37 | $this->performAssertions($context);
38 | }
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/IO/Commands/ThrowException.php:
--------------------------------------------------------------------------------
1 | exception)) {
34 | $this->exception = new Exception($this->exception);
35 | }
36 |
37 | throw $this->exception;
38 | }
39 |
40 | public function toData(): string
41 | {
42 | try {
43 | return parent::toData();
44 | } catch (Throwable $exception) {
45 | // If the full exception can't be serialized, then we'll just send the message
46 | if (! is_string($this->exception)) {
47 | $this->exception = class_basename($this->exception).': '.$this->exception->getMessage();
48 | return parent::toData();
49 | }
50 |
51 | // If we already have a string, we'll just re-throw
52 | throw $exception;
53 | }
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/src/Browser/RemoteWebDriverBroker.php:
--------------------------------------------------------------------------------
1 | queue = new ResponseQueue($loop);
27 | }
28 |
29 | public function addToResponseQueue(string $request_id, $response): static
30 | {
31 | $this->queue->push($request_id, $response);
32 |
33 | return $this;
34 | }
35 |
36 | public function stop(): void
37 | {
38 | $this->debug('Stopping remote web driver.');
39 |
40 | $this->process->signal(SIGTERM);
41 | }
42 |
43 | public function sendCommandAndWaitForResponse(Command $command): mixed
44 | {
45 | $this->sendCommand($command);
46 |
47 | return $this->queue->waitForResponse($command->id);
48 | }
49 |
50 | protected function startBackgroundProcess(InputStream $stdin): Process
51 | {
52 | $process = $this->artisan(['dawn:drive', $this->url], $stdin);
53 |
54 | register_shutdown_function(fn() => $process->stop(1, SIGKILL));
55 |
56 | $process->start();
57 |
58 | return $process;
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "glhd/dawn",
3 | "description": "",
4 | "keywords": [
5 | "laravel"
6 | ],
7 | "authors": [
8 | {
9 | "name": "Chris Morrell",
10 | "homepage": "http://www.cmorrell.com"
11 | }
12 | ],
13 | "license": "MIT",
14 | "require": {
15 | "php": "^8.1",
16 | "ext-json": "*",
17 | "ext-pcntl": "*",
18 | "ext-sockets": "*",
19 | "ext-zip": "*",
20 | "illuminate/support": "9.*|10.*|dev-master",
21 | "nyholm/psr7": "^1.5",
22 | "php-webdriver/webdriver": "^1.14",
23 | "react/http": "^1.8",
24 | "symfony/psr-http-message-bridge": "^2.1"
25 | },
26 | "require-dev": {
27 | "orchestra/testbench": "7.*|^8.2.2|dev-master",
28 | "friendsofphp/php-cs-fixer": "^3.0",
29 | "mockery/mockery": "^1.3",
30 | "phpunit/phpunit": "^9.5",
31 | "laravel/sanctum": "^3.0",
32 | "nunomaduro/collision": "^6.1",
33 | "spatie/laravel-ignition": "1.*|2.*"
34 | },
35 | "autoload": {
36 | "psr-4": {
37 | "Glhd\\Dawn\\": "src/"
38 | }
39 | },
40 | "autoload-dev": {
41 | "classmap": [
42 | "tests/TestCase.php"
43 | ],
44 | "psr-4": {
45 | "Glhd\\Dawn\\Tests\\": "tests/"
46 | }
47 | },
48 | "scripts": {
49 | "fix-style": "vendor/bin/php-cs-fixer fix",
50 | "check-style": "vendor/bin/php-cs-fixer fix --diff --dry-run"
51 | },
52 | "extra": {
53 | "laravel": {
54 | "providers": [
55 | "Glhd\\Dawn\\Providers\\DawnServiceProvider"
56 | ]
57 | }
58 | },
59 | "minimum-stability": "dev",
60 | "prefer-stable": true
61 | }
62 |
--------------------------------------------------------------------------------
/src/Browser/SeleniumDriverProcess.php:
--------------------------------------------------------------------------------
1 | findChromeDriverExecutable();
14 |
15 | array_unshift($arguments, "--port={$port}");
16 | array_unshift($arguments, $executable);
17 |
18 | parent::__construct(command: $arguments, env: $this->getSeleniumDriverEnvironment());
19 |
20 | register_shutdown_function(fn() => $this->signal(SIGKILL));
21 |
22 | $this->start();
23 |
24 | $this->waitUntil(function($type, $output) {
25 | return Str::of($output)->contains('started successfully');
26 | });
27 | }
28 |
29 | protected function findChromeDriverExecutable(): string
30 | {
31 | $finder = new ExecutableFinder();
32 |
33 | $default = file_exists('/opt/homebrew/bin/chromedriver')
34 | ? '/opt/homebrew/bin/chromedriver'
35 | : '/usr/local/bin/chromedriver';
36 |
37 | return $finder->find('chromedriver', $default);
38 | }
39 |
40 | protected function getSeleniumDriverEnvironment(): array
41 | {
42 | if ($this->onMacOrWindows()) {
43 | return [];
44 | }
45 |
46 | return [
47 | 'DISPLAY' => $_ENV['DISPLAY'] ?? ':0',
48 | ];
49 | }
50 |
51 | protected function onMacOrWindows(): bool
52 | {
53 | return PHP_OS === 'Darwin'
54 | || PHP_OS === 'WINNT'
55 | || Str::contains(php_uname(), 'Microsoft');
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/src/IO/CommandIO.php:
--------------------------------------------------------------------------------
1 | buffer = new CommandBuffer($this->handleCommand(...));
23 | $in->on('data', fn($chunk) => $this->buffer->write($chunk));
24 | }
25 |
26 | public function sendCommand(Command $command): static
27 | {
28 | $this->out->write($command->toData());
29 |
30 | return $this;
31 | }
32 |
33 | protected function handleCommand(Command $command): void
34 | {
35 | if (! method_exists($command, 'execute')) {
36 | return;
37 | }
38 |
39 | $reflection = new ReflectionMethod($command, 'execute');
40 | if (! count($parameters = $reflection->getParameters())) {
41 | return;
42 | }
43 |
44 | foreach (Reflector::getParameterClassNames($parameters[0]) as $type_hint) {
45 | if (is_a($this->context, $type_hint)) {
46 | try {
47 | $command->execute($this->context);
48 | } catch (Throwable $exception) {
49 | if ($this->context instanceof BackgroundProcess) {
50 | $this->context->sendException($exception);
51 | } else {
52 | throw $exception;
53 | }
54 | }
55 | return;
56 | }
57 | }
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Assertions/AssertInputValue.php:
--------------------------------------------------------------------------------
1 | actual = $this->getValue($manager);
27 | }
28 |
29 | protected function getValue(BrowserManager $manager): mixed
30 | {
31 | $element = $manager->resolver->resolveForTyping($this->selector);
32 |
33 | return match ($element->getTagName()) {
34 | 'input', 'textarea' => $element->getAttribute('value'),
35 | default => $element->getText(),
36 | };
37 | }
38 |
39 | protected function performAssertions(RemoteWebDriverBroker $broker): void
40 | {
41 | $selector = $this->selector()->getValue();
42 |
43 | if ($this->not) {
44 | Assert::assertNotEquals(
45 | $this->value,
46 | $this->actual,
47 | "Value [{$this->value}] for the [{$selector}] input should not equal the actual value."
48 | );
49 | }
50 |
51 | Assert::assertEquals(
52 | $this->value,
53 | $this->actual,
54 | "Expected value [{$this->value}] for the [{$selector}] input does not equal the actual value [{$this->actual}]."
55 | );
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Assertions/AssertQueryStringHas.php:
--------------------------------------------------------------------------------
1 | actual = $manager->getCurrentURL();
24 | }
25 |
26 | protected function performAssertions(RemoteWebDriverBroker $broker): void
27 | {
28 | $parts = parse_url($this->actual);
29 |
30 | Assert::assertArrayHasKey(
31 | 'query',
32 | $parts,
33 | 'Did not see expected query string in ['.$this->actual.'].'
34 | );
35 |
36 | parse_str($parts['query'], $output);
37 |
38 | Assert::assertTrue(
39 | Arr::has($output, $this->name),
40 | "Did not see expected query string parameter [{$this->name}] in [{$this->actual}]."
41 | );
42 |
43 | if (! $this->value) {
44 | return;
45 | }
46 |
47 | $actual = Arr::get($output, $this->name, new stdClass());
48 |
49 | $actual_for_message = is_array($actual)
50 | ? implode(',', $actual)
51 | : $actual;
52 |
53 | $expected_for_message = is_array($this->value)
54 | ? implode(',', $this->value)
55 | : $this->value;
56 |
57 | Assert::assertEquals(
58 | $this->value,
59 | $actual,
60 | "Query string parameter [{$this->name}] had value [{$actual_for_message}], but expected [{$expected_for_message}]."
61 | );
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/src/Http/Commands/SendWebResponse.php:
--------------------------------------------------------------------------------
1 | getStatusCode(),
17 | static::getPsrHeaders($response),
18 | static::getContent($response),
19 | $response->getProtocolVersion(),
20 | );
21 | }
22 |
23 | protected static function getPsrHeaders(SymfonyResponse $response): array
24 | {
25 | $headers = $response->headers->all();
26 |
27 | if (! empty($cookies = $response->headers->getCookies())) {
28 | $headers['Set-Cookie'] = [];
29 | foreach ($cookies as $cookie) {
30 | $headers['Set-Cookie'][] = $cookie->__toString();
31 | }
32 | }
33 |
34 | return $headers;
35 | }
36 |
37 | protected static function getContent(SymfonyResponse $response): string
38 | {
39 | // This accounts for binary responses that are typically streamed
40 | ob_start();
41 | $response->sendContent();
42 | return ob_get_clean();
43 | }
44 |
45 | public function __construct(
46 | public string $request_id,
47 | public int $status,
48 | public array $headers,
49 | public string $content,
50 | public string $version,
51 | ) {
52 | }
53 |
54 | public function execute(WebServerProcess $server)
55 | {
56 | $server->relay->handleResponse(
57 | request_id: $this->request_id,
58 | response: new ReactResponse($this->status, $this->headers, $this->content, $this->version),
59 | );
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/src/IO/CommandBuffer.php:
--------------------------------------------------------------------------------
1 | open) {
25 | throw new BadMethodCallException('Cannot write to a closed message buffer.');
26 | }
27 |
28 | $this->buffer .= $chunk;
29 |
30 | $this->emit();
31 |
32 | return $this;
33 | }
34 |
35 | public function close(): static
36 | {
37 | $this->emit();
38 |
39 | $this->open = false;
40 |
41 | return $this;
42 | }
43 |
44 | protected function emit(): void
45 | {
46 | if (! str_contains($this->buffer, "\n")) {
47 | return;
48 | }
49 |
50 | $chunks = Str::of($this->buffer)->explode("\n");
51 |
52 | // Since the last chunk is either a new line or a partial line,
53 | // we'll push it back to the buffer until we get more data
54 | $this->buffer = $chunks->pop();
55 |
56 | try {
57 | $chunks->filter()
58 | ->map(fn(string $chunk) => Command::fromData($chunk))
59 | ->each($this->callback);
60 | } catch (UnableToInstantiateCommandFromData) {
61 | // If we're unable to process a command, we'll assume that there's error output
62 | // that could not be serialized as a command. If that's the case, it's best to just
63 | // dump the whole message for debugging purposes (better of two evils).
64 | throw new RuntimeException("Error processing background output:\n\n".$chunks->join("\n"));
65 | }
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Assertions/AssertHasClass.php:
--------------------------------------------------------------------------------
1 | resolver->findOrFail($this->selector);
27 | $class = $element->getAttribute('class') ?? '';
28 |
29 | $this->actual = explode(' ', $class);
30 | }
31 |
32 | protected function performAssertions(RemoteWebDriverBroker $broker): void
33 | {
34 | $selector = $this->selector()->getValue();
35 | $classes = (array) $this->class;
36 |
37 | if ($this->not) {
38 | $this->performNotContainsAssertions($classes, $selector);
39 | } else {
40 | $this->performContainsAssertions($classes, $selector);
41 | }
42 | }
43 |
44 | protected function performContainsAssertions(array $classes, string $selector): void
45 | {
46 | foreach ($classes as $class) {
47 | Assert::assertContains($class, $this->actual, "Did not find [{$class}] in class list for [{$selector}].");
48 | }
49 | }
50 |
51 | protected function performNotContainsAssertions(array $classes, string $selector): void
52 | {
53 | foreach ($classes as $class) {
54 | Assert::assertNotContains($class, $this->actual, "Found unexpected [{$class}] in class list for [{$selector}].");
55 | }
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Wait/WaitUsing.php:
--------------------------------------------------------------------------------
1 | getApply();
28 | }
29 |
30 | $this->closure = new SerializableClosure($wait->bindTo(null));
31 | $this->seconds ??= 5;
32 | }
33 |
34 | protected function executeWithBrowser(BrowserManager $manager)
35 | {
36 | $manager->wait($this->seconds, $this->interval)
37 | ->until($this->getClosure($manager), $this->message);
38 | }
39 |
40 | protected function getClosure(BrowserManager $manager): Closure
41 | {
42 | $closure = $this->closure->getClosure();
43 |
44 | // If our wait callback needs access to the BrowserManager instance, we'll need
45 | // to wrap it in a native "until" callback because that's handled inside the webdriver package.
46 | try {
47 | if (BrowserManager::class === $this->firstClosureParameterType($closure)) {
48 | return static function() use ($closure, $manager) {
49 | return $closure($manager);
50 | };
51 | }
52 | } catch (RuntimeException) {
53 | // If the closure doesn't have a typed parameter, we'll just leave it be
54 | }
55 |
56 | return $closure;
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/.github/workflows/coverage.yml:
--------------------------------------------------------------------------------
1 | name: Code Coverage
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 |
8 | jobs:
9 | coverage:
10 | runs-on: ubuntu-latest
11 |
12 | name: Publish code coverage
13 |
14 | steps:
15 | - name: Checkout code
16 | uses: actions/checkout@v2
17 |
18 | - name: Setup PHP
19 | uses: shivammathur/setup-php@v2
20 | with:
21 | php-version: 8.1
22 | extensions: dom, curl, libxml, mbstring, zip, pcntl, bcmath, intl, iconv
23 | coverage: pcov
24 |
25 | - name: Setup Chrome Driver
26 | uses: nanasess/setup-chromedriver@v1
27 |
28 | - name: Start Chrome Driver
29 | run: |
30 | export DISPLAY=:99
31 | chromedriver --port=9515 &
32 | sudo Xvfb -ac :99 -screen 0 1280x1024x24 > /dev/null 2>&1 &
33 |
34 | - name: Cache dependencies
35 | uses: actions/cache@v2
36 | with:
37 | path: |
38 | vendor
39 | ${{ steps.composer-cache-files-dir.outputs.dir }}
40 | key: ${{ runner.os }}-composer-${{ hashFiles('composer.json') }}
41 | restore-keys: |
42 | ${{ runner.os }}-composer-
43 |
44 | - name: Install dependencies
45 | env:
46 | COMPOSER_DISCARD_CHANGES: true
47 | run: composer require --no-suggest --no-progress --no-interaction --prefer-dist --update-with-all-dependencies "laravel/framework:^9.0" "orchestra/testbench:^7.0"
48 |
49 | - name: Run and publish code coverage
50 | uses: paambaati/codeclimate-action@v2.4.0
51 | env:
52 | CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }}
53 | with:
54 | coverageCommand: vendor/bin/phpunit --coverage-clover ${{ github.workspace }}/clover.xml
55 | debug: true
56 | coverageLocations:
57 | "${{github.workspace}}/clover.xml:clover"
58 |
--------------------------------------------------------------------------------
/tests/TestCase.php:
--------------------------------------------------------------------------------
1 | setUpRunsBrowserTests();
20 |
21 | View::getFinder()->addLocation(__DIR__.'/views');
22 |
23 | Route::get('/_dawn/alpine.js', fn() => response(file_get_contents(__DIR__.'/resources/alpine.js'), 200, ['Content-Type' => 'application/javascript']));
24 | Route::get('/_dawn/tailwind.css', fn() => response(file_get_contents(__DIR__.'/resources/tailwind.css'), 200, ['Content-Type' => 'text/css']));
25 |
26 | // This forces the testbench binary to load our service provider on the CLI
27 | file_put_contents(base_path('testbench.yaml'), "providers:\n - ".DawnServiceProvider::class);
28 |
29 | // This tricks the `testbench` CLI to have access to the correct autoloader
30 | $vendor_dir = dirname(base_path()).'/vendor';
31 | if (! file_exists($vendor_dir)) {
32 | symlink(dirname(__DIR__).'/vendor', $vendor_dir);
33 | }
34 | }
35 |
36 | protected function tearDown(): void
37 | {
38 | $this->tearDownRunsBrowserTests();
39 |
40 | // Clean up our custom yaml file
41 | if (file_exists(base_path('testbench.yaml'))) {
42 | unlink(base_path('testbench.yaml'));
43 | }
44 |
45 | parent::tearDown();
46 | }
47 |
48 | protected function getPackageProviders($app)
49 | {
50 | return [
51 | DawnServiceProvider::class,
52 | ];
53 | }
54 |
55 | protected function getPackageAliases($app)
56 | {
57 | return [];
58 | }
59 |
60 | protected function getApplicationTimezone($app)
61 | {
62 | return 'America/New_York';
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/src/Support/ProcessManager.php:
--------------------------------------------------------------------------------
1 | config !== $config) {
23 | static::$instance->stop();
24 | static::$instance = null;
25 | }
26 |
27 | return static::$instance ??= new static($config);
28 | }
29 |
30 | public static function clearInstance(): void
31 | {
32 | static::$instance = null;
33 |
34 | Container::getInstance()->forgetInstance(static::class);
35 | }
36 |
37 | public function __construct(
38 | public readonly array $config
39 | ) {
40 | $this->remote_web_driver = new RemoteWebDriverBroker(
41 | url: $this->config('dawn.browser_url', 'http://localhost:9515')
42 | );
43 |
44 | $this->web_server = new WebServerBroker(
45 | host: $this->config('dawn.server_host', '127.0.0.1'),
46 | port: $this->config('dawn.server_port') ?? $this->findOpenPort(),
47 | );
48 | }
49 |
50 | public function stop(): void
51 | {
52 | $this->web_server->stop();
53 | $this->remote_web_driver->stop();
54 | }
55 |
56 | protected function config(string $key, $default = null): mixed
57 | {
58 | return Arr::get($this->config, $key, $default);
59 | }
60 |
61 | protected function findOpenPort(): int
62 | {
63 | $sock = socket_create_listen(0);
64 |
65 | socket_getsockname($sock, $addr, $port);
66 | socket_close($sock);
67 |
68 | return $port;
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/src/Browser/Concerns/ExecutesMouseCommands.php:
--------------------------------------------------------------------------------
1 | command(new ClickAndHold($selector));
25 | }
26 |
27 | public function doubleClick(WebDriverBy|string|null $selector): static
28 | {
29 | return $this->command(new DoubleClick($selector));
30 | }
31 |
32 | public function dragAndDrop(WebDriverBy|string $from, WebDriverBy|string $to): static
33 | {
34 | return $this->command(new DragAndDrop($from, $to));
35 | }
36 |
37 | public function dragAndDropBy(WebDriverBy|string $selector, int $x = 0, int $y = 0): static
38 | {
39 | return $this->command(new DragAndDropBy($selector, $x, $y));
40 | }
41 |
42 | public function mouseOver(WebDriverBy|string $by): static
43 | {
44 | return $this->command(new MouseOver($by));
45 | }
46 |
47 | public function moveByOffset(int $x, int $y): static
48 | {
49 | return $this->command(new MoveByOffset($x, $y));
50 | }
51 |
52 | public function releaseMouse(): static
53 | {
54 | return $this->command(new ReleaseMouse());
55 | }
56 |
57 | public function rightClick(WebDriverBy|string|null $selector): static
58 | {
59 | return $this->command(new RightClick($selector));
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Assertions/AssertAttribute.php:
--------------------------------------------------------------------------------
1 | resolver->findOrFail($this->selector);
29 |
30 | $this->actual = $element->getAttribute($this->attribute);
31 | }
32 |
33 | protected function performAssertions(RemoteWebDriverBroker $broker): void
34 | {
35 | $selector = $this->selector()->getValue();
36 |
37 | Assert::assertNotNull(
38 | $this->actual,
39 | "Did not see expected attribute [{$this->attribute}] within element [{$selector}]."
40 | );
41 |
42 | [$assertion, $message] = $this->getAssertion();
43 |
44 | $assertion($this->value, $this->actual, sprintf($message, $this->attribute, $this->value, $this->actual));
45 | }
46 |
47 | protected function getAssertion(): array
48 | {
49 | return match ([$this->not, $this->contains]) {
50 | [false, false] => [Assert::assertEquals(...), 'Expected \'%s\' attribute [%s] does not equal actual value [%s].'],
51 | [false, true] => [Assert::assertStringContainsString(...), 'Attribute \'%s\' does not contain [%s]. Full attribute value was [%s].'],
52 | [true, true] => [Assert::assertStringNotContainsString(...), 'Attribute \'%s\' should not contain [%s].'],
53 | [true, false] => [Assert::assertNotEquals(...), 'Attribute \'%s\' should not equal [%s].'],
54 | };
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Visit.php:
--------------------------------------------------------------------------------
1 | url = match (true) {
20 | Str::is('about:*', $url) => $url,
21 | default => url($url),
22 | };
23 |
24 | $this->rewriteUrlIfHandledByApplication();
25 | }
26 |
27 | protected function rewriteUrlIfHandledByApplication()
28 | {
29 | try {
30 | Route::getRoutes()->match(Request::create($this->url));
31 | } catch (NotFoundHttpException) {
32 | return;
33 | }
34 |
35 | // FIXME: Maybe include the original URL as the user parameter?
36 |
37 | $parts = array_merge(
38 | parse_url($this->url),
39 | parse_url(app(WebServerBroker::class)->url()),
40 | );
41 |
42 | $this->url = $this->rebuildUrl($parts);
43 | }
44 |
45 | protected function rebuildUrl(array $parts): string
46 | {
47 | $url = '';
48 | $keys = ['scheme', 'user', 'pass', 'host', 'port', 'path', 'query', 'fragment'];
49 |
50 | foreach ($keys as $key) {
51 | if (! $value = Arr::get($parts, $key)) {
52 | continue;
53 | }
54 |
55 | $url .= match ($key) {
56 | 'scheme' => "{$value}://",
57 | 'user' => $value,
58 | 'pass' => ":{$value}",
59 | 'host' => isset($parts['user'])
60 | ? "@{$value}"
61 | : $value,
62 | 'port' => 80 === (int) $value
63 | ? ''
64 | : ":{$value}",
65 | 'path' => $value,
66 | 'query' => "?{$value}",
67 | 'fragment' => "#{$value}",
68 | };
69 | }
70 |
71 | return $url;
72 | }
73 |
74 | protected function executeWithBrowser(BrowserManager $manager)
75 | {
76 | $manager->get($this->url);
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Assertions/AssertValue.php:
--------------------------------------------------------------------------------
1 | resolver->findOrFail($this->selector);
30 |
31 | if (! $this->elementSupportsValueAttribute($element)) {
32 | $this->is_supported = false;
33 | return;
34 | }
35 |
36 | $this->actual = $element->getAttribute('value');
37 | }
38 |
39 | protected function performAssertions(RemoteWebDriverBroker $broker): void
40 | {
41 | $selector = $this->selector()->getValue();
42 |
43 | [$assertion, $message] = $this->getAssertion();
44 |
45 | $assertion(
46 | expected: $this->value,
47 | actual: $this->actual,
48 | message: sprintf($message, $this->value, $selector)
49 | );
50 | }
51 |
52 | protected function getAssertion(): array
53 | {
54 | if ($this->not) {
55 | return [Assert::assertNotEquals(...), 'Saw unexpected value [%s] within element [%s].'];
56 | }
57 |
58 | return [Assert::assertEquals(...), 'Did not see expected value [%s] within element [%s].'];
59 | }
60 |
61 | protected function elementSupportsValueAttribute(RemoteWebElement $element): bool
62 | {
63 | return in_array($element->getTagName(), [
64 | 'textarea',
65 | 'select',
66 | 'button',
67 | 'input',
68 | 'li',
69 | 'meter',
70 | 'option',
71 | 'param',
72 | 'progress',
73 | ]);
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Elements/SendKeys.php:
--------------------------------------------------------------------------------
1 | keys)) {
25 | $this->sendKeysDirectly($manager);
26 | return;
27 | }
28 |
29 | $element = $manager->resolver->resolveForTyping($this->selector);
30 |
31 | if ($this->clear_input) {
32 | $element->clear();
33 | }
34 |
35 | if ($this->pause > 0) {
36 | $this->sendKeysWithDelay($element);
37 | return;
38 | }
39 |
40 | $element->sendKeys($this->keys);
41 | }
42 |
43 | protected function sendKeysDirectly(BrowserManager $manager)
44 | {
45 | $manager->resolver->findOrFail($this->selector)
46 | ->sendKeys($this->parseKeys($this->keys));
47 | }
48 |
49 | protected function sendKeysWithDelay(RemoteWebElement $element)
50 | {
51 | foreach (preg_split('//u', $this->keys, -1, PREG_SPLIT_NO_EMPTY) as $key) {
52 | $element->sendKeys($key);
53 | usleep($this->pause * 1000);
54 | }
55 | }
56 |
57 | protected function parseKeys(array $keys): array
58 | {
59 | return collect($keys)
60 | ->map(function($key) {
61 | if (is_string($key) && Str::startsWith($key, '{') && Str::endsWith($key, '}')) {
62 | $key = constant(WebDriverKeys::class.'::'.strtoupper(trim($key, '{}')));
63 | }
64 |
65 | if (is_array($key) && Str::startsWith($key[0], '{')) {
66 | $key[0] = constant(WebDriverKeys::class.'::'.strtoupper(trim($key[0], '{}')));
67 | }
68 |
69 | return $key;
70 | })
71 | ->all();
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/.idea/watcherTasks.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
--------------------------------------------------------------------------------
/config/dawn.php:
--------------------------------------------------------------------------------
1 | '127.0.0.1',
14 | 'server_port' => null,
15 |
16 | /*
17 | |--------------------------------------------------------------------------
18 | | WebDriver
19 | |--------------------------------------------------------------------------
20 | |
21 | | Configure the Chrome/Selenium WebDriver setup.
22 | |
23 | */
24 | 'browser_url' => 'http://localhost:9515',
25 | 'browser_window' => '1200,720',
26 | 'browser_headless' => false,
27 | 'browser_sandbox' => null === env('CI'),
28 |
29 | /*
30 | |--------------------------------------------------------------------------
31 | | Storage
32 | |--------------------------------------------------------------------------
33 | |
34 | | Configure where Dawn should store screenshots/logs/etc by default.
35 | |
36 | */
37 | 'storage_screenshots' => resource_path('dawn/screenshots'),
38 | 'storage_logs' => resource_path('dawn/logs'),
39 | 'storage_sources' => resource_path('dawn/sources'),
40 |
41 | /*
42 | |--------------------------------------------------------------------------
43 | | DOM Targeting
44 | |--------------------------------------------------------------------------
45 | |
46 | | Configure the attribute Dawn uses for @dawnTarget() calls. This is useful
47 | | if you have other code that depends on `dusk=` attributes, or you want
48 | | to re-use targets for other systems, like `data-intercom-target` for
49 | | Intercom product tours, or `data-cy` or `data-testid` for Cypress tests.
50 | |
51 | */
52 | 'target_attribute' => 'data-dawn-target',
53 |
54 | /*
55 | |--------------------------------------------------------------------------
56 | | Debugger
57 | |--------------------------------------------------------------------------
58 | |
59 | | Available debuggers: null (no debugging), 'dump' (dump debug messages to
60 | | standard output), 'log' (send messages to debug log), or 'ray' (send
61 | | debug messages to Ray ).
62 | |
63 | */
64 | 'debugger' => null,
65 | ];
66 |
--------------------------------------------------------------------------------
/.github/workflows/phpunit.yml:
--------------------------------------------------------------------------------
1 | name: PHPUnit
2 |
3 | on:
4 | push:
5 | pull_request:
6 | schedule:
7 | - cron: '0 14 * * 3' # Run Wednesdays at 2pm EST
8 |
9 | jobs:
10 | php-tests:
11 | runs-on: ubuntu-latest
12 |
13 | strategy:
14 | matrix:
15 | php: [ 8.1 ]
16 | laravel: [ ^9.0 ]
17 | dependency-version: [ stable ]
18 |
19 | name: "${{ matrix.php }} / ${{ matrix.laravel }} (${{ matrix.dependency-version }})"
20 |
21 | steps:
22 | - name: Checkout code
23 | uses: actions/checkout@v2
24 |
25 | - name: Setup PHP
26 | uses: shivammathur/setup-php@v2
27 | with:
28 | php-version: ${{ matrix.php }}
29 | extensions: dom, curl, libxml, mbstring, zip, pcntl, bcmath, intl, iconv
30 | tools: composer:v2
31 |
32 | - name: Setup Chrome Driver
33 | uses: nanasess/setup-chromedriver@v1
34 |
35 | - name: Start Chrome Driver
36 | run: |
37 | export DISPLAY=:99
38 | chromedriver --port=9515 &
39 | sudo Xvfb -ac :99 -screen 0 1280x1024x24 > /dev/null 2>&1 &
40 |
41 | - name: Register composer cache directory
42 | id: composer-cache-files-dir
43 | run: echo "::set-output name=dir::$(composer config cache-files-dir)"
44 |
45 | - name: Cache dependencies
46 | uses: actions/cache@v2
47 | with:
48 | path: |
49 | vendor
50 | ${{ steps.composer-cache-files-dir.outputs.dir }}
51 | key: ${{ runner.os }}-composer-${{ hashFiles('composer.json') }}
52 | restore-keys: |
53 | ${{ runner.os }}-composer-
54 |
55 | - name: Set minimum stability
56 | run: composer config minimum-stability ${{ matrix.minimum-stability }}
57 |
58 | - name: Install dependencies
59 | env:
60 | COMPOSER_DISCARD_CHANGES: true
61 | run: composer require --no-suggest --no-progress --no-interaction --prefer-dist --update-with-all-dependencies "laravel/framework:${{ matrix.laravel }}"
62 |
63 | - name: Set dependency version
64 | env:
65 | COMPOSER_DISCARD_CHANGES: true
66 | run: composer update --no-suggest --no-progress --no-interaction --no-suggest --prefer-dist --with-all-dependencies --prefer-${{ matrix.dependency-version }}
67 |
68 | - name: Execute tests
69 | run: vendor/bin/phpunit
70 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Elements/Select.php:
--------------------------------------------------------------------------------
1 | value) {
23 | $this->value = $this->normalizeValue($this->value);
24 | }
25 | }
26 |
27 | protected function executeWithBrowser(BrowserManager $manager)
28 | {
29 | $element = $manager->resolver->resolveForSelection($this->selector);
30 |
31 | if ($multiple = $this->isMultiple($element)) {
32 | $this->deselectAll($element);
33 | }
34 |
35 | $this->clickMatchingOptions($this->getEnabledOptions($element), $multiple);
36 | }
37 |
38 | protected function clickMatchingOptions(Collection $options, bool $multiple = false)
39 | {
40 | // If we're not passed a value, just click a random item
41 | if (null === $this->value) {
42 | $options->random()->click();
43 | return;
44 | }
45 |
46 | // Otherwise click all (or the first, if not multi-select) matching one
47 | foreach ($options as $option) {
48 | if (in_array((string) $option->getAttribute('value'), $this->value)) {
49 | $option->click();
50 |
51 | if (! $multiple) {
52 | return;
53 | }
54 | }
55 | }
56 | }
57 |
58 | protected function normalizeValue($value): array
59 | {
60 | return collect(Arr::wrap($value))
61 | ->map(fn($value) => match ($value) {
62 | true => '1',
63 | false => '0',
64 | default => (string) $value,
65 | })
66 | ->all();
67 | }
68 |
69 | protected function isMultiple(RemoteWebElement $element): bool
70 | {
71 | if ('select' !== $element->getTagName()) {
72 | return false;
73 | }
74 |
75 | return (new WebDriverSelect($element))->isMultiple();
76 | }
77 |
78 | protected function deselectAll(RemoteWebElement $element): void
79 | {
80 | (new WebDriverSelect($element))->deselectAll();
81 | }
82 |
83 | protected function getEnabledOptions(RemoteWebElement $element): Collection
84 | {
85 | $selector = WebDriverBy::cssSelector('option:not([disabled])');
86 |
87 | return collect($element->findElements($selector));
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Assertions/AssertUrl.php:
--------------------------------------------------------------------------------
1 | expect($segment, $operator, $value);
20 | }
21 |
22 | public function __construct(?string $expected = null)
23 | {
24 | if (null !== $expected) {
25 | $this->expect('url', '=', $expected);
26 | }
27 | }
28 |
29 | public function expect(string $segment, string $operator, ?string $value = null): static
30 | {
31 | if (! in_array($operator, ['=', '!=', 'starts_with', 'has', 'missing'])) {
32 | throw new InvalidArgumentException("Invalid operator: '$operator'");
33 | }
34 |
35 | $this->expectations[] = [$segment, $operator, $value];
36 |
37 | return $this;
38 | }
39 |
40 | protected function loadData(BrowserManager $manager): void
41 | {
42 | $this->actual = $manager->getCurrentURL();
43 | }
44 |
45 | protected function performAssertions(RemoteWebDriverBroker $broker): void
46 | {
47 | $segments = parse_url($this->actual);
48 |
49 | $segments['url'] = sprintf(
50 | '%s://%s%s%s',
51 | $segments['scheme'],
52 | $segments['host'],
53 | Arr::get($segments, 'port', '')
54 | ? ':'.$segments['port']
55 | : '',
56 | Arr::get($segments, 'path', '')
57 | );
58 |
59 | foreach ($this->expectations as $expectation) {
60 | [$key, $operator, $expected] = $expectation;
61 | $actual = $segments[$key] ?? '';
62 |
63 | $pattern = '/^'.str_replace('\*', '.*', preg_quote($expected, '/')).'$/u';
64 |
65 | if ('=' === $operator) {
66 | Assert::assertMatchesRegularExpression(
67 | $pattern,
68 | $actual,
69 | "Actual {$key} [$actual] does not equal expected {$key} [$expected]."
70 | );
71 | }
72 |
73 | if ('!=' === $operator) {
74 | Assert::assertDoesNotMatchRegularExpression(
75 | $pattern,
76 | $actual,
77 | ucfirst($key)." [$actual] should not equal the actual value."
78 | );
79 | }
80 |
81 | if ('starts_with' === $operator) {
82 | Assert::assertStringStartsWith(
83 | $expected,
84 | $actual,
85 | "Actual {$key} [$actual] does not begin with expected {$key} [$expected]."
86 | );
87 | }
88 | }
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/src/Support/Broker.php:
--------------------------------------------------------------------------------
1 | loop ??= Loop::get();
32 |
33 | // Start the server process up
34 | $stdin ??= new InputStream();
35 | $this->process = $this->startBackgroundProcess($stdin);
36 |
37 | // We're going to pipe outgoing messages to our process's STDIN
38 | $process_input = new ThroughStream();
39 | $process_input->on('data', fn($data) => $stdin->write($data));
40 |
41 | // And poll for STDOUT to write to our incoming message stream
42 | $this->process_output = new ThroughStream();
43 | $this->loop->addPeriodicTimer(0.1, $this->flushIncomingMessageStream(...));
44 |
45 | $this->io = new CommandIO($this, $this->process_output, $process_input);
46 | }
47 |
48 | public function debug($message): static
49 | {
50 | app(Debugger::class)->debug($message);
51 |
52 | return $this;
53 | }
54 |
55 | public function sendCommand(Command $command): static
56 | {
57 | $this->io->sendCommand($command);
58 |
59 | // Force the process to flush I/O
60 | $this->process->getStatus();
61 |
62 | // Run for at least one cycle
63 | $this->loop->futureTick(fn() => $this->loop->stop());
64 | $this->loop->run();
65 |
66 | return $this;
67 | }
68 |
69 | protected function flushIncomingMessageStream(): void
70 | {
71 | try {
72 | $this->process_output->write(@$this->process->getIncrementalOutput());
73 | } catch (Throwable $exception) {
74 | dd($exception);
75 | }
76 | }
77 |
78 | protected function artisan(array $arguments, InputStream $stdin): Process
79 | {
80 | return new Process(
81 | command: array_merge([
82 | (new PhpExecutableFinder())->find(false),
83 | (new ArtisanExecutableFinder())->find(),
84 | ], $arguments),
85 | cwd: base_path(),
86 | env: collect($_ENV)
87 | ->only([
88 | 'APP_ENV',
89 | 'LARAVEL_SAIL',
90 | 'PHP_CLI_SERVER_WORKERS',
91 | 'PHP_IDE_CONFIG',
92 | 'SYSTEMROOT',
93 | 'XDEBUG_CONFIG',
94 | 'XDEBUG_MODE',
95 | 'XDEBUG_SESSION',
96 | ])
97 | ->all(),
98 | input: $stdin,
99 | );
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/src/Browser.php:
--------------------------------------------------------------------------------
1 | id = (string) Str::uuid();
36 | }
37 |
38 | public function sleep(float $seconds): static
39 | {
40 | // Rather than `usleep()`, we'll just run our loop until the timer
41 | // triggers. This way, other commands can continue to be processed
42 | // while this sleep operation runs.
43 |
44 | $sleeping = true;
45 | $this->loop->addTimer($seconds, function() use (&$sleeping) {
46 | $sleeping = false;
47 | $this->loop->stop();
48 | });
49 |
50 | while ($sleeping) {
51 | $this->loop->run();
52 | }
53 |
54 | return $this;
55 | }
56 |
57 | public function tap($callback): static
58 | {
59 | $callback($this);
60 |
61 | return $this;
62 | }
63 |
64 | public function tinker(): static
65 | {
66 | if (! class_exists(\Psy\Shell::class)) {
67 | throw new BadMethodCallException('Psy Shell (required for Tinker) is not installed.');
68 | }
69 |
70 | // Unfortunately, because of the I/O channel, the driver/resolver/page aren't available
71 | // inside the main process. I'm not sure if there's a solution for that…
72 |
73 | \Psy\Shell::debug([
74 | 'browser' => $this,
75 | // 'driver' => $this->driver,
76 | // 'resolver' => $this->resolver,
77 | // 'page' => $this->page,
78 | ], $this);
79 |
80 | return $this;
81 | }
82 |
83 | public function stop(): void
84 | {
85 | exit();
86 | }
87 |
88 | public function withLastResponse(Closure $callback): static
89 | {
90 | $callback($this->last_response);
91 |
92 | return $this;
93 | }
94 |
95 | /**
96 | * @return $this|mixed
97 | */
98 | protected function command(Command $command): mixed
99 | {
100 | if ($command instanceof BrowserCommand) {
101 | $command->setBrowser($this);
102 | }
103 |
104 | $this->last_response = $this->broker->sendCommandAndWaitForResponse($command);
105 |
106 | return $command instanceof ValueCommand
107 | ? $this->last_response
108 | : $this;
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/src/Browser/Concerns/ExecutesElementCommands.php:
--------------------------------------------------------------------------------
1 | command(new Attach($selector, $path));
29 | }
30 |
31 | public function checkOrUncheck(WebDriverBy|string $selector, bool $check = true): static
32 | {
33 | return $this->command(new CheckOrUncheck($selector, $check));
34 | }
35 |
36 | public function clear(WebDriverBy|string $selector): static
37 | {
38 | return $this->command(new Clear($selector));
39 | }
40 |
41 | public function click(WebDriverBy|string|null $selector, string $resolver = 'find', bool $wait = false): static
42 | {
43 | return $this->command(new Click($selector, $resolver, $wait));
44 | }
45 |
46 | public function clickRadio(WebDriverBy|string $selector): static
47 | {
48 | return $this->command(new ClickRadio($selector));
49 | }
50 |
51 | /** @return $this|mixed */
52 | public function getAttribute(WebDriverBy|string $selector, string $attribute): mixed
53 | {
54 | return $this->command(new GetAttribute($selector, $attribute));
55 | }
56 |
57 | public function getSelected(WebDriverBy|string $selector, string $value): bool
58 | {
59 | return $this->command(new GetSelected($selector, $value));
60 | }
61 |
62 | public function getText(WebDriverBy|string $selector): string
63 | {
64 | return $this->command(new GetText($selector));
65 | }
66 |
67 | /** @return $this|mixed */
68 | public function getValue(WebDriverBy|string $selector): mixed
69 | {
70 | return $this->command(new GetValue($selector));
71 | }
72 |
73 | public function select(WebDriverBy|string $selector, string|array|null $value = null): static
74 | {
75 | return $this->command(new Select($selector, $value));
76 | }
77 |
78 | public function sendKeys(WebDriverBy|string $selector, string|array $keys, bool $clear_input = false, int $pause = 0): static
79 | {
80 | return $this->command(new SendKeys($selector, $keys, $clear_input, $pause));
81 | }
82 |
83 | public function setValue(WebDriverBy|string $selector, $value): static
84 | {
85 | return $this->command(new SetValue($selector, $value));
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/src/Http/Commands/HandleWebRequest.php:
--------------------------------------------------------------------------------
1 | id ??= (string) Str::uuid();
25 |
26 | // Passing large uploads over the I/O channel can lead to memory issues. This just
27 | // puts the file in a temp location and then restores it on the other side.
28 | $this->saveFilesToFilesystem();
29 | }
30 |
31 | public function execute(WebServerBroker $broker)
32 | {
33 | try {
34 | $request = Request::createFromBase((new HttpFoundationFactory())->createRequest($this->request));
35 |
36 | $request->files->replace($this->getFilesFromFilesystem());
37 |
38 | $response = $this->runRequestThroughKernel($request);
39 |
40 | $broker->debug("[{$response->getStatusCode()}] {$request->url()}");
41 |
42 | $broker->sendCommand(ResponseMessage::from($this->id, $response));
43 | } catch (HttpException $exception) {
44 | $broker->sendCommand(ResponseMessage::from($this->id, response(
45 | content: $exception->getMessage(),
46 | status: $exception->getStatusCode(),
47 | headers: $exception->getHeaders(),
48 | )));
49 | }
50 | }
51 |
52 | protected function getFilesFromFilesystem(): array
53 | {
54 | return collect($this->files)
55 | ->map(function(array $data) {
56 | return new UploadedFile(
57 | $data['path'],
58 | $data['original_filename'],
59 | $data['mime'],
60 | $data['error'],
61 | );
62 | })
63 | ->all();
64 | }
65 |
66 | protected function saveFilesToFilesystem(): void
67 | {
68 | $this->files = collect($this->request->getUploadedFiles())
69 | ->map($this->saveFileToFilesystem(...))
70 | ->all();
71 |
72 | $this->request = $this->request->withUploadedFiles([]);
73 | }
74 |
75 | protected function saveFileToFilesystem(UploadedFileInterface $file): array
76 | {
77 | if ($file->getError()) {
78 | return [
79 | 'path' => '',
80 | 'original_filename' => $file->getClientFilename(),
81 | 'mime' => $file->getClientMediaType(),
82 | 'error' => $file->getError(),
83 | ];
84 | }
85 |
86 | $stream = $file->getStream();
87 | $destination = tempnam(sys_get_temp_dir(), 'dawn');
88 |
89 | if ($stream->isSeekable()) {
90 | $stream->rewind();
91 | }
92 |
93 | $handle = fopen($destination, 'w');
94 |
95 | while (! $stream->eof()) {
96 | fwrite($handle, $stream->read(1048576));
97 | }
98 |
99 | fclose($handle);
100 |
101 | return [
102 | 'path' => $destination,
103 | 'original_filename' => $file->getClientFilename(),
104 | 'mime' => $file->getClientMediaType(),
105 | 'error' => UPLOAD_ERR_OK,
106 | ];
107 | }
108 |
109 | protected function runRequestThroughKernel(Request $request): Response
110 | {
111 | $kernel = app(HttpKernel::class);
112 |
113 | $response = $kernel->handle($request);
114 |
115 | $kernel->terminate($request, $response);
116 |
117 | return $response;
118 | }
119 | }
120 |
--------------------------------------------------------------------------------
/src/Browser/BrowserManager.php:
--------------------------------------------------------------------------------
1 | connector = $this->getConnector($connect);
39 | $this->browsers = new Collection();
40 | }
41 |
42 | public function switchToBrowser(string $browser_id): static
43 | {
44 | $managed = $this->getDriver($browser_id);
45 |
46 | $this->driver = $managed->driver;
47 | $this->resolver = $managed->resolver;
48 |
49 | return $this;
50 | }
51 |
52 | public function quitAll(): void
53 | {
54 | $this->browsers->each(fn(ManagedDriver $driver) => $driver->quit());
55 | }
56 |
57 | public function stop(): void
58 | {
59 | $this->quitAll();
60 |
61 | $this->driver_process?->signal(SIGKILL);
62 | }
63 |
64 | public function __call(string $name, array $arguments)
65 | {
66 | return $this->forwardDecoratedCallTo($this->driver, $name, $arguments);
67 | }
68 |
69 | protected function getDriver(string $browser_id): ManagedDriver
70 | {
71 | if (! $this->browsers->has($browser_id)) {
72 | $this->browsers->put($browser_id, new ManagedDriver($this->newBrowserConnection()));
73 | }
74 |
75 | return $this->browsers->get($browser_id);
76 | }
77 |
78 | protected function newBrowserConnection(bool $autostart = true): RemoteWebDriver
79 | {
80 | try {
81 | return call_user_func($this->connector, $this);
82 | } catch (PhpWebDriverExceptionInterface $exception) {
83 | // If we've already tried to auto-start, then just fail
84 | if (! $autostart || $this->driver_process) {
85 | throw new WebDriverNotRunningException($exception);
86 | }
87 |
88 | $this->driver_process = app(SeleniumDriverProcess::class);
89 | return $this->newBrowserConnection(autostart: false);
90 | }
91 | }
92 |
93 | protected function getConnector(Closure|string $connect): Closure
94 | {
95 | if ($connect instanceof Closure) {
96 | return $connect;
97 | }
98 |
99 | return function() use ($connect) {
100 | $capabilities = DesiredCapabilities::chrome();
101 |
102 | if (! empty($arguments = $this->defaultChromeArguments())) {
103 | $capabilities->setCapability(ChromeOptions::CAPABILITY, (new ChromeOptions())->addArguments($arguments));
104 | }
105 |
106 | return RemoteWebDriver::create($connect, $capabilities);
107 | };
108 | }
109 |
110 | protected function defaultChromeArguments(): array
111 | {
112 | $arguments = [];
113 |
114 | if (false === config('dawn.browser_sandbox')) {
115 | $arguments[] = '--no-sandbox';
116 | $arguments[] = '--disable-dev-shm-usage';
117 | }
118 |
119 | if (config('dawn.browser_headless', true)) {
120 | $arguments[] = '--headless';
121 | $arguments[] = '--disable-gpu';
122 | }
123 |
124 | if ($window = config('dawn.browser_window')) {
125 | $arguments[] = '--window-size='.$window;
126 | }
127 |
128 | return $arguments;
129 | }
130 | }
131 |
--------------------------------------------------------------------------------
/src/Providers/DawnServiceProvider.php:
--------------------------------------------------------------------------------
1 | mergeConfigFrom($this->packageConfigFile(), 'dawn');
25 |
26 | if (! $this->app->runningUnitTests()) {
27 | return;
28 | }
29 |
30 | $this->app->singleton('dawn.loop', function() {
31 | return Loop::get();
32 | });
33 |
34 | $this->app->singleton(Debugger::class, function() {
35 | return match (config('dawn.debugger')) {
36 | 'dump' => new Debugger(fn($message) => dump($message)),
37 | 'ray' => new Debugger(fn($message) => ray($message)),
38 | 'log' => new Debugger(fn($message) => Log::debug($message)),
39 | default => new Debugger(),
40 | };
41 | });
42 |
43 | $this->app->bind(WebServerBroker::class, function(Container $app) {
44 | return $app->make(ProcessManager::class)->web_server;
45 | });
46 |
47 | $this->app->bind(RemoteWebDriverBroker::class, function(Container $app) {
48 | return $app->make(ProcessManager::class)->remote_web_driver;
49 | });
50 |
51 | $this->app->singleton(SeleniumDriverProcess::class, function() {
52 | return new SeleniumDriverProcess(port: $this->seleniumPort());
53 | });
54 |
55 | $this->app->bind(ProcessManager::class, function() {
56 | // Under the hood, Dawn manages its own singleton instance so that the same
57 | // processes can be shared across multiple tests. We pass the current Dawn
58 | // config in each time to ensure that if config values have been changed
59 | // dynamically, new processes can be spawned.
60 | return ProcessManager::getInstance(config('dawn'));
61 | });
62 |
63 | $this->app->bind(Browser::class, function(Container $app) {
64 | // When we ask for a browser, we want all background processes running,
65 | // so we'll load up the full process manager (even though the browser
66 | // only really cares about the webdriver process).
67 | return new Browser(
68 | broker: $app->make(ProcessManager::class)->remote_web_driver,
69 | loop: $app->make('dawn.loop'),
70 | );
71 | });
72 | }
73 |
74 | public function boot()
75 | {
76 | $this->publishes(
77 | [$this->packageConfigFile() => $this->app->configPath('dawn.php')],
78 | ['dawn', 'dawn-config']
79 | );
80 |
81 | Blade::directive('dawnTarget', function($expression) {
82 | $attribute = config('dawn.target_attribute', 'data-dawn-target');
83 |
84 | if (empty($attribute)) {
85 | return '';
86 | }
87 |
88 | return '';
89 | });
90 |
91 | if ($this->app->runningInConsole() || $this->app->runningUnitTests()) {
92 | $this->commands([
93 | DriveCommand::class,
94 | GenerateCommandHelpersCommand::class,
95 | ServeCommand::class,
96 | ]);
97 | }
98 | }
99 |
100 | protected function seleniumPort(): int
101 | {
102 | $port = parse_url(config('dawn.browser_url', 'http://localhost:9515'), PHP_URL_PORT);
103 |
104 | if (is_numeric($port)) {
105 | return (int) $port;
106 | }
107 |
108 | return 9515;
109 | }
110 |
111 | protected function packageConfigFile(): string
112 | {
113 | return __DIR__.'/../../config/dawn.php';
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/src/Browser/Commands/Assertions/AssertElementStatus.php:
--------------------------------------------------------------------------------
1 | resolveElement($manager->resolver)) {
41 | $this->exists = false;
42 | $this->displayed = false;
43 | $this->selected = false;
44 | $this->enabled = false;
45 | $this->focused = false;
46 | return;
47 | }
48 |
49 | $this->displayed = $element->isDisplayed();
50 | $this->selected = $element->isSelected();
51 | $this->enabled = $element->isEnabled();
52 | $this->focused = $manager->switchTo()->activeElement()->equals($element);
53 | }
54 |
55 | protected function resolveElement(ElementResolver $resolver): ?RemoteWebElement
56 | {
57 | return $resolver->{$this->resolver}($this->selector());
58 | }
59 |
60 | protected function performAssertions(RemoteWebDriverBroker $broker): void
61 | {
62 | $selector = $this->selector()->getValue();
63 |
64 | $this->performExistenceAssertions($selector);
65 | $this->performDisplayAssertions($selector);
66 | $this->performSelectionAssertions($selector);
67 | $this->performEnabledAssertions($selector);
68 | $this->performFocusAssertions($selector);
69 | }
70 |
71 | protected function performExistenceAssertions(string $selector)
72 | {
73 | if (true === $this->expect_exists) {
74 | Assert::assertTrue($this->exists, "Element [{$selector}] does not exist.");
75 | }
76 |
77 | if (false === $this->expect_exists) {
78 | Assert::assertFalse($this->exists, "Element [{$selector}] exists.");
79 | }
80 | }
81 |
82 | protected function performDisplayAssertions(string $selector)
83 | {
84 | if (true === $this->expect_displayed) {
85 | Assert::assertTrue($this->displayed, "Element [{$selector}] is not displayed.");
86 | }
87 |
88 | if (false === $this->expect_displayed) {
89 | Assert::assertFalse($this->displayed, "Element [{$selector}] is displayed.");
90 | }
91 | }
92 |
93 | protected function performSelectionAssertions(string $selector)
94 | {
95 | if (true === $this->expect_selected) {
96 | Assert::assertTrue($this->selected, "Element [{$selector}] is not selected.");
97 | }
98 |
99 | if (false === $this->expect_selected) {
100 | Assert::assertFalse($this->selected, "Element [{$selector}] is selected.");
101 | }
102 | }
103 |
104 | protected function performEnabledAssertions(string $selector)
105 | {
106 | if (true === $this->expect_enabled) {
107 | Assert::assertTrue($this->enabled, "Element [{$selector}] is not enabled.");
108 | }
109 |
110 | if (false === $this->expect_enabled) {
111 | Assert::assertFalse($this->enabled, "Element [{$selector}] is enabled.");
112 | }
113 | }
114 |
115 | protected function performFocusAssertions(string $selector)
116 | {
117 | if (true === $this->expect_focused) {
118 | Assert::assertTrue($this->focused, "Expected element [{$selector}] to be focused, but it wasn't.");
119 | }
120 |
121 | if (false === $this->expect_focused) {
122 | Assert::assertFalse($this->focused, "Expected element [{$selector}] not to be focused, but it was.");
123 | }
124 | }
125 | }
126 |
--------------------------------------------------------------------------------
/.php-cs-fixer.dist.php:
--------------------------------------------------------------------------------
1 | setRiskyAllowed(true)
7 | ->setIndent("\t")
8 | ->setLineEnding("\n")
9 | ->setRules([
10 | '@PSR2' => true,
11 | 'function_declaration' => [
12 | 'closure_function_spacing' => 'none',
13 | 'closure_fn_spacing' => 'none',
14 | ],
15 | 'ordered_imports' => [
16 | 'sort_algorithm' => 'alpha',
17 | ],
18 | 'array_indentation' => true,
19 | 'braces' => [
20 | 'allow_single_line_closure' => true,
21 | ],
22 | 'no_break_comment' => false,
23 | 'return_type_declaration' => [
24 | 'space_before' => 'none',
25 | ],
26 | 'blank_line_after_opening_tag' => true,
27 | 'compact_nullable_typehint' => true,
28 | 'cast_spaces' => true,
29 | 'concat_space' => [
30 | 'spacing' => 'none',
31 | ],
32 | 'declare_equal_normalize' => [
33 | 'space' => 'none',
34 | ],
35 | 'function_typehint_space' => true,
36 | 'new_with_braces' => true,
37 | 'method_argument_space' => true,
38 | 'no_empty_statement' => true,
39 | 'no_empty_comment' => true,
40 | 'no_empty_phpdoc' => true,
41 | 'no_extra_blank_lines' => [
42 | 'tokens' => [
43 | 'extra',
44 | 'use',
45 | 'use_trait',
46 | 'return',
47 | ],
48 | ],
49 | 'no_leading_import_slash' => true,
50 | 'no_leading_namespace_whitespace' => true,
51 | 'no_blank_lines_after_class_opening' => true,
52 | 'no_blank_lines_after_phpdoc' => true,
53 | 'no_whitespace_in_blank_line' => false,
54 | 'no_whitespace_before_comma_in_array' => true,
55 | 'no_useless_else' => true,
56 | 'no_useless_return' => true,
57 | 'single_trait_insert_per_statement' => true,
58 | 'psr_autoloading' => true,
59 | 'dir_constant' => true,
60 | 'single_line_comment_style' => [
61 | 'comment_types' => ['hash'],
62 | ],
63 | 'include' => true,
64 | 'is_null' => true,
65 | 'linebreak_after_opening_tag' => true,
66 | 'lowercase_cast' => true,
67 | 'lowercase_static_reference' => true,
68 | 'magic_constant_casing' => true,
69 | 'magic_method_casing' => true,
70 | 'class_attributes_separation' => [
71 | // TODO: This can be reverted when https://github.com/FriendsOfPHP/PHP-CS-Fixer/pull/5869 is merged
72 | 'elements' => ['const' => 'one', 'method' => 'one', 'property' => 'one'],
73 | ],
74 | 'modernize_types_casting' => true,
75 | 'native_function_casing' => true,
76 | 'native_function_type_declaration_casing' => true,
77 | 'no_alias_functions' => true,
78 | 'no_multiline_whitespace_around_double_arrow' => true,
79 | 'multiline_whitespace_before_semicolons' => true,
80 | 'no_short_bool_cast' => true,
81 | 'no_unused_imports' => true,
82 | 'no_php4_constructor' => true,
83 | 'no_singleline_whitespace_before_semicolons' => true,
84 | 'no_spaces_around_offset' => true,
85 | 'no_trailing_comma_in_list_call' => true,
86 | 'no_trailing_comma_in_singleline_array' => true,
87 | 'normalize_index_brace' => true,
88 | 'object_operator_without_whitespace' => true,
89 | 'phpdoc_annotation_without_dot' => true,
90 | 'phpdoc_indent' => true,
91 | 'phpdoc_no_package' => true,
92 | 'phpdoc_no_access' => true,
93 | 'phpdoc_no_useless_inheritdoc' => true,
94 | 'phpdoc_single_line_var_spacing' => true,
95 | 'phpdoc_trim' => true,
96 | 'phpdoc_types' => true,
97 | 'semicolon_after_instruction' => true,
98 | 'array_syntax' => [
99 | 'syntax' => 'short',
100 | ],
101 | 'list_syntax' => [
102 | 'syntax' => 'short',
103 | ],
104 | 'short_scalar_cast' => true,
105 | 'single_blank_line_before_namespace' => true,
106 | 'single_quote' => true,
107 | 'standardize_not_equals' => true,
108 | 'ternary_operator_spaces' => true,
109 | 'whitespace_after_comma_in_array' => true,
110 | 'not_operator_with_successor_space' => true,
111 | 'trailing_comma_in_multiline' => true,
112 | 'trim_array_spaces' => true,
113 | 'binary_operator_spaces' => true,
114 | 'unary_operator_spaces' => true,
115 | 'php_unit_method_casing' => [
116 | 'case' => 'snake_case',
117 | ],
118 | 'php_unit_test_annotation' => [
119 | 'style' => 'prefix',
120 | ],
121 | ])
122 | ->setFinder(
123 | PhpCsFixer\Finder::create()
124 | ->exclude('.circleci')
125 | ->exclude('bin')
126 | ->exclude('node_modules')
127 | ->exclude('vendor')
128 | ->notPath('.phpstorm.meta.php')
129 | ->notPath('_ide_helper.php')
130 | ->notPath('artisan')
131 | ->in(__DIR__)
132 | );
133 |
--------------------------------------------------------------------------------
/src/Http/Relays/LocalHttpCommandRelay.php:
--------------------------------------------------------------------------------
1 | $queue */
35 | protected Collection $queue;
36 |
37 | protected SocketServer $socket;
38 |
39 | public function __construct(
40 | protected LoopInterface $loop,
41 | protected CommandIO $io,
42 | protected ?string $public_path = null,
43 | string $host = '127.0.0.1',
44 | int $port = 8089,
45 | ) {
46 | $this->public_path ??= getcwd();
47 | $this->queue = new Collection();
48 | $this->socket = $this->getSocketServer($host, $port, $this->loop);
49 | }
50 |
51 | public function handleResponse(string $request_id, Response $response): void
52 | {
53 | // If we don't have a handler queued for this message ID, we'll just abort
54 | if (! $resolve = $this->queue->pull($request_id)) {
55 | $this->sendCommand(ThrowException::runtime("There is no handler for request '{$request_id}'"));
56 | return;
57 | }
58 |
59 | $resolve($response);
60 | }
61 |
62 | public function stop(): void
63 | {
64 | // Clear out remaining queue
65 | while ($resolve = $this->queue->shift()) {
66 | $resolve(Response::plaintext('Shutting down at '.microtime(true))->withStatus(500));
67 | }
68 |
69 | $this->socket->close();
70 | }
71 |
72 | protected function getSocketServer(string $host, int $port, LoopInterface $loop): SocketServer
73 | {
74 | $socket = new SocketServer("{$host}:{$port}", [], $loop);
75 |
76 | $http = new ReactHttpServer(
77 | $loop,
78 | new StreamingRequestMiddleware(),
79 | new LimitConcurrentRequestsMiddleware(100),
80 | new RequestBodyBufferMiddleware(32 * 1024 * 1024), // 32 MB
81 | new RequestBodyParserMiddleware(32 * 1024 * 1024, 100), // 32 MB (these maybe need to be configurable)
82 | $this->handleRequest(...)
83 | );
84 |
85 | $http->listen($socket);
86 |
87 | $http->on('error', function(Exception $exception) {
88 | echo $exception->getMessage().PHP_EOL;
89 | });
90 |
91 | return $socket;
92 | }
93 |
94 | protected function handleRequest(ServerRequestInterface $psr_request): Promise|Response
95 | {
96 | // If this is just a request for a static asset, just stream that content back
97 | if ($static_response = $this->staticResponse($psr_request)) {
98 | return $static_response;
99 | }
100 |
101 | $this->sendCommand($message = new HandleWebRequest($psr_request));
102 |
103 | return $this->deferredResponse($message->id);
104 | }
105 |
106 | protected function deferredResponse(string $id): Promise
107 | {
108 | $promise = new Promise(function($resolve) use ($id) {
109 | $this->queue->put($id, $resolve);
110 |
111 | // 30 second timeout
112 | Loop::addTimer(30, function() use ($id, $resolve) {
113 | if ($this->queue->pull($id, false)) {
114 | $resolve($this->timeout());
115 | }
116 | });
117 | });
118 |
119 | // Handle exception
120 | $promise->otherwise(fn(Throwable $exception) => $this->internalError($exception));
121 |
122 | return $promise;
123 | }
124 |
125 | protected function staticResponse(ServerRequestInterface $psr_request): ?Response
126 | {
127 | $path = $psr_request->getUri()->getPath();
128 |
129 | if (Str::contains($path, '../')) {
130 | return null;
131 | }
132 |
133 | $filepath = $this->public_path.'/'.ltrim($path, '/');
134 |
135 | if (file_exists($filepath) && ! is_dir($filepath)) {
136 | return new Response(
137 | status: 200,
138 | headers: [
139 | 'Content-Type' => match (pathinfo($filepath, PATHINFO_EXTENSION)) {
140 | 'css' => 'text/css',
141 | 'js' => 'application/javascript',
142 | 'png' => 'image/png',
143 | 'jpg', 'jpeg' => 'image/jpeg',
144 | 'svg' => 'image/svg+xml',
145 | 'woff' => 'font/woff',
146 | 'woff2' => 'font/woff2',
147 | 'eot' => 'application/vnd.ms-fontobject',
148 | 'ttf' => 'font/ttf',
149 | default => (new MimeTypes())->guessMimeType($filepath),
150 | },
151 | ],
152 | body: new ReadableResourceStream(fopen($filepath, 'r')),
153 | );
154 | }
155 |
156 | return null;
157 | }
158 |
159 | protected function timeout(): ResponseInterface
160 | {
161 | return Response::plaintext('Request timed out.')
162 | ->withStatus(Response::STATUS_GATEWAY_TIMEOUT);
163 | }
164 |
165 | protected function internalError(?Throwable $exception): ResponseInterface
166 | {
167 | $exception ??= new Exception('Internal error');
168 |
169 | return Response::plaintext($exception->getMessage()."\n".$exception->getTraceAsString())
170 | ->withStatus(Response::STATUS_INTERNAL_SERVER_ERROR);
171 | }
172 | }
173 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
33 |
34 | # Dawn
35 |
36 | Dawn is an experimental browser-testing library for Laravel. It aims to be mostly compatible with Dusk,
37 | but with different trade-offs[^1]. The main benefit of Dawn is that it allows you to write browser tests
38 | exactly as you write all other feature tests (database transactions, mocks, custom testing routes, etc).
39 | This generally means that they run faster and with fewer restrictions.
40 |
41 | > **Warning**
42 | > This is a very early release. Some edge-cases have been accounted for. Many have not. Much of the Dusk
43 | > API has been implemented, but plenty of methods and assertions are missing.
44 |
45 | ## Installation
46 |
47 | ### Install Dawn
48 | You can install the development release of Dawn via Composer (you'll need PHP 8.1 and Laravel 9):
49 |
50 | ```shell
51 | composer require glhd/dawn:dev-main
52 | ```
53 |
54 | ### Install Chrome Driver globally
55 | You'll also need [chromedriver](https://chromedriver.chromium.org/downloads) installed on your machine.
56 |
57 | > **Note**
58 | > Eventually, Dawn will install a copy of chromedriver for you, just like Dusk. The current
59 | > implementation has only been tested on MacOS with chromedriver installed via homebrew. YMMV.
60 |
61 | ## Usage
62 |
63 | To use Dawn, add `RunsBrowserTests` to any test case. Then you can call `openBrowser()` to get
64 | a `Browser` instance to start testing with.
65 |
66 | ```php
67 | class MyBrowserTest extends TestCase
68 | {
69 | use RefreshDatabase;
70 | use RunsBrowserTests;
71 |
72 | public function test_can_visit_homepage()
73 | {
74 | $this->openBrowser() // <-- this is Dawn
75 | ->visit('/')
76 | ->assertTitleContains('Home');
77 | }
78 | }
79 | ```
80 |
81 | ### Dawn API
82 |
83 | Dawn aims to have an API that is mostly compatible with [Laravel Dusk](https://laravel.com/docs/9.x/dusk).
84 | Not all features or assertions are implemented, but for right now you're best using the Dusk documentation
85 | for reference.
86 |
87 | #### Differences from Dusk
88 |
89 | The primary API difference between Dawn and Dusk is that the Dawn APIs do not require browser
90 | interactions to happen inside of callbacks. For the most part, this just involves replacing
91 | calls to `$this->browse()` with `$this->openBrowser()` and removing the closure:
92 |
93 | ```diff
94 | -$this->browse(function ($browser) {
95 | +$browser = $this->openBrowser();
96 | $browser->visit('/login')
97 | -});
98 | ```
99 |
100 | There are certain Dusk methods are either tricky to implement with Dawn's async I/O channel, or are
101 | impossible to recreate exactly due to serialization constraints (you cannot serialize a TCP connection, for example).
102 | Here are some such functions:
103 |
104 | - `login()`, `loginAs()`, etc — these don't make any sense in Dawn, because you can just
105 | use the normal `actingAs()` or `be()` helper methods in your test.
106 | - `cookie()` and `plainCookie()` — these will eventually be implemented
107 | - `element()` and `elements()` — because Dawn interacts with the WebDriver instance in a background process,
108 | it is a little harder to get direct access to the underlying `RemoteWebElement` instances in your main
109 | PHPUnit process. There will eventually be an API for accessing these, but it will likely work slightly differently.
110 | - `ensurejQueryIsAvailable()` — Dawn does not rely on jQuery
111 |
112 | ### Dusk API Compatibility
113 |
114 | Much of the Dusk API has been implemented, but not all of it.
115 |
116 | #### Missing methods (may not be exhaustive):
117 |
118 | - `pressAndWaitFor()`
119 | - `within()`/`with()`/`elsewhere()`/`elsewhereWhenAvailable()` (scopes are generally not implemented yet)
120 | - `onComponent()` (components aren't implemented yet)
121 |
122 | #### Missing assertions (may not be exhaustive):
123 |
124 | - `assertVueContains()`
125 | - `assertVueDoesNotContain()`
126 | - `assertQueryStringHas()`
127 | - `assertQueryStringMissing()`
128 |
129 | ## Troubleshooting
130 |
131 |
132 | I get an error like `Failed to connect to localhost port 9515 after 2 ms: Couldn't connect to server`
133 | Make sure you have both `chromedriver` and the same version of Google Chrome installed.
134 |
135 |
136 | ## FAQ
137 |
138 |
139 | Does it work on anything other that Chris's Mac?
140 | 🤷♂️
141 | Will it ever work on Windows?
142 | 🤷♂️ Probably?
143 | When can I use this?
144 | Fortune favors the brave.
145 | Is this the final API?
146 | Most of the `RunsBrowserTests` is pretty solid. The underlying implementation may change a ton before 1.0.
147 |
148 |
149 | ## Major TODO Items
150 |
151 | - Finish copying over the Dusk API
152 | - Full test coverage
153 | - Get automated tests running on Windows
154 | - Improve the `chromedriver` installation/etc story
155 | - Get drag and drop working
156 |
157 | [^1]: Dawn is generally easier to use out of the box, but writing custom low-level WebDriver code
158 | is trickier due to how Dawn works under the hood.
159 |
--------------------------------------------------------------------------------
/src/Console/Commands/GenerateCommandHelpersCommand.php:
--------------------------------------------------------------------------------
1 | base = rtrim(realpath(__DIR__.'/../../Browser/Commands'), '/').'/';
24 |
25 | $this->line('Generating traits...');
26 | $this->newLine();
27 |
28 | $traits = collect(Finder::create()->files()->in($this->base)->name('*.php'))
29 | ->reduce(function(Collection $traits, SplFileInfo $file) {
30 | [$trait, $imports, $function_name, $function_body] = $this->handleFile($file);
31 |
32 | if (! $trait) {
33 | return $traits;
34 | }
35 |
36 | $traits[$trait] ??= (object) [
37 | 'trait' => $trait,
38 | 'imports' => new Collection(),
39 | 'functions' => new Collection(),
40 | ];
41 |
42 | $traits[$trait]->imports->push(...$imports->all());
43 | $traits[$trait]->functions->put($function_name, $function_body);
44 |
45 | return $traits;
46 | }, new Collection())
47 | ->map(function($trait) {
48 | $functions = $trait->functions->sortKeys()->values()->implode("\n\t\n");
49 |
50 | $imports = $trait->imports->unique()
51 | ->filter(function($import) use ($functions) {
52 | return Str::contains($functions, [$import, "\{$import}"])
53 | || preg_match('/(?:^|[^a-z])'.preg_quote(class_basename($import), '/').'[: (|]/', $functions);
54 | })
55 | ->sort()
56 | ->map(fn($import) => "use {$import};")
57 | ->implode("\n");
58 |
59 | $code = $this->template($trait->trait, $imports, $functions);
60 |
61 | $fs = new Filesystem();
62 | $path = __DIR__.'/../../Browser/Concerns/'.$trait->trait.'.php';
63 | $fs->put($path, $code);
64 |
65 | return "use {$trait->trait};";
66 | })
67 | ->sort()
68 | ->each(fn($use) => $this->line($use))
69 | ->implode("\n\t");
70 |
71 | $fs = new Filesystem();
72 | $path = __DIR__.'/../../Browser/Concerns/ExecutesCommands.php';
73 | $code = <<put($path, $code);
91 |
92 | $this->newLine();
93 | }
94 |
95 | protected function handleFile(SplFileInfo $file): array
96 | {
97 | $source = $file->getContents();
98 |
99 | $trait = Str::of($file->getRelativePath())
100 | ->whenEmpty(fn() => Str::of('Browser'))
101 | ->replace('//', '')
102 | ->singular()
103 | ->prepend('Executes')
104 | ->append('Commands');
105 |
106 | $classname = $file->getBasename('.php');
107 | $fqcn = $this->getNamespace($source).'\\'.$classname;
108 | $function_name = lcfirst($classname);
109 |
110 | if (! Str::contains($source, ['extends BrowserCommand', 'extends BrowserAssertionCommand'])) {
111 | return [null, null, null, null];
112 | }
113 |
114 | $imports = $this->getImports($source);
115 | $imports->push($fqcn);
116 |
117 | $return_type = $this->getReturnType($source);
118 |
119 | $parameters = $this->getParameters($source);
120 | $function = <<arguments}): {$return_type}
122 | {
123 | return \$this->command(new {$classname}({$parameters->calls}));
124 | }
125 | END_CODE;
126 |
127 | // Allow "mixed" returns to be fluent
128 | if ('mixed' === $return_type) {
129 | $function = "\t/** @return \$this|mixed */\n$function";
130 | }
131 |
132 | return [(string) $trait, $imports, $function_name, $function];
133 | }
134 |
135 | protected function getReturnType(string $source): string
136 | {
137 | $return_type_pattern = '/function executeWithBrowser\([^)]*\):\s+(?P.+)\s*$/m';
138 |
139 | if (preg_match($return_type_pattern, $source, $matches)) {
140 | return trim($matches['return_type']);
141 | }
142 |
143 | return Str::contains($source, 'implements ValueCommand')
144 | ? 'mixed'
145 | : 'static';
146 | }
147 |
148 | protected function getParameters(string $source): stdClass
149 | {
150 | if (! preg_match('/__construct\((.*?)\)/s', $source, $matches)) {
151 | return (object) [
152 | 'arguments' => '',
153 | 'calls' => '',
154 | ];
155 | }
156 |
157 | [$arguments, $calls] = Str::of($matches[1])
158 | ->explode(',')
159 | ->map(fn($parameter) => trim($parameter))
160 | ->filter()
161 | ->map(function($parameter) {
162 | $pattern = '/^\s*(?:private|protected|public)?(?:\s+readonly)?\s*(?:(?P[^\s]*)\s+)(?P\$[a-z0-9_]+)(?:\s*(?P=.*)\s*)?$/i';
163 | preg_match($pattern, $parameter, $matches);
164 | return (object) $matches;
165 | })
166 | ->reduceSpread(function(Collection $arguments, Collection $calls, stdClass $parameter) {
167 | $arguments->push(collect([$parameter->type ?? null, $parameter->variable, $parameter->defaults ?? null])->filter()->implode(' '));
168 | $calls->push($parameter->variable);
169 | return [$arguments, $calls];
170 | }, new Collection(), new Collection());
171 |
172 | return (object) [
173 | 'arguments' => $arguments->implode(', '),
174 | 'calls' => $calls->implode(', '),
175 | ];
176 | }
177 |
178 | protected function getImports(string $source): Collection
179 | {
180 | preg_match_all('/use\s+(.*);/i', $source, $matches);
181 |
182 | return collect($matches[1] ?? []);
183 | }
184 |
185 | protected function getNamespace(string $source): string
186 | {
187 | preg_match('/^namespace (Glhd\\\\Dawn.*);/im', $source, $matches);
188 |
189 | return $matches[1];
190 | }
191 |
192 | protected function template(string $trait, string $imports, string $functions): string
193 | {
194 | $fqcn = static::class;
195 |
196 | return <<command(new AssertAttribute($selector, $attribute, $value, $not, $contains));
42 | }
43 |
44 | public function assertAttributeMissing(WebDriverBy|string $selector, string $attribute): static
45 | {
46 | return $this->command(new AssertAttributeMissing($selector, $attribute));
47 | }
48 |
49 | public function assertCookieMissing(string $name, bool $decrypt = true): static
50 | {
51 | return $this->command(new AssertCookieMissing($name, $decrypt));
52 | }
53 |
54 | public function assertDialogOpened(?string $expected = null): static
55 | {
56 | return $this->command(new AssertDialogOpened($expected));
57 | }
58 |
59 | public function assertDontSeeIn(string|WebDriverBy $selector, string $needle): static
60 | {
61 | return $this->command(new AssertDontSeeIn($selector, $needle));
62 | }
63 |
64 | public function assertElementStatus(WebDriverBy|string $selector, ?bool $expect_exists = true, string $resolver = 'find', ?bool $expect_displayed = null, ?bool $expect_selected = null, ?bool $expect_enabled = null, ?bool $expect_focused = null): static
65 | {
66 | return $this->command(new AssertElementStatus($selector, $expect_exists, $resolver, $expect_displayed, $expect_selected, $expect_enabled, $expect_focused));
67 | }
68 |
69 | public function assertHasClass(WebDriverBy|string $selector, string|array $class, bool $not = false): static
70 | {
71 | return $this->command(new AssertHasClass($selector, $class, $not));
72 | }
73 |
74 | public function assertHasCookie(string $name, ?string $expected = null, bool $decrypt = true): static
75 | {
76 | return $this->command(new AssertHasCookie($name, $expected, $decrypt));
77 | }
78 |
79 | public function assertInputValue(WebDriverBy|string $selector, $value, bool $not = false): static
80 | {
81 | return $this->command(new AssertInputValue($selector, $value, $not));
82 | }
83 |
84 | public function assertLinkVisibility(string $text, bool $expected = true, bool $partial = false): static
85 | {
86 | return $this->command(new AssertLinkVisibility($text, $expected, $partial));
87 | }
88 |
89 | public function assertOptionPresence(WebDriverBy|string $selector, array $options, bool $expected = true, string $message = ''): static
90 | {
91 | return $this->command(new AssertOptionPresence($selector, $options, $expected, $message));
92 | }
93 |
94 | public function assertOptionSelectionState(WebDriverBy|string $selector, $value, bool $expected = true, string $message = ''): static
95 | {
96 | return $this->command(new AssertOptionSelectionState($selector, $value, $expected, $message));
97 | }
98 |
99 | public function assertQueryStringHas(string $name, $value = null): static
100 | {
101 | return $this->command(new AssertQueryStringHas($name, $value));
102 | }
103 |
104 | public function assertScript(string $expression, $expected = true): static
105 | {
106 | return $this->command(new AssertScript($expression, $expected));
107 | }
108 |
109 | public function assertSeeAnythingIn(string|WebDriverBy $selector): static
110 | {
111 | return $this->command(new AssertSeeAnythingIn($selector));
112 | }
113 |
114 | public function assertSeeIn(string|WebDriverBy $selector, string $needle): static
115 | {
116 | return $this->command(new AssertSeeIn($selector, $needle));
117 | }
118 |
119 | public function assertSeeNothingIn(string|WebDriverBy $selector): static
120 | {
121 | return $this->command(new AssertSeeNothingIn($selector));
122 | }
123 |
124 | public function assertSelectionState(WebDriverBy|string $selector, $value, bool $expected = true, bool $expect_indeterminate = false, string $resolver = 'findOrFail', string $message = ''): static
125 | {
126 | return $this->command(new AssertSelectionState($selector, $value, $expected, $expect_indeterminate, $resolver, $message));
127 | }
128 |
129 | public function assertSourceHas(string $needle): static
130 | {
131 | return $this->command(new AssertSourceHas($needle));
132 | }
133 |
134 | public function assertSourceMissing(string $needle): static
135 | {
136 | return $this->command(new AssertSourceMissing($needle));
137 | }
138 |
139 | public function assertTitle(string $expected): static
140 | {
141 | return $this->command(new AssertTitle($expected));
142 | }
143 |
144 | public function assertTitleContains(string $expected): static
145 | {
146 | return $this->command(new AssertTitleContains($expected));
147 | }
148 |
149 | public function assertUrl(?string $expected = null): static
150 | {
151 | return $this->command(new AssertUrl($expected));
152 | }
153 |
154 | public function assertValue(WebDriverBy|string $selector, $value, bool $not = false): static
155 | {
156 | return $this->command(new AssertValue($selector, $value, $not));
157 | }
158 |
159 | public function assertVue(string $key, $value, WebDriverBy|string|null $selector = null, bool $not = false): static
160 | {
161 | return $this->command(new AssertVue($key, $value, $selector, $not));
162 | }
163 | }
164 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/Project_Default.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 |
--------------------------------------------------------------------------------