From 8476d78254991ca37d2129aefc2b2eab9fb2406d Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Thu, 15 Oct 2026 09:53:14 -0400 Subject: [PATCH] Add extensible screenshot driver manager --- README.md | 55 ++++++++- config/screenshot.php | 12 ++ src/Contracts/Factory.php | 16 +++ src/Facades/Screenshot.php | 19 +-- src/ScreenshotManager.php | 106 +++++++++++++++++ src/ScreenshotServiceProvider.php | 50 +------- src/Testing/ScreenshotManagerFake.php | 48 ++++++++ tests/Browser/ManagerTest.php | 24 ++++ tests/Feature/FactoryTest.php | 53 +++++++++ tests/Feature/InstallCommandTest.php | 5 +- tests/Feature/ScreenshotManagerTest.php | 140 +++++++++++++++++++++++ tests/Feature/ScreenshotTest.php | 13 ++- tests/Unit/ScreenshotManagerFakeTest.php | 31 +++++ 13 files changed, 507 insertions(+), 65 deletions(-) create mode 100644 src/Contracts/Factory.php create mode 100644 src/ScreenshotManager.php create mode 100644 src/Testing/ScreenshotManagerFake.php create mode 100644 tests/Browser/ManagerTest.php create mode 100644 tests/Feature/FactoryTest.php create mode 100644 tests/Feature/ScreenshotManagerTest.php create mode 100644 tests/Unit/ScreenshotManagerFakeTest.php diff --git a/README.md b/README.md index 2570e37..085a870 100644 --- a/README.md +++ b/README.md @@ -362,7 +362,56 @@ Pass `BrowserOptions`, `ImageOptions`, or `PdfOptions` objects through the `brow ### Custom Drivers -Chrome is the built-in driver. To supply your own, implement [`Driver`](src/Contracts/Driver.php) and bind it in your service provider's `register()` method, or pass it to the standalone `Screenshot` constructor. Drivers supporting `close()` should also implement [`ManagesBrowsers`](src/Contracts/ManagesBrowsers.php). +The facade uses `ScreenshotManager` to resolve named drivers. Chrome is included as `chrome` and is the default. Calls such as `Screenshot::html()` are forwarded to the configured default driver: + +```php +Screenshot::html('

Hello!

')->capture(); +Screenshot::driver('chrome')->html('

Hello!

')->capture(); +``` + +To register another driver, implement [`Driver`](src/Contracts/Driver.php) and call `extend()` in your service provider's `boot()` method: + +```php +use App\Screenshots\RemoteDriver; +use DirectoryTree\Screenshot\Facades\Screenshot; +use Illuminate\Contracts\Foundation\Application; + +public function boot(): void +{ + Screenshot::extend('remote', function (Application $app) { + return $app->make(RemoteDriver::class); + }); +} +``` + +Select it for an individual capture: + +```php +Screenshot::driver('remote')->url('https://example.com')->pdf()->capture(); +``` + +Set `SCREENSHOT_DRIVER=remote` or `screenshot.default` to use it by default. Drivers are created on first use and cached by name. Every driver uses the same fluent builders and configured capture, image, and PDF defaults; custom drivers only implement the capture operation. + +Inject `DirectoryTree\Screenshot\Contracts\Factory` when using the container directly. Both the manager and its fake implement this contract: + +```php +use DirectoryTree\Screenshot\Contracts\Factory; +use DirectoryTree\Screenshot\ImageResult; + +class InvoiceScreenshot +{ + public function __construct( + protected Factory $screenshots, + ) {} + + public function capture(string $html): ImageResult + { + return $this->screenshots->driver()->html($html)->capture(); + } +} +``` + +Outside Laravel, pass a driver to the standalone `Screenshot` constructor. Drivers supporting `close()` should also implement [`ManagesBrowsers`](src/Contracts/ManagesBrowsers.php). Drivers receive `SourceType::Html` or `SourceType::Url` and handle every output through `capture(string $source, SourceType $type, CaptureOptions $options)`. Blade views are rendered to HTML first. `CaptureOptions` contains: @@ -393,6 +442,10 @@ Screenshot::assertCaptured(function (string $source, SourceType $type, CaptureOp }); ``` +`Screenshot::fake()` intercepts both the default driver and explicit `driver()` selections without creating real drivers. Assertions collect captures and shutdown requests across all driver names. + +`Screenshot::fake()` also replaces the `Contracts\Factory` container binding. Services resolved after faking receive the same recorder as the facade. Use this contract for constructor injection; the fake does not extend the concrete `ScreenshotManager` class. + `assertCaptured()` and `assertCapturedCount()` include PNG, JPEG, and PDF captures. Use `assertNothingCaptured()` when no captures should occur. Assert browser shutdown requests with `assertClosed($key)`, `assertNotClosed($key)`, and `assertNothingClosed()`. Check the output format in the callback when it matters: diff --git a/config/screenshot.php b/config/screenshot.php index 233e2be..9360982 100644 --- a/config/screenshot.php +++ b/config/screenshot.php @@ -2,6 +2,18 @@ return [ + /* + |-------------------------------------------------------------------------- + | Default Driver + |-------------------------------------------------------------------------- + | + | Captures use this driver unless one is selected explicitly. Chrome is + | included by default. Register custom drivers using Screenshot::extend(). + | + */ + + 'default' => env('SCREENSHOT_DRIVER', 'chrome'), + /* |-------------------------------------------------------------------------- | Chrome / Chromium diff --git a/src/Contracts/Factory.php b/src/Contracts/Factory.php new file mode 100644 index 0000000..1c2e29d --- /dev/null +++ b/src/Contracts/Factory.php @@ -0,0 +1,16 @@ +fake($result)); return $fake; } @@ -43,6 +44,6 @@ public static function fake(ImageResult|PdfResult|null $result = null): Screensh */ protected static function getFacadeAccessor(): string { - return Factory::class; + return ScreenshotManager::class; } } diff --git a/src/ScreenshotManager.php b/src/ScreenshotManager.php new file mode 100644 index 0000000..8414346 --- /dev/null +++ b/src/ScreenshotManager.php @@ -0,0 +1,106 @@ +createScreenshot(new ScreenshotFake), + $result, + )); + } + + /** + * Get the default driver name. + */ + public function getDefaultDriver(): string + { + return $this->config->get('screenshot.default'); + } + + /** + * Get a screenshot factory for the given driver. + */ + public function driver($driver = null): Screenshot + { + return parent::driver($driver); + } + + /** + * Wrap the resolved capture driver in a configured screenshot factory. + */ + protected function createDriver($driver): Screenshot + { + return $this->createScreenshot(parent::createDriver($driver)); + } + + /** + * Create the Chrome capture driver. + */ + protected function createChromeDriver(): ChromeDriver + { + return new ChromeDriver( + $this->config->get('screenshot.browser.binary') ?? $this->container->make(ChromeInstaller::class)->path(), + $this->config->get('screenshot.browser.options', []), + $this->config->get('screenshot.sockets.directory'), + $this->config->get('screenshot.sockets.lock_timeout', 60000), + ); + } + + /** + * Create a screenshot factory with the application's capture defaults. + */ + protected function createScreenshot(Driver $driver): Screenshot + { + $capture = $this->config->get('screenshot.capture'); + $image = $this->config->get('screenshot.image'); + $pdf = $this->config->get('screenshot.pdf'); + + [$width, $height] = PaperSize::from($pdf['paper'])->dimensions(); + + return new Screenshot( + driver: $driver, + browser: (new BrowserOptions) + ->setSize($capture['width'], $capture['height']) + ->setTimeouts($capture['load_timeout'], $capture['browser_timeout']) + ->setJavascript($capture['javascript']) + ->setFailOnTimeout($capture['fail_on_timeout']) + ->setSocket($capture['socket']), + image: (new ImageOptions) + ->setFormat(OutputFormat::from($image['format']), $image['quality']) + ->setMaxSize($image['max_width'], $image['max_height']) + ->setFullPage($image['full_page']), + pdf: (new PdfOptions) + ->setLayout( + width: $width, + height: $height, + landscape: $pdf['landscape'], + top: $pdf['margins']['top'], + right: $pdf['margins']['right'], + bottom: $pdf['margins']['bottom'], + left: $pdf['margins']['left'], + ) + ->setBackground($pdf['background']) + ->setPreferCssPageSize($pdf['prefer_css_page_size']), + views: $this->container->make('view'), + ); + } +} diff --git a/src/ScreenshotServiceProvider.php b/src/ScreenshotServiceProvider.php index cef0137..464fa06 100644 --- a/src/ScreenshotServiceProvider.php +++ b/src/ScreenshotServiceProvider.php @@ -3,10 +3,7 @@ namespace DirectoryTree\Screenshot; use DirectoryTree\Screenshot\Commands\InstallCommand; -use DirectoryTree\Screenshot\Contracts\Driver; -use DirectoryTree\Screenshot\Drivers\ChromeDriver; -use DirectoryTree\Screenshot\Enums\OutputFormat; -use DirectoryTree\Screenshot\Enums\PaperSize; +use DirectoryTree\Screenshot\Contracts\Factory; use DirectoryTree\Screenshot\Support\ChromeInstaller; use Illuminate\Contracts\Foundation\Application; use Illuminate\Support\ServiceProvider; @@ -24,49 +21,8 @@ public function register(): void $app['config']->get('screenshot.browser.directory'), )); - $this->app->singleton(Driver::class, function (Application $app): Driver { - return new ChromeDriver( - $app['config']->get('screenshot.browser.binary') ?? $app->make(ChromeInstaller::class)->path(), - $app['config']->get('screenshot.browser.options', []), - $app['config']->get('screenshot.sockets.directory'), - $app['config']->get('screenshot.sockets.lock_timeout', 60000), - ); - }); - - $this->app->singleton(Screenshot::class, function (Application $app): Screenshot { - $capture = $app['config']->get('screenshot.capture'); - $image = $app['config']->get('screenshot.image'); - $pdf = $app['config']->get('screenshot.pdf'); - - [$width, $height] = PaperSize::from($pdf['paper'])->dimensions(); - - return new Screenshot( - driver: $app->make(Driver::class), - browser: (new BrowserOptions) - ->setSize($capture['width'], $capture['height']) - ->setTimeouts($capture['load_timeout'], $capture['browser_timeout']) - ->setJavascript($capture['javascript']) - ->setFailOnTimeout($capture['fail_on_timeout']) - ->setSocket($capture['socket']), - image: (new ImageOptions) - ->setFormat(OutputFormat::from($image['format']), $image['quality']) - ->setMaxSize($image['max_width'], $image['max_height']) - ->setFullPage($image['full_page']), - pdf: (new PdfOptions) - ->setLayout( - width: $width, - height: $height, - landscape: $pdf['landscape'], - top: $pdf['margins']['top'], - right: $pdf['margins']['right'], - bottom: $pdf['margins']['bottom'], - left: $pdf['margins']['left'], - ) - ->setBackground($pdf['background']) - ->setPreferCssPageSize($pdf['prefer_css_page_size']), - views: $app['view'], - ); - }); + $this->app->singleton(ScreenshotManager::class); + $this->app->alias(ScreenshotManager::class, Factory::class); } /** diff --git a/src/Testing/ScreenshotManagerFake.php b/src/Testing/ScreenshotManagerFake.php new file mode 100644 index 0000000..009eaa4 --- /dev/null +++ b/src/Testing/ScreenshotManagerFake.php @@ -0,0 +1,48 @@ +fake; + } + + /** + * Create a fresh recording manager using the same capture defaults. + */ + public function fake(ImageResult|PdfResult|null $result = null): static + { + return new static(ScreenshotFake::from($this->fake, $result)); + } + + /** + * Forward capture methods and assertions to the shared recorder. + */ + public function __call(string $method, array $parameters): mixed + { + return $this->forwardCallTo($this->fake, $method, $parameters); + } +} diff --git a/tests/Browser/ManagerTest.php b/tests/Browser/ManagerTest.php new file mode 100644 index 0000000..cf5a426 --- /dev/null +++ b/tests/Browser/ManagerTest.php @@ -0,0 +1,24 @@ +group('browser'); + +test('the manager captures images and PDFs through the configured Chrome driver', function () { + config([ + 'screenshot.browser.binary' => getenv('CHROME_PATH') ?: null, + 'screenshot.browser.options' => ['noSandbox' => getenv('SCREENSHOT_NO_SANDBOX') === '1'], + 'screenshot.capture.width' => 640, + 'screenshot.capture.height' => 480, + 'screenshot.image.full_page' => false, + ]); + + $image = Screenshot::html('

Default driver

')->capture(); + $pdf = Screenshot::driver('chrome')->view('card', ['name' => 'Steve'])->pdf()->capture(); + + expect($image->width())->toBe(640); + expect($image->height())->toBe(480); + expect($image->mimeType())->toBe('image/png'); + expect($pdf->contents())->toStartWith('%PDF-'); +}); diff --git a/tests/Feature/FactoryTest.php b/tests/Feature/FactoryTest.php new file mode 100644 index 0000000..ac2d868 --- /dev/null +++ b/tests/Feature/FactoryTest.php @@ -0,0 +1,53 @@ + 'remote']); + $driver = new ScreenshotFake; + Screenshot::extend('remote', fn () => $driver); + + expect(app(Factory::class))->toBe(app(ScreenshotManager::class)); + + app(ScreenshotFactoryConsumer::class)->capture(); + + $driver->assertCaptured(fn ($source) => $source === 'invoice'); + $driver->assertCapturedCount(1); +}); + +test('faking replaces a previously resolved factory contract for constructor injection', function ($driver) { + config(['screenshot.default' => 'remote']); + Screenshot::extend('remote', fn () => throw new RuntimeException('Real driver resolved.')); + $original = app(Factory::class); + $result = new ImageResult(imageFixture()); + $fake = Screenshot::fake($result); + + expect(app(Factory::class))->toBe($fake)->not->toBe($original); + expect(app(ScreenshotFactoryConsumer::class)->capture($driver))->toBe($result); + Screenshot::assertCapturedCount(1); + + $nextResult = new ImageResult(imageFixture()); + $nextFake = Screenshot::fake($nextResult); + + expect(app(Factory::class))->toBe($nextFake)->not->toBe($fake); + Screenshot::assertNothingCaptured(); + expect(app(ScreenshotFactoryConsumer::class)->capture($driver))->toBe($nextResult); + Screenshot::assertCapturedCount(1); + $fake->assertCapturedCount(1); +})->with(['default driver' => null, 'named driver' => 'remote']); + +class ScreenshotFactoryConsumer +{ + public function __construct( + protected Factory $screenshots, + ) {} + + public function capture(?string $driver = null): ImageResult + { + return $this->screenshots->driver($driver)->html('invoice')->capture(); + } +} diff --git a/tests/Feature/InstallCommandTest.php b/tests/Feature/InstallCommandTest.php index 44fcbe8..2036ea4 100644 --- a/tests/Feature/InstallCommandTest.php +++ b/tests/Feature/InstallCommandTest.php @@ -1,6 +1,6 @@ andReturn('/installed/chrome'); app()->instance(ChromeInstaller::class, $installer); - $driver = app()->make(Driver::class); + $factory = Screenshot::driver('chrome'); + $driver = (new ReflectionProperty($factory, 'driver'))->getValue($factory); expect((new ReflectionProperty($driver, 'binary'))->getValue($driver))->toBe($expected); })->with([ diff --git a/tests/Feature/ScreenshotManagerTest.php b/tests/Feature/ScreenshotManagerTest.php new file mode 100644 index 0000000..88fbd2a --- /dev/null +++ b/tests/Feature/ScreenshotManagerTest.php @@ -0,0 +1,140 @@ +shouldReceive('path')->once()->andReturnNull(); + app()->instance(ChromeInstaller::class, $installer); + + $manager = app(ScreenshotManager::class); + + expect($manager->getDrivers())->toBe([]); + expect($manager->getDefaultDriver())->toBe('chrome'); + expect($manager->driver())->toBe($manager->driver('chrome')); + expect($manager->getDrivers())->toHaveKey('chrome'); +}); + +test('custom drivers receive the container and are cached until forgotten', function () { + $created = 0; + $container = app(); + + Screenshot::extend('remote', function ($app) use ($container, &$created) { + expect($app)->toBe($container); + $created++; + + return new ScreenshotFake; + }); + + expect($created)->toBe(0); + + $factory = Screenshot::driver('remote'); + + expect(Screenshot::driver('remote'))->toBe($factory); + expect($created)->toBe(1); + + app(ScreenshotManager::class)->forgetDrivers(); + + expect(Screenshot::driver('remote'))->not->toBe($factory); + expect($created)->toBe(2); +}); + +test('default and explicitly selected drivers share the fluent API with independent captures', function () { + config(['screenshot.default' => 'remote', 'screenshot.capture.width' => 900]); + $installer = Mockery::mock(ChromeInstaller::class); + $installer->shouldNotReceive('path'); + app()->instance(ChromeInstaller::class, $installer); + $remote = new ScreenshotFake; + $other = new ScreenshotFake; + + Screenshot::extend('remote', fn () => $remote); + Screenshot::extend('other', fn () => $other); + + Screenshot::html('first')->size(400, 300)->jpeg()->capture(); + Screenshot::driver('other')->url('https://example.com/invoice')->element('#invoice')->pdf()->capture(); + Screenshot::view('card', ['name' => ''])->capture(); + + $remote->assertCapturedCount(2); + $remote->assertCaptured(fn ($source, $type, $options) => $source === 'first' + && $options->format() === OutputFormat::Jpeg + && $options->browser()->getWidth() === 400); + $remote->assertCaptured(fn ($source, $type, $options) => str_contains($source, '<Steve>') + && $type === SourceType::Html + && $options->format() === OutputFormat::Png + && $options->browser()->getWidth() === 900); + $other->assertCapturedCount(1); + $other->assertCaptured(fn ($source, $type, $options) => $source === 'https://example.com/invoice' + && $type === SourceType::Url + && $options->format() === OutputFormat::Pdf + && $options->element() === '#invoice' + && $options->browser()->getWidth() === 900); +}); + +test('custom creators may replace the built-in Chrome driver', function () { + $driver = new ScreenshotFake; + Screenshot::extend('chrome', fn () => $driver); + + Screenshot::html('custom chrome')->capture(); + + $driver->assertCapturedCount(1); +}); + +test('unsupported driver names fail on resolution', function () { + expect(fn () => Screenshot::driver('missing')) + ->toThrow(InvalidArgumentException::class, 'Driver [missing] not supported.'); +}); + +test('browser shutdowns are forwarded to the selected driver', function () { + $driver = Mockery::mock(Driver::class, ManagesBrowsers::class); + $driver->shouldReceive('close')->once()->with('invoices'); + Screenshot::extend('remote', fn () => $driver); + + Screenshot::driver('remote')->close('invoices'); +}); + +test('the facade recognizes its recording fake', function () { + expect(Screenshot::isFake())->toBeFalse(); + + Screenshot::fake(); + + expect(Screenshot::isFake())->toBeTrue(); + + Screenshot::fake(); + + expect(Screenshot::isFake())->toBeTrue(); +}); + +test('the facade fake records captures and shutdowns across every named driver', function () { + config(['screenshot.default' => 'remote', 'screenshot.capture.width' => 900]); + Screenshot::extend('remote', fn () => throw new RuntimeException('Real driver resolved.')); + Screenshot::driver('chrome'); + $result = new ImageResult(imageFixture()); + $fake = Screenshot::fake($result); + + expect(app(ScreenshotManager::class))->toBe($fake); + expect(Screenshot::html('default')->capture())->toBe($result); + expect(Screenshot::driver('chrome')->html('chrome')->capture())->toBe($result); + app(ScreenshotManager::class)->driver('other')->view('card', ['name' => 'Steve'])->pdf()->capture(); + Screenshot::driver('remote')->close('invoices'); + + $fake->assertCapturedCount(3); + Screenshot::assertCaptured(fn ($source, $type, $options) => $options->format() === OutputFormat::Pdf + && $options->browser()->getWidth() === 900); + Screenshot::assertClosed('invoices'); + + Screenshot::fake(); + + Screenshot::assertNothingCaptured(); + Screenshot::assertNothingClosed(); + Screenshot::driver('chrome')->html('fresh')->capture(); + Screenshot::assertCapturedCount(1); + $fake->assertCapturedCount(3); +}); diff --git a/tests/Feature/ScreenshotTest.php b/tests/Feature/ScreenshotTest.php index de7e20b..c9a2bfc 100644 --- a/tests/Feature/ScreenshotTest.php +++ b/tests/Feature/ScreenshotTest.php @@ -9,7 +9,7 @@ use DirectoryTree\Screenshot\ImageResult; use DirectoryTree\Screenshot\PdfResult; use DirectoryTree\Screenshot\PendingSource; -use DirectoryTree\Screenshot\Screenshot as Factory; +use DirectoryTree\Screenshot\ScreenshotManager; use DirectoryTree\Screenshot\ScreenshotServiceProvider; use DirectoryTree\Screenshot\Support\BrowserSocket; use Illuminate\Filesystem\Filesystem; @@ -24,20 +24,21 @@ ->withArgs(fn ($source, $type, $options) => $source === 'hello' && $options->browser()->getWidth() === 900) ->andReturn(new ImageResult(imageFixture())); - app()->instance(Driver::class, $driver); + Screenshot::extend('custom', fn () => $driver); + config(['screenshot.default' => 'custom']); config(['screenshot.capture.width' => 900, 'screenshot.capture.height' => 600]); expect(Screenshot::html('hello')->capture()->width())->toBe(1); }); -test('the facade fake replaces the container factory and renders escaped Blade views', function () { +test('the facade fake replaces the container manager and renders escaped Blade views', function () { config(['screenshot.image.quality' => 85]); Screenshot::fake(); Screenshot::assertNothingCaptured(); - app()->make(Factory::class) + app()->make(ScreenshotManager::class) ->view('card', ['name' => '']) ->jpeg() ->capture(); @@ -400,10 +401,10 @@ ); }); -test('invalid configured defaults fail when the factory is resolved', function ($key, $value) { +test('invalid configured defaults fail when a driver is resolved', function ($key, $value) { config([$key => $value]); - expect(fn () => app(Factory::class))->toThrow(InvalidArgumentException::class); + expect(fn () => app(ScreenshotManager::class)->driver())->toThrow(InvalidArgumentException::class); })->with([ 'browser dimensions' => ['screenshot.capture.width', 0], 'browser timeout' => ['screenshot.capture.browser_timeout', 0], diff --git a/tests/Unit/ScreenshotManagerFakeTest.php b/tests/Unit/ScreenshotManagerFakeTest.php new file mode 100644 index 0000000..f4dcc8e --- /dev/null +++ b/tests/Unit/ScreenshotManagerFakeTest.php @@ -0,0 +1,31 @@ +setSize(900, 600), + )); + + $fake->html('default')->capture(); + $fake->driver('remote')->html('remote')->size(320, 240)->capture(); + $fake->close(key: 'invoices'); + + $fake->assertCapturedCount(count: 2); + $fake->assertClosed(key: 'invoices'); + + $result = new ImageResult(imageFixture()); + $fresh = $fake->fake($result); + + $fresh->assertNothingCaptured(); + $fresh->assertNothingClosed(); + expect($fresh->driver('chrome')->html('fresh')->capture())->toBe($result); + $fresh->assertCaptured(fn ($source, $type, $options) => $source === 'fresh' + && $options->browser()->getWidth() === 900 + && $options->browser()->getHeight() === 600); + $fresh->assertCapturedCount(1); + $fake->assertCapturedCount(2); +});