├── 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 | 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 | 8 | 9 | @endsection 10 | -------------------------------------------------------------------------------- /.idea/phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 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 | 9 |
10 | @endsection 11 | -------------------------------------------------------------------------------- /.idea/laravel-idea-personal.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 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 | 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 | 7 | 8 | 9 |
10 |
16 |
22 |
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 |
5 | 6 |
7 | 8 |
9 | 12 | 13 | 16 | 17 | 23 | 24 | 29 | 30 | 33 | 34 | 39 | 40 | 45 | 46 | 49 |
50 |
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 | 16 | 24 | 25 | 36 | 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 |
2 | 3 | Build Status 7 | 8 | 9 | Test Coverage Status 13 | 14 | 15 | Latest Stable Release 19 | 20 | 21 | MIT Licensed 25 | 26 | 27 | Follow @inxilpro on Twitter 31 | 32 |
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 | 133 | --------------------------------------------------------------------------------