├── .gitignore ├── LICENSE ├── README.md ├── composer.json ├── config └── visual-testing.php ├── docs ├── first-run.png └── second-run.png ├── phpunit.xml ├── src ├── Agent.php ├── Console │ ├── DuskCommand.php │ └── DuskFailsCommand.php ├── Script.php └── VisualTestingServiceProvider.php └── tests ├── BuildsScriptTest.php └── TestCase.php /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | .idea 3 | .DS_Store 4 | composer.lock 5 | .phpunit.result.cache -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Signature Tech Studio 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Visual UI screenshot testing for Laravel Dusk 2 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/stechstudio/laravel-visual-testing.svg?style=flat-square)](https://packagist.org/packages/stechstudio/laravel-visual-testing) 3 | [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE.md) 4 | ![Build Status](https://app.chipperci.com/projects/5cc95e3c-628f-48c6-815c-1f16621c9514/status/master) 5 | 6 | 7 | This package extends Dusk with the ability to do visual diffs with the [Percy visual testing](https://percy.io/) platform. 8 | 9 | ### Why write visual tests? 10 | 11 | If you are new to the idea of visual testing we recommend reading through [Visual testing and visual diffs](https://blog.percy.io/product-spotlight-series-visual-testing-and-visual-diffs-6a1fc540fc93) on the Percy blog. 12 | 13 | > Sometimes called visual regression testing or UI testing, visual testing is the process of automatically discovering and reviewing software for perceptual changes. 14 | > 15 | > **Visual testing is all about what your users actually see and interact with.** 16 | 17 | This form of testing is very useful in cases where you want to guard against unexpected changed to your UI. Visual testing is not meant to replace your Laravel unit/feature/browser tests, but rather provide another tool in your testing toolbox. 18 | 19 | ## Getting started 20 | 21 | This package integrates with [Laravel Dusk](https://laravel.com/docs/master/dusk). If you haven't already, first go through the Dusk [installation steps](https://laravel.com/docs/master/dusk#installation) and make sure you can run the example test with `php artisan dusk`. 22 | 23 | Next: 24 | 25 | 1. Sign up for a free account at [percy.io](https://percy.io) and create your first project. Put your `PERCY_TOKEN` in your Laravel .env file (or [specific dusk environment files](https://laravel.com/docs/5.7/dusk#environment-handling) if you are using those). 26 | 27 | ``` 28 | PERCY_TOKEN=aaabbbcccdddeeefff 29 | ``` 30 | 31 | 2. Install the [`@percy/agent`](https://www.npmjs.com/package/@percy/agent) NPM package. 32 | 33 | ``` 34 | npm install --save-dev @percy/agent 35 | ``` 36 | 37 | 3. Install this composer package. 38 | 39 | ``` 40 | composer require stechstudio/laravel-visual-testing --dev 41 | ``` 42 | 43 | ## How to use 44 | 45 | To take a snapshot call `snapshot()` on the browser instance in any of your Dusk tests. 46 | 47 | ```php 48 | $browser->visit('/auth/login') 49 | ->snapshot(); 50 | ``` 51 | 52 | Then run your test suite like your normally would. 53 | 54 | ``` 55 | php artisan dusk 56 | ``` 57 | 58 | ### Naming your snapshots 59 | 60 | By default the name of your snapshot will be the relative URL of the page (e.g. `/auth/login`). You can also pass in your own name when taking the snapshot. 61 | 62 | ```php 63 | $browser->visit('/auth/login') 64 | ->snapshot('Login page'); 65 | ``` 66 | 67 | ### Snapshot options 68 | 69 | You can pass in an array of options when taking a snapshot: 70 | 71 | - `widths`: An array of integers representing the browser widths at which you want to take snapshots. 72 | - `minHeight`: An integer specifying the minimum height of the resulting snapshot, in pixels. Defaults to 1024px. 73 | 74 | ```php 75 | $browser->visit('/auth/login') 76 | ->snapshot('Login page', [ 'widths' => [768, 992, 1200] ]); 77 | ``` 78 | 79 | ### Disabling snapshots 80 | 81 | If you want to run your tests without snapshots, use the `--without-percy` command line option. 82 | 83 | ### Selecting base build branch 84 | 85 | Percy uses a variety of strategies to determine the optimal base build for comparison. For details see [Base build selection](https://docs.percy.io/docs/baseline-picking-logic). 86 | 87 | If you want to override and specify your own base you have two options: 88 | 89 | - `--percy-target-branch` : Specify base by branch name 90 | - `--percy-target-commit` : Specify by target commit SHA (only works if there is a finished Percy build for that commit) 91 | 92 | ## Basic example 93 | 94 | Open the example test at `tests/Browser/ExampleTest.php`. Add a call to `snapshot()` right after the `visit`, and pass in a name for your snapshot. 95 | 96 | ```php 97 | public function testBasicExample() 98 | { 99 | $this->browse(function (Browser $browser) { 100 | $browser->visit('/') 101 | ->snapshot('basic-example') // <-- add this 102 | ->assertSee('Laravel'); 103 | 104 | }); 105 | } 106 | ``` 107 | 108 | Now go run your test: 109 | 110 | ``` 111 | php artisan dusk 112 | ``` 113 | 114 | If all goes well, you should see output similar to this: 115 | 116 | ``` 117 | $ php artisan dusk 118 | [percy] created build #1 119 | [percy] percy has started. 120 | 121 | [percy] snapshot taken: 'basic-example' 122 | . 1 / 1 (100%) 123 | 124 | Time: 2.37 seconds, Memory: 22.00MB 125 | 126 | OK (1 test, 1 assertion) 127 | [percy] stopping percy... 128 | [percy] waiting for 1 snapshots to complete... 129 | [percy] done. 130 | [percy] finalized build #1 131 | ``` 132 | 133 | Now go check out your Percy dashboard, and you should see the new build. 134 | 135 | ![](docs/first-run.png) 136 | 137 | At this point it won't have anything to compare the snapshot to. But if you go modify the `welcome.blade.php` file and run it again, you'll get a nice visual diff of your change. 138 | 139 | ![](docs/second-run.png) 140 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stechstudio/laravel-visual-testing", 3 | "description": "Visual UI testing for Laravel Dusk using percy.io", 4 | "type": "library", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Joseph Szobody", 9 | "email": "joseph@stechstudio.com" 10 | } 11 | ], 12 | "require": { 13 | "laravel/dusk": "^6.0" 14 | }, 15 | "require-dev": { 16 | "orchestra/testbench": "^6.0", 17 | "phpunit/phpunit": "^9.0" 18 | }, 19 | "autoload": { 20 | "psr-4": { 21 | "STS\\VisualTesting\\": "src" 22 | } 23 | }, 24 | "autoload-dev": { 25 | "psr-4": { 26 | "STS\\VisualTesting\\Tests\\": "tests" 27 | } 28 | }, 29 | "extra": { 30 | "laravel": { 31 | "providers": [ 32 | "STS\\VisualTesting\\VisualTestingServiceProvider" 33 | ] 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /config/visual-testing.php: -------------------------------------------------------------------------------- 1 | [ 5 | /** 6 | * This assumes a local NPM install of @percy/agent. If you install it globally, you'll need 7 | * to set the _absolute_ path to the percy-agent.js file. 8 | */ 9 | 'agent_path' => env('PERCY_AGENT_PATH', base_path("node_modules/@percy/agent/dist/public/percy-agent.js")), 10 | 11 | /** 12 | * A string identifying the operating system, browser and other useful environment information, e.g. Windows; chrome. 13 | */ 14 | 'environment_info' => env('PERCY_ENVIRONMENT_INFO', 'laravel/' . Illuminate\Foundation\Application::VERSION), 15 | 16 | /** 17 | * An array of options to use by default with all your snapshots. 18 | * Can include: 19 | * - `widths` : An array of integers representing the browser widths at which you want to take snapshots. 20 | * - `minHeight` : An integer specifying the minimum height of the resulting snapshot, in pixels. Defaults to 1024px. 21 | */ 22 | 'snapshot_options' => [], 23 | ] 24 | ]; 25 | -------------------------------------------------------------------------------- /docs/first-run.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stechstudio/laravel-visual-testing/681e48507ac1e49980d6b01a059a61a9f73ac061/docs/first-run.png -------------------------------------------------------------------------------- /docs/second-run.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stechstudio/laravel-visual-testing/681e48507ac1e49980d6b01a059a61a9f73ac061/docs/second-run.png -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | tests 15 | 16 | 17 | 18 | 19 | src/ 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/Agent.php: -------------------------------------------------------------------------------- 1 | jsAgentPath = $jsAgentPath; 34 | $this->clientInfo = $clientInfo; 35 | $this->environmentInfo = $environmentInfo; 36 | $this->options = $options; 37 | } 38 | 39 | /** 40 | * @param Browser $browser 41 | * @param $name 42 | * @param array $options 43 | * 44 | * @return Browser 45 | */ 46 | public function snapshot(Browser $browser, $name = null, $options = []) 47 | { 48 | $browser->script( 49 | $this->getScript($browser, $name, $options)->toArray() 50 | ); 51 | 52 | // Gotta give it just a bit to breath. Otherwise we risk the browser disconnecting 53 | // before Percy loads and has time to take the snapshot. 54 | $browser->pause(100); 55 | 56 | return $browser; 57 | } 58 | 59 | /** 60 | * @param Browser $browser 61 | * @param $name 62 | * @param array $options 63 | * 64 | * @return Script 65 | */ 66 | public function getScript(Browser $browser, $name = null, $options = []) 67 | { 68 | return new Script( 69 | file_get_contents($this->jsAgentPath), 70 | [ 71 | 'clientInfo' => $this->clientInfo, 72 | 'environmentInfo' => $this->environmentInfo 73 | ], 74 | $this->name($browser, $name), 75 | $this->options($options) 76 | ); 77 | } 78 | 79 | /** 80 | * @param array $options 81 | * 82 | * @return array 83 | */ 84 | protected function options($options = []) 85 | { 86 | return array_merge($this->options, $options); 87 | } 88 | 89 | /** 90 | * @param Browser $browser 91 | * @param $name 92 | * 93 | * @return string 94 | */ 95 | protected function name(Browser $browser, $name = null) 96 | { 97 | return $name == null 98 | ? $this->generateName($browser) 99 | : $name; 100 | } 101 | 102 | /** 103 | * @param Browser $browser 104 | * 105 | * @return string 106 | */ 107 | protected function generateName(Browser $browser) 108 | { 109 | $name = str_replace($browser::$baseUrl, '', $browser->driver->getCurrentURL()); 110 | 111 | $this->generatedNames[$name] = Arr::get($this->generatedNames, $name, -1) + 1; 112 | 113 | return $this->generatedNames[$name] == 0 114 | ? $name 115 | : $name . " (" . $this->generatedNames[$name] . ")"; 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/Console/DuskCommand.php: -------------------------------------------------------------------------------- 1 | purgeScreenshots(); 32 | 33 | $this->purgeConsoleLogs(); 34 | 35 | return $this->withDuskEnvironment(function () { 36 | $process = $this->process(); 37 | 38 | try { 39 | $process->setTty(!$this->option('without-tty')); 40 | } catch (RuntimeException $e) { 41 | $this->output->writeln('Warning: ' . $e->getMessage()); 42 | } 43 | 44 | return $process->run(function ($type, $line) { 45 | $this->output->write($line); 46 | }, $this->env()); 47 | }); 48 | } 49 | 50 | /** 51 | * Prepend the Percy token and wrapper command 52 | * 53 | * @return array 54 | */ 55 | protected function binary() 56 | { 57 | if ($this->option('without-percy')) { 58 | return parent::binary(); 59 | } 60 | 61 | return array_merge([ 62 | 'npx', 63 | 'percy', 64 | 'exec', 65 | '--' 66 | ], parent::binary()); 67 | } 68 | 69 | /** 70 | * @return array 71 | */ 72 | protected function env() 73 | { 74 | return array_filter([ 75 | 'PERCY_TARGET_BRANCH' => $this->option('percy-target-branch'), 76 | 'PERCY_TARGET_COMMIT' => $this->option('percy-target-commit') 77 | ]); 78 | } 79 | 80 | /** 81 | * @return Process 82 | */ 83 | protected function process() 84 | { 85 | return (new Process(array_merge( 86 | $this->binary(), $this->phpunitArguments($this->processOptions()) 87 | )))->setTimeout(null); 88 | } 89 | 90 | /** 91 | * @return array 92 | */ 93 | protected function processOptions() 94 | { 95 | return array_filter(array_slice($_SERVER['argv'], 2), function($param) { 96 | return !Str::startsWith($param, ['--without-tty', '--pest', '--without-percy', '--percy-target-branch', '--percy-target-commit']); 97 | }); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/Console/DuskFailsCommand.php: -------------------------------------------------------------------------------- 1 | jsAgent = $jsAgent; 24 | $this->agentOptions = $agentOptions; 25 | $this->snapshotName = $snapshotName; 26 | $this->snapshotOptions = $snapshotOptions; 27 | } 28 | 29 | /** 30 | * @return mixed 31 | */ 32 | public function jsAgent() 33 | { 34 | return $this->jsAgent; 35 | } 36 | 37 | /** 38 | * @return mixed 39 | */ 40 | public function agentOptions() 41 | { 42 | return $this->agentOptions; 43 | } 44 | 45 | /** 46 | * @return mixed 47 | */ 48 | public function snapshotName() 49 | { 50 | return $this->snapshotName; 51 | } 52 | 53 | /** 54 | * @return mixed 55 | */ 56 | public function snapshotOptions() 57 | { 58 | return $this->snapshotOptions; 59 | } 60 | 61 | /** 62 | * @return array 63 | */ 64 | public function toArray() 65 | { 66 | return [ 67 | $this->jsAgent(), 68 | sprintf( 69 | "const percyAgentClient = new PercyAgent(%s); percyAgentClient.snapshot(%s, %s);", 70 | json_encode($this->agentOptions()), 71 | json_encode($this->snapshotName()), 72 | json_encode($this->snapshotOptions()) 73 | ) 74 | ]; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/VisualTestingServiceProvider.php: -------------------------------------------------------------------------------- 1 | publishes([ 20 | __DIR__ . '/../config/visual-testing.php' => config_path('visual-testing.php'), 21 | ]); 22 | 23 | // Extend and override the base dusk commands 24 | if ($this->app->runningInConsole()) { 25 | $this->commands([ 26 | DuskCommand::class, 27 | DuskFailsCommand::class 28 | ]); 29 | } 30 | } 31 | 32 | /** 33 | * Register any package services. 34 | * 35 | * @return void 36 | */ 37 | public function register() 38 | { 39 | $this->mergeConfigFrom( 40 | __DIR__ . '/../config/visual-testing.php', 'visual-testing' 41 | ); 42 | 43 | $this->app->singleton(Agent::class, function () { 44 | return new Agent( 45 | config('visual-testing.percy.agent_path'), 46 | $this->clientInfo(), 47 | config('visual-testing.percy.environment_info'), 48 | config('visual-testing.percy.snapshot_options') 49 | ); 50 | }); 51 | 52 | Browser::macro('snapshot', function ($name = null, $options = []) { 53 | return app(Agent::class)->snapshot($this, $name, $options); 54 | }); 55 | } 56 | 57 | /** 58 | * @return array 59 | */ 60 | public function provides() 61 | { 62 | return [Agent::class]; 63 | } 64 | 65 | /** 66 | * @return string 67 | */ 68 | protected function clientInfo() 69 | { 70 | return "stechstudio/laravel-visual-testing/" . $this->packageVersion(); 71 | } 72 | 73 | /** 74 | * @return string 75 | */ 76 | protected function packageVersion() 77 | { 78 | if(!file_exists(base_path("composer.lock"))) { 79 | return ''; 80 | } 81 | 82 | $composer = json_decode(file_get_contents(base_path("composer.lock")), true); 83 | 84 | return collect($composer['packages']) 85 | ->merge($composer['packages-dev']) 86 | ->where("name", "stechstudio/laravel-visual-testing") 87 | ->pluck("version") 88 | ->first(); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /tests/BuildsScriptTest.php: -------------------------------------------------------------------------------- 1 | [ 12 | 'agent_path' => sys_get_temp_dir() . '/percy-agent.js', 13 | 'snapshot_options' => [ 14 | 'widths' => [500, 600] 15 | ] 16 | ] 17 | ]); 18 | 19 | $script = $this->agent()->getScript($this->browser()); 20 | 21 | $this->assertEquals("this is percy", $script->jsAgent()); 22 | $this->assertEquals([500, 600], $script->snapshotOptions()['widths']); 23 | } 24 | 25 | public function test_runtime_config() 26 | { 27 | config(['visual-testing.percy.snapshot_options' => [ 'widths' => [100, 200] ] ]); 28 | 29 | $script = $this->agent()->getScript($this->browser(), 'snapshot-name', [ 'widths' => [600, 700] ]); 30 | 31 | $this->assertEquals('snapshot-name', $script->snapshotName()); 32 | $this->assertEquals([600, 700], $script->snapshotOptions()['widths']); 33 | } 34 | 35 | public function test_generated_snapshot_name() 36 | { 37 | $script = $this->agent()->getScript($this->browser()); 38 | $this->assertEquals('/test', $script->snapshotName()); 39 | 40 | $script = $this->agent()->getScript($this->browser()); 41 | $this->assertEquals('/test (1)', $script->snapshotName()); 42 | 43 | $script = $this->agent()->getScript($this->browser()); 44 | $this->assertEquals('/test (2)', $script->snapshotName()); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | sys_get_temp_dir() . '/percy-agent.js']); 31 | } 32 | 33 | /** 34 | * @return Agent 35 | */ 36 | protected function agent() 37 | { 38 | return resolve(Agent::class); 39 | } 40 | 41 | /** 42 | * @return Browser|\Mockery\MockInterface 43 | */ 44 | protected function browser() 45 | { 46 | $browser = \Mockery::mock(Browser::class); 47 | $browser->driver = \Mockery::mock(RemoteWebDriver::class); 48 | $browser->driver->shouldReceive('getCurrentURL')->andReturn($browser::$baseUrl . '/test'); 49 | 50 | return $browser; 51 | } 52 | } --------------------------------------------------------------------------------