From d2269957e2206885fe55cadee2f4e47b4c8f10cc Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Fri, 16 Oct 2026 12:20:59 -0400 Subject: [PATCH 1/5] Add before capture callbacks with driver context --- README.md | 34 ++++++++++++ src/CaptureContext.php | 39 ++++++++++++++ src/CaptureOptions.php | 22 ++++++++ src/Drivers/Chrome/PageCapture.php | 7 +++ src/Drivers/ChromeDriver.php | 2 +- src/Drivers/Gotenberg/Request.php | 32 +++++++++++ src/Drivers/GotenbergDriver.php | 8 ++- src/PendingImage.php | 11 ++++ src/PendingPdf.php | 11 ++++ src/PendingSource.php | 21 +++++++- tests/Browser/CaptureTest.php | 16 ++++++ tests/Unit/BeforeCaptureTest.php | 85 ++++++++++++++++++++++++++++++ tests/Unit/PageCaptureTest.php | 29 ++++++++++ 13 files changed, 314 insertions(+), 3 deletions(-) create mode 100644 src/CaptureContext.php create mode 100644 src/Drivers/Gotenberg/Request.php create mode 100644 tests/Unit/BeforeCaptureTest.php diff --git a/README.md b/README.md index a2b16d3..b591250 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,40 @@ PDFs move the selected element directly into the document body and print it usin Selection works with JavaScript disabled. Missing elements and invalid selectors throw exceptions. Images require an element with nonzero layout bounds; PDFs follow the document's print styles. Each output builder keeps the selector chosen when it was created. +### Customizing a Capture + +Use `before()` on a source, image, or PDF builder to customize the prepared driver resource. Each callback receives a `CaptureContext` containing the actual driver instance: + +```php +use DirectoryTree\Screenshot\CaptureContext; +use DirectoryTree\Screenshot\Drivers\ChromeDriver; +use DirectoryTree\Screenshot\Drivers\GotenbergDriver; +use DirectoryTree\Screenshot\Facades\Screenshot; + +Screenshot::url('https://example.com') + ->javascript() + ->before(function (CaptureContext $context) { + match (true) { + $context->driver instanceof ChromeDriver => $context->page() + ->evaluate('document.querySelector("#banner")?.remove()') + ->getReturnValue(), + $context->driver instanceof GotenbergDriver => $context->request() + ->set('waitForExpression', 'window.ready === true'), + default => null, + }; + }) + ->png() + ->capture(); +``` + +Chrome invokes callbacks after the source loads and before image bounds are measured or PDF preparation begins. `page()` returns the live `HeadlessChromium\Page`, allowing JavaScript execution, application-specific readiness waits, and viewport changes. Enable `javascript()` when your callback needs to execute JavaScript. + +Gotenberg invokes callbacks immediately before sending its HTTP request. `request()` returns a mutable `DirectoryTree\Screenshot\Drivers\Gotenberg\Request`. Use `set()` to add or replace a multipart field. Its public `url` and `options` properties expose the destination and Guzzle request options, including headers and multipart files. This does not provide access to Gotenberg's remote browser page. + +Calling `page()` or `request()` when that resource is unavailable throws a `LogicException`. Callbacks run in registration order, their return values are ignored, and exceptions propagate through the driver's existing cleanup. A callback added to a source is copied into subsequently created output builders; callbacks added to an output affect only that builder. Callbacks run for every actual capture, including captures triggered by `save()` and `store()`. + +The screenshot fake records capture options without executing callbacks. Custom drivers can invoke registered callbacks through `$options->prepare($context)` when they provide a supported resource. + ### Saving and Reading Captures Call `save()` or `store()` on a source or output builder, or capture once and reuse the result: diff --git a/src/CaptureContext.php b/src/CaptureContext.php new file mode 100644 index 0000000..677e363 --- /dev/null +++ b/src/CaptureContext.php @@ -0,0 +1,39 @@ +resource instanceof Page + ? $this->resource + : throw new LogicException('The current capture does not provide a Chrome page.'); + } + + /** + * Get the prepared Gotenberg request. + */ + public function request(): Request + { + return $this->resource instanceof Request + ? $this->resource + : throw new LogicException('The current capture does not provide a Gotenberg request.'); + } +} diff --git a/src/CaptureOptions.php b/src/CaptureOptions.php index 56bcfcb..c7268ea 100644 --- a/src/CaptureOptions.php +++ b/src/CaptureOptions.php @@ -2,6 +2,7 @@ namespace DirectoryTree\Screenshot; +use Closure; use DirectoryTree\Screenshot\Enums\OutputFormat; /** @@ -18,6 +19,7 @@ public function __construct( protected BrowserOptions $browser, protected ImageOptions|PdfOptions $output, protected ?string $element = null, + protected array $callbacks = [], ) {} /** @@ -63,6 +65,26 @@ public function format(): OutputFormat return $this->output->getFormat(); } + /** + * Register a callback to customize the driver before capturing. + */ + public function before(Closure $callback): static + { + $this->callbacks[] = $callback; + + return $this; + } + + /** + * Invoke the callbacks with the prepared driver context. + */ + public function prepare(CaptureContext $context): void + { + foreach ($this->callbacks as $callback) { + $callback($context); + } + } + /** * Validate the browser and output settings before capturing. */ diff --git a/src/Drivers/Chrome/PageCapture.php b/src/Drivers/Chrome/PageCapture.php index 6988a38..6c354de 100644 --- a/src/Drivers/Chrome/PageCapture.php +++ b/src/Drivers/Chrome/PageCapture.php @@ -2,7 +2,9 @@ namespace DirectoryTree\Screenshot\Drivers\Chrome; +use DirectoryTree\Screenshot\CaptureContext; use DirectoryTree\Screenshot\CaptureOptions; +use DirectoryTree\Screenshot\Contracts\Driver; use DirectoryTree\Screenshot\Enums\OutputFormat; use DirectoryTree\Screenshot\Enums\SourceType; use DirectoryTree\Screenshot\ImageResult; @@ -25,6 +27,7 @@ public function __construct( protected Browser $browser, protected CaptureOptions $options, protected bool $shared = false, + protected ?Driver $driver = null, ) {} /** @@ -43,6 +46,10 @@ public function capture(string $source, SourceType $type): ImageResult|PdfResult $this->configurePage($page); $this->loadPage($page, $source, $type); + if ($this->driver !== null) { + $this->options->prepare(new CaptureContext($this->driver, $page)); + } + $result = $this->newCapture()->capture($page); $failed = false; diff --git a/src/Drivers/ChromeDriver.php b/src/Drivers/ChromeDriver.php index 6a61894..7e91296 100644 --- a/src/Drivers/ChromeDriver.php +++ b/src/Drivers/ChromeDriver.php @@ -57,7 +57,7 @@ protected function capturePage(Browser $browser, string $source, SourceType $typ $browser->getConnection()->setConnectionHttpHeaders($this->browserOptions['headers']); } - $result = (new PageCapture($browser, $options, $shared))->capture($source, $type); + $result = (new PageCapture($browser, $options, $shared, $this))->capture($source, $type); $failed = false; diff --git a/src/Drivers/Gotenberg/Request.php b/src/Drivers/Gotenberg/Request.php new file mode 100644 index 0000000..78b579f --- /dev/null +++ b/src/Drivers/Gotenberg/Request.php @@ -0,0 +1,32 @@ +options['multipart'] = array_values(array_filter( + $this->options['multipart'], + fn (array $part) => $part['name'] !== $name, + )); + + $this->options['multipart'][] = [ + 'name' => $name, + 'contents' => is_bool($value) ? ($value ? 'true' : 'false') : (string) $value, + ]; + + return $this; + } +} diff --git a/src/Drivers/GotenbergDriver.php b/src/Drivers/GotenbergDriver.php index 2b4c8df..7b05645 100644 --- a/src/Drivers/GotenbergDriver.php +++ b/src/Drivers/GotenbergDriver.php @@ -2,11 +2,13 @@ namespace DirectoryTree\Screenshot\Drivers; +use DirectoryTree\Screenshot\CaptureContext; use DirectoryTree\Screenshot\CaptureOptions; use DirectoryTree\Screenshot\Contracts\Driver; use DirectoryTree\Screenshot\Drivers\Gotenberg\ImageOutput; use DirectoryTree\Screenshot\Drivers\Gotenberg\Output; use DirectoryTree\Screenshot\Drivers\Gotenberg\PdfOutput; +use DirectoryTree\Screenshot\Drivers\Gotenberg\Request; use DirectoryTree\Screenshot\Enums\OutputFormat; use DirectoryTree\Screenshot\Enums\SourceType; use DirectoryTree\Screenshot\ImageResult; @@ -62,13 +64,17 @@ protected function capturePage(string $source, SourceType $type, CaptureOptions { $route = $output->route(); - $response = $this->http->request('POST', rtrim($this->url, '/')."/forms/chromium/{$route}/{$type->value}", [ + $request = new Request(rtrim($this->url, '/')."/forms/chromium/{$route}/{$type->value}", [ 'multipart' => $this->getMultipart($source, $type, $output->fields()), 'timeout' => $options->browser()->getBrowserTimeout() / 1000, 'http_errors' => false, 'allow_redirects' => false, ]); + $options->prepare(new CaptureContext($this, $request)); + + $response = $this->http->request('POST', $request->url, $request->options); + if ($response->getStatusCode() !== 200) { throw new RuntimeException(sprintf( 'Gotenberg capture failed (HTTP %d, trace: %s): %s', diff --git a/src/PendingImage.php b/src/PendingImage.php index 79003eb..6323a93 100644 --- a/src/PendingImage.php +++ b/src/PendingImage.php @@ -2,10 +2,21 @@ namespace DirectoryTree\Screenshot; +use Closure; use DirectoryTree\Screenshot\Enums\OutputFormat; class PendingImage { + /** + * Customize the driver immediately before capturing. + */ + public function before(Closure $callback): static + { + $this->options->before($callback); + + return $this; + } + /** * Create an image builder with independent settings. * diff --git a/src/PendingPdf.php b/src/PendingPdf.php index 4ae60de..0cd61f0 100644 --- a/src/PendingPdf.php +++ b/src/PendingPdf.php @@ -2,10 +2,21 @@ namespace DirectoryTree\Screenshot; +use Closure; use DirectoryTree\Screenshot\Enums\PaperSize; class PendingPdf { + /** + * Customize the driver immediately before capturing. + */ + public function before(Closure $callback): static + { + $this->options->before($callback); + + return $this; + } + /** * Create a PDF builder with independent print settings. * diff --git a/src/PendingSource.php b/src/PendingSource.php index 3a8d350..09a699f 100644 --- a/src/PendingSource.php +++ b/src/PendingSource.php @@ -2,13 +2,32 @@ namespace DirectoryTree\Screenshot; +use Closure; + class PendingSource { + /** + * Customize the driver immediately before capturing. + */ + public function before(Closure $callback): static + { + $this->callbacks[] = $callback; + + return $this; + } + /** * The CSS selector to capture from the source. */ protected ?string $element = null; + /** + * The callbacks invoked before capturing. + * + * @var array + */ + protected array $callbacks = []; + /** * Prepare a source with independent browser settings and shared output defaults. */ @@ -161,6 +180,6 @@ public function capture(): ImageResult */ protected function newCaptureOptions(ImageOptions|PdfOptions $output): CaptureOptions { - return new CaptureOptions($this->browser, $output, $this->element); + return new CaptureOptions($this->browser, $output, $this->element, $this->callbacks); } } diff --git a/tests/Browser/CaptureTest.php b/tests/Browser/CaptureTest.php index 0bbabe3..d0893b1 100644 --- a/tests/Browser/CaptureTest.php +++ b/tests/Browser/CaptureTest.php @@ -1,6 +1,7 @@ stop(); } }); + +test('before callbacks customize a loaded Chrome page before element capture', function () { + $image = realScreenshot()->html('
') + ->javascript() + ->before(function (CaptureContext $context) { + expect($context->driver)->toBeInstanceOf(ChromeDriver::class); + $context->page()->evaluate('document.querySelector("#card").style.width = "350px"; document.querySelector("#card").style.height = "250px";')->getReturnValue(); + }) + ->element('#card') + ->png() + ->capture(); + + expect($image->width())->toBe(350); + expect($image->height())->toBe(250); +}); diff --git a/tests/Unit/BeforeCaptureTest.php b/tests/Unit/BeforeCaptureTest.php new file mode 100644 index 0000000..1e09584 --- /dev/null +++ b/tests/Unit/BeforeCaptureTest.php @@ -0,0 +1,85 @@ +push(Middleware::history($history)); + $driver = new GotenbergDriver(http: new Client(['handler' => $stack])); + $calls = []; + + $source = (new Screenshot($driver))->html('

Hello

')->javascript() + ->before(function (CaptureContext $context) use ($driver, &$calls) { + expect($context->driver)->toBe($driver); + expect($context->request()->options['multipart'][0]['contents'])->toBe('

Hello

'); + $context->request()->set('waitForExpression', 'window.ready')->set('skipNetworkIdleEvent', true); + $calls[] = 'source'; + }); + + $source->{$format}()->before(function (CaptureContext $context) use (&$calls) { + $context->request()->set('waitForExpression', 'window.finished'); + $context->request()->options['headers']['X-Capture'] = 'custom'; + $calls[] = 'output'; + })->capture(); + + $body = (string) $history[0]['request']->getBody(); + expect($calls)->toBe(['source', 'output']); + expect($body)->toContain('window.finished', 'true')->not->toContain('window.ready'); + expect($history[0]['request']->getHeaderLine('X-Capture'))->toBe('custom'); +})->with(['png', 'jpeg', 'pdf']); + +test('callbacks on an output do not leak into its source or sibling outputs', function () { + $stack = HandlerStack::create(new MockHandler(array_fill(0, 3, new Response(200, [], base64_decode(imageFixture()))))); + $source = (new Screenshot(new GotenbergDriver(http: new Client(['handler' => $stack])))) + ->html('hello')->javascript(); + $calls = 0; + $image = $source->png()->before(function () use (&$calls) { + $calls++; + }); + + $source->capture(); + $source->png()->capture(); + $image->capture(); + + expect($calls)->toBe(1); +}); + +test('context rejects resources unavailable to its driver', function () { + $page = Mockery::mock(Page::class); + $context = new CaptureContext(new ChromeDriver, $page); + + expect($context->page())->toBe($page); + expect(fn () => $context->request())->toThrow(LogicException::class); +}); + +test('Gotenberg callback failures propagate without sending the request', function () { + $http = Mockery::mock(ClientInterface::class); + $http->shouldNotReceive('request'); + + expect(fn () => (new Screenshot(new GotenbergDriver(http: $http))) + ->html('hello')->javascript() + ->before(function (CaptureContext $context) { + expect(fn () => $context->page())->toThrow(LogicException::class); + throw new RuntimeException('preparation failed'); + })->capture())->toThrow(RuntimeException::class, 'preparation failed'); +}); diff --git a/tests/Unit/PageCaptureTest.php b/tests/Unit/PageCaptureTest.php index f8ec88d..d4f7d21 100644 --- a/tests/Unit/PageCaptureTest.php +++ b/tests/Unit/PageCaptureTest.php @@ -1,9 +1,11 @@ with([false, true]); + +test('before callbacks run after loading and failures still close the page', function () { + $browser = Mockery::mock(Browser::class); + $page = Mockery::mock(Page::class); + $session = Mockery::mock(Session::class); + $driver = new ChromeDriver; + $loaded = false; + $options = new CaptureOptions(new BrowserOptions, new ImageOptions); + $options->before(function (CaptureContext $context) use ($page, $driver, &$loaded) { + expect($loaded)->toBeTrue(); + expect($context->driver)->toBe($driver); + expect($context->page())->toBe($page); + throw new RuntimeException('preparation failed'); + }); + + $browser->shouldReceive('createPage')->once()->andReturn($page); + $page->shouldReceive('getSession')->andReturn($session); + $session->shouldReceive('sendMessageSync')->once()->andReturn(new Response([], new Message('test'))); + $page->shouldReceive('setHtml')->once()->andReturnUsing(function () use (&$loaded) { + $loaded = true; + }); + $page->shouldReceive('close')->once(); + $page->shouldNotReceive('screenshot'); + + expect(fn () => (new PageCapture($browser, $options, driver: $driver))->capture('hello', SourceType::Html)) + ->toThrow(RuntimeException::class, 'preparation failed'); +}); From fb6e248e87121124108a5e417395967e8e0b5407 Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Fri, 16 Oct 2026 12:27:24 -0400 Subject: [PATCH 2/5] Use driver-specific capture contexts behind a shared contract --- README.md | 12 ++++---- src/CaptureContext.php | 39 ------------------------ src/CaptureOptions.php | 1 + src/Contracts/CaptureContext.php | 11 +++++++ src/Drivers/Chrome/CaptureContext.php | 34 +++++++++++++++++++++ src/Drivers/Chrome/PageCapture.php | 5 ++- src/Drivers/Gotenberg/CaptureContext.php | 33 ++++++++++++++++++++ src/Drivers/GotenbergDriver.php | 2 +- tests/Browser/CaptureTest.php | 4 +-- tests/Unit/BeforeCaptureTest.php | 22 +++++++++---- tests/Unit/PageCaptureTest.php | 4 +-- 11 files changed, 108 insertions(+), 59 deletions(-) delete mode 100644 src/CaptureContext.php create mode 100644 src/Contracts/CaptureContext.php create mode 100644 src/Drivers/Chrome/CaptureContext.php create mode 100644 src/Drivers/Gotenberg/CaptureContext.php diff --git a/README.md b/README.md index b591250..5fde3f8 100644 --- a/README.md +++ b/README.md @@ -177,10 +177,10 @@ Selection works with JavaScript disabled. Missing elements and invalid selectors ### Customizing a Capture -Use `before()` on a source, image, or PDF builder to customize the prepared driver resource. Each callback receives a `CaptureContext` containing the actual driver instance: +Use `before()` on a source, image, or PDF builder to customize the prepared driver resource. Each callback receives a `CaptureContext` implementing the `Contracts\CaptureContext` interface. Its `driver()` method returns the actual driver instance: ```php -use DirectoryTree\Screenshot\CaptureContext; +use DirectoryTree\Screenshot\Contracts\CaptureContext; use DirectoryTree\Screenshot\Drivers\ChromeDriver; use DirectoryTree\Screenshot\Drivers\GotenbergDriver; use DirectoryTree\Screenshot\Facades\Screenshot; @@ -189,10 +189,10 @@ Screenshot::url('https://example.com') ->javascript() ->before(function (CaptureContext $context) { match (true) { - $context->driver instanceof ChromeDriver => $context->page() + $context->driver() instanceof ChromeDriver => $context->page() ->evaluate('document.querySelector("#banner")?.remove()') ->getReturnValue(), - $context->driver instanceof GotenbergDriver => $context->request() + $context->driver() instanceof GotenbergDriver => $context->request() ->set('waitForExpression', 'window.ready === true'), default => null, }; @@ -205,9 +205,9 @@ Chrome invokes callbacks after the source loads and before image bounds are meas Gotenberg invokes callbacks immediately before sending its HTTP request. `request()` returns a mutable `DirectoryTree\Screenshot\Drivers\Gotenberg\Request`. Use `set()` to add or replace a multipart field. Its public `url` and `options` properties expose the destination and Guzzle request options, including headers and multipart files. This does not provide access to Gotenberg's remote browser page. -Calling `page()` or `request()` when that resource is unavailable throws a `LogicException`. Callbacks run in registration order, their return values are ignored, and exceptions propagate through the driver's existing cleanup. A callback added to a source is copied into subsequently created output builders; callbacks added to an output affect only that builder. Callbacks run for every actual capture, including captures triggered by `save()` and `store()`. +Chrome and Gotenberg each provide their own context implementation. Only Chrome contexts expose `page()`, and only Gotenberg contexts expose `request()`. Callbacks run in registration order, their return values are ignored, and exceptions propagate through the driver's existing cleanup. A callback added to a source is copied into subsequently created output builders; callbacks added to an output affect only that builder. Callbacks run for every actual capture, including captures triggered by `save()` and `store()`. -The screenshot fake records capture options without executing callbacks. Custom drivers can invoke registered callbacks through `$options->prepare($context)` when they provide a supported resource. +The screenshot fake records capture options without executing callbacks. Custom drivers can implement `Contracts\CaptureContext` to expose their own resources and invoke registered callbacks through `$options->prepare($context)`. ### Saving and Reading Captures diff --git a/src/CaptureContext.php b/src/CaptureContext.php deleted file mode 100644 index 677e363..0000000 --- a/src/CaptureContext.php +++ /dev/null @@ -1,39 +0,0 @@ -resource instanceof Page - ? $this->resource - : throw new LogicException('The current capture does not provide a Chrome page.'); - } - - /** - * Get the prepared Gotenberg request. - */ - public function request(): Request - { - return $this->resource instanceof Request - ? $this->resource - : throw new LogicException('The current capture does not provide a Gotenberg request.'); - } -} diff --git a/src/CaptureOptions.php b/src/CaptureOptions.php index c7268ea..a9c4a21 100644 --- a/src/CaptureOptions.php +++ b/src/CaptureOptions.php @@ -3,6 +3,7 @@ namespace DirectoryTree\Screenshot; use Closure; +use DirectoryTree\Screenshot\Contracts\CaptureContext; use DirectoryTree\Screenshot\Enums\OutputFormat; /** diff --git a/src/Contracts/CaptureContext.php b/src/Contracts/CaptureContext.php new file mode 100644 index 0000000..731a9bf --- /dev/null +++ b/src/Contracts/CaptureContext.php @@ -0,0 +1,11 @@ +driver; + } + + /** + * Get the prepared page. + */ + public function page(): Page + { + return $this->page; + } +} diff --git a/src/Drivers/Chrome/PageCapture.php b/src/Drivers/Chrome/PageCapture.php index 6c354de..8a1b8cb 100644 --- a/src/Drivers/Chrome/PageCapture.php +++ b/src/Drivers/Chrome/PageCapture.php @@ -2,9 +2,8 @@ namespace DirectoryTree\Screenshot\Drivers\Chrome; -use DirectoryTree\Screenshot\CaptureContext; use DirectoryTree\Screenshot\CaptureOptions; -use DirectoryTree\Screenshot\Contracts\Driver; +use DirectoryTree\Screenshot\Drivers\ChromeDriver; use DirectoryTree\Screenshot\Enums\OutputFormat; use DirectoryTree\Screenshot\Enums\SourceType; use DirectoryTree\Screenshot\ImageResult; @@ -27,7 +26,7 @@ public function __construct( protected Browser $browser, protected CaptureOptions $options, protected bool $shared = false, - protected ?Driver $driver = null, + protected ?ChromeDriver $driver = null, ) {} /** diff --git a/src/Drivers/Gotenberg/CaptureContext.php b/src/Drivers/Gotenberg/CaptureContext.php new file mode 100644 index 0000000..f98b270 --- /dev/null +++ b/src/Drivers/Gotenberg/CaptureContext.php @@ -0,0 +1,33 @@ +driver; + } + + /** + * Get the prepared request. + */ + public function request(): Request + { + return $this->request; + } +} diff --git a/src/Drivers/GotenbergDriver.php b/src/Drivers/GotenbergDriver.php index 7b05645..5e6e814 100644 --- a/src/Drivers/GotenbergDriver.php +++ b/src/Drivers/GotenbergDriver.php @@ -2,9 +2,9 @@ namespace DirectoryTree\Screenshot\Drivers; -use DirectoryTree\Screenshot\CaptureContext; use DirectoryTree\Screenshot\CaptureOptions; use DirectoryTree\Screenshot\Contracts\Driver; +use DirectoryTree\Screenshot\Drivers\Gotenberg\CaptureContext; use DirectoryTree\Screenshot\Drivers\Gotenberg\ImageOutput; use DirectoryTree\Screenshot\Drivers\Gotenberg\Output; use DirectoryTree\Screenshot\Drivers\Gotenberg\PdfOutput; diff --git a/tests/Browser/CaptureTest.php b/tests/Browser/CaptureTest.php index d0893b1..7de9e6e 100644 --- a/tests/Browser/CaptureTest.php +++ b/tests/Browser/CaptureTest.php @@ -1,7 +1,7 @@ html('
') ->javascript() ->before(function (CaptureContext $context) { - expect($context->driver)->toBeInstanceOf(ChromeDriver::class); + expect($context->driver())->toBeInstanceOf(ChromeDriver::class); $context->page()->evaluate('document.querySelector("#card").style.width = "350px"; document.querySelector("#card").style.height = "250px";')->getReturnValue(); }) ->element('#card') diff --git a/tests/Unit/BeforeCaptureTest.php b/tests/Unit/BeforeCaptureTest.php index 1e09584..e696ba9 100644 --- a/tests/Unit/BeforeCaptureTest.php +++ b/tests/Unit/BeforeCaptureTest.php @@ -1,7 +1,8 @@ html('

Hello

')->javascript() ->before(function (CaptureContext $context) use ($driver, &$calls) { - expect($context->driver)->toBe($driver); + expect($context->driver())->toBe($driver); expect($context->request()->options['multipart'][0]['contents'])->toBe('

Hello

'); $context->request()->set('waitForExpression', 'window.ready')->set('skipNetworkIdleEvent', true); $calls[] = 'source'; @@ -64,12 +65,22 @@ expect($calls)->toBe(1); }); -test('context rejects resources unavailable to its driver', function () { +test('each driver context implements the shared contract and exposes its resource', function () { + $driver = new ChromeDriver; $page = Mockery::mock(Page::class); - $context = new CaptureContext(new ChromeDriver, $page); + $context = new DirectoryTree\Screenshot\Drivers\Chrome\CaptureContext($driver, $page); + expect($context)->toBeInstanceOf(CaptureContext::class); + expect($context->driver())->toBe($driver); expect($context->page())->toBe($page); - expect(fn () => $context->request())->toThrow(LogicException::class); + + $driver = new GotenbergDriver; + $request = new Request('http://localhost:3000', ['multipart' => []]); + $context = new DirectoryTree\Screenshot\Drivers\Gotenberg\CaptureContext($driver, $request); + + expect($context)->toBeInstanceOf(CaptureContext::class); + expect($context->driver())->toBe($driver); + expect($context->request())->toBe($request); }); test('Gotenberg callback failures propagate without sending the request', function () { @@ -79,7 +90,6 @@ expect(fn () => (new Screenshot(new GotenbergDriver(http: $http))) ->html('hello')->javascript() ->before(function (CaptureContext $context) { - expect(fn () => $context->page())->toThrow(LogicException::class); throw new RuntimeException('preparation failed'); })->capture())->toThrow(RuntimeException::class, 'preparation failed'); }); diff --git a/tests/Unit/PageCaptureTest.php b/tests/Unit/PageCaptureTest.php index d4f7d21..f34e20b 100644 --- a/tests/Unit/PageCaptureTest.php +++ b/tests/Unit/PageCaptureTest.php @@ -1,8 +1,8 @@ before(function (CaptureContext $context) use ($page, $driver, &$loaded) { expect($loaded)->toBeTrue(); - expect($context->driver)->toBe($driver); + expect($context->driver())->toBe($driver); expect($context->page())->toBe($page); throw new RuntimeException('preparation failed'); }); From b99845bd76d0a29cf4aa116583820eec52bd4219 Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Fri, 16 Oct 2026 12:28:58 -0400 Subject: [PATCH 3/5] Place capture callbacks after builder properties and constructors --- src/PendingImage.php | 20 ++++++++++---------- src/PendingPdf.php | 20 ++++++++++---------- src/PendingSource.php | 20 ++++++++++---------- 3 files changed, 30 insertions(+), 30 deletions(-) diff --git a/src/PendingImage.php b/src/PendingImage.php index 6323a93..1f04e9a 100644 --- a/src/PendingImage.php +++ b/src/PendingImage.php @@ -7,16 +7,6 @@ class PendingImage { - /** - * Customize the driver immediately before capturing. - */ - public function before(Closure $callback): static - { - $this->options->before($callback); - - return $this; - } - /** * Create an image builder with independent settings. * @@ -29,6 +19,16 @@ public function __construct( $this->options = clone $options; } + /** + * Customize the driver immediately before capturing. + */ + public function before(Closure $callback): static + { + $this->options->before($callback); + + return $this; + } + /** * Set the viewport dimensions after validating both values. */ diff --git a/src/PendingPdf.php b/src/PendingPdf.php index 0cd61f0..683f3a1 100644 --- a/src/PendingPdf.php +++ b/src/PendingPdf.php @@ -7,16 +7,6 @@ class PendingPdf { - /** - * Customize the driver immediately before capturing. - */ - public function before(Closure $callback): static - { - $this->options->before($callback); - - return $this; - } - /** * Create a PDF builder with independent print settings. * @@ -29,6 +19,16 @@ public function __construct( $this->options = clone $options; } + /** + * Customize the driver immediately before capturing. + */ + public function before(Closure $callback): static + { + $this->options->before($callback); + + return $this; + } + /** * Set the viewport dimensions after validating both values. */ diff --git a/src/PendingSource.php b/src/PendingSource.php index 09a699f..1f9a233 100644 --- a/src/PendingSource.php +++ b/src/PendingSource.php @@ -6,16 +6,6 @@ class PendingSource { - /** - * Customize the driver immediately before capturing. - */ - public function before(Closure $callback): static - { - $this->callbacks[] = $callback; - - return $this; - } - /** * The CSS selector to capture from the source. */ @@ -40,6 +30,16 @@ public function __construct( $this->browser = clone $browser; } + /** + * Customize the driver immediately before capturing. + */ + public function before(Closure $callback): static + { + $this->callbacks[] = $callback; + + return $this; + } + /** * Set the viewport dimensions after validating both values. */ From afc337a6a6a232aac5e3a40fea01dffc0a458332 Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Fri, 16 Oct 2026 12:29:55 -0400 Subject: [PATCH 4/5] Require the Chrome driver when preparing page captures --- src/Drivers/Chrome/PageCapture.php | 6 ++---- src/Drivers/ChromeDriver.php | 2 +- tests/Unit/PageCaptureTest.php | 25 ++++++++++++++----------- 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/src/Drivers/Chrome/PageCapture.php b/src/Drivers/Chrome/PageCapture.php index 8a1b8cb..97d2765 100644 --- a/src/Drivers/Chrome/PageCapture.php +++ b/src/Drivers/Chrome/PageCapture.php @@ -23,10 +23,10 @@ class PageCapture * Prepare to capture a page within the given browser. */ public function __construct( + protected ChromeDriver $driver, protected Browser $browser, protected CaptureOptions $options, protected bool $shared = false, - protected ?ChromeDriver $driver = null, ) {} /** @@ -45,9 +45,7 @@ public function capture(string $source, SourceType $type): ImageResult|PdfResult $this->configurePage($page); $this->loadPage($page, $source, $type); - if ($this->driver !== null) { - $this->options->prepare(new CaptureContext($this->driver, $page)); - } + $this->options->prepare(new CaptureContext($this->driver, $page)); $result = $this->newCapture()->capture($page); $failed = false; diff --git a/src/Drivers/ChromeDriver.php b/src/Drivers/ChromeDriver.php index 7e91296..6c4616f 100644 --- a/src/Drivers/ChromeDriver.php +++ b/src/Drivers/ChromeDriver.php @@ -57,7 +57,7 @@ protected function capturePage(Browser $browser, string $source, SourceType $typ $browser->getConnection()->setConnectionHttpHeaders($this->browserOptions['headers']); } - $result = (new PageCapture($browser, $options, $shared, $this))->capture($source, $type); + $result = (new PageCapture($this, $browser, $options, $shared))->capture($source, $type); $failed = false; diff --git a/tests/Unit/PageCaptureTest.php b/tests/Unit/PageCaptureTest.php index f34e20b..251385d 100644 --- a/tests/Unit/PageCaptureTest.php +++ b/tests/Unit/PageCaptureTest.php @@ -75,7 +75,7 @@ $browser->shouldNotReceive('close'); expect( - (new PageCapture($browser, new CaptureOptions(new BrowserOptions, new ImageOptions)))->capture('hello', SourceType::Html)->width(), + (new PageCapture(new ChromeDriver, $browser, new CaptureOptions(new BrowserOptions, new ImageOptions)))->capture('hello', SourceType::Html)->width(), )->toBe(1); }); @@ -104,6 +104,7 @@ expect( fn () => (new PageCapture( + new ChromeDriver, $browser, new CaptureOptions((new BrowserOptions)->setFailOnTimeout(true), new ImageOptions), ))->capture('hello', SourceType::Html), @@ -151,7 +152,7 @@ $browser->shouldNotReceive('close'); expect( - fn () => (new PageCapture($browser, new CaptureOptions(new BrowserOptions, new ImageOptions)))->capture('hello', SourceType::Html), + fn () => (new PageCapture(new ChromeDriver, $browser, new CaptureOptions(new BrowserOptions, new ImageOptions)))->capture('hello', SourceType::Html), )->toThrow(RuntimeException::class, 'capture failure'); }); @@ -185,7 +186,7 @@ $browser->shouldNotReceive('close'); expect( - fn () => (new PageCapture($browser, new CaptureOptions(new BrowserOptions, new ImageOptions)))->capture('hello', SourceType::Html), + fn () => (new PageCapture(new ChromeDriver, $browser, new CaptureOptions(new BrowserOptions, new ImageOptions)))->capture('hello', SourceType::Html), )->toThrow(RuntimeException::class, 'screenshot failure'); }); @@ -234,7 +235,7 @@ $page->shouldReceive('close')->once(); $browser->shouldNotReceive('close'); - $capture = new PageCapture($browser, new CaptureOptions(new BrowserOptions, new ImageOptions), shared: true); + $capture = new PageCapture(new ChromeDriver, $browser, new CaptureOptions(new BrowserOptions, new ImageOptions), shared: true); expect(fn () => $capture->capture('hello', SourceType::Html))->toThrow(RuntimeException::class, 'capture failure'); }); @@ -282,7 +283,7 @@ $connection->shouldReceive('readData')->once(); } - $capture = new PageCapture($browser, new CaptureOptions(new BrowserOptions, new ImageOptions), shared: true); + $capture = new PageCapture(new ChromeDriver, $browser, new CaptureOptions(new BrowserOptions, new ImageOptions), shared: true); expect(fn () => $capture->capture('hello', SourceType::Html))->toThrow(OperationTimedOut::class, 'initialization failure'); })->with([false, true]); @@ -323,6 +324,7 @@ $browser->shouldNotReceive('close'); $result = (new PageCapture( + new ChromeDriver, $browser, new CaptureOptions( (new BrowserOptions)->setTimeouts(load: 15000, browser: 12345), @@ -363,7 +365,7 @@ $browser->shouldNotReceive('close'); expect( - fn () => (new PageCapture($browser, new CaptureOptions(new BrowserOptions, new PdfOptions)))->capture('invoice', SourceType::Html), + fn () => (new PageCapture(new ChromeDriver, $browser, new CaptureOptions(new BrowserOptions, new PdfOptions)))->capture('invoice', SourceType::Html), )->toThrow(RuntimeException::class, 'print failure'); }); @@ -414,6 +416,7 @@ expect( (new PageCapture( + new ChromeDriver, $browser, new CaptureOptions(new BrowserOptions, (new ImageOptions)->setFormat($format, 91)), ))->capture('image', SourceType::Html), @@ -443,7 +446,7 @@ $page->shouldReceive('close')->once(); $browser->shouldNotReceive('close'); - $capture = new class($browser, new CaptureOptions(new BrowserOptions, new ImageOptions)) extends PageCapture + $capture = new class(new ChromeDriver, $browser, new CaptureOptions(new BrowserOptions, new ImageOptions)) extends PageCapture { protected function newCapture(): CapturesPage { @@ -479,7 +482,7 @@ protected function newCapture(): CapturesPage $browser->shouldNotReceive('close'); expect( - fn () => (new PageCapture($browser, new CaptureOptions(new BrowserOptions, new ImageOptions)))->capture('hello', SourceType::Html), + fn () => (new PageCapture(new ChromeDriver, $browser, new CaptureOptions(new BrowserOptions, new ImageOptions)))->capture('hello', SourceType::Html), )->toThrow(RuntimeException::class, 'Unable to configure JavaScript execution: configuration rejected'); }); @@ -513,7 +516,7 @@ protected function newCapture(): CapturesPage $browser->shouldNotReceive('close'); expect( - fn () => (new PageCapture($browser, new CaptureOptions(new BrowserOptions, new ImageOptions)))->capture('hello', SourceType::Html), + fn () => (new PageCapture(new ChromeDriver, $browser, new CaptureOptions(new BrowserOptions, new ImageOptions)))->capture('hello', SourceType::Html), )->toThrow(RuntimeException::class, 'Unable to stop page loading: stop rejected'); }); @@ -594,7 +597,7 @@ protected function newCapture(): CapturesPage ) ->andReturn(new Response(['error' => ['message' => 'target rejected']], new Message('test'))); - $capture = new PageCapture($browser, new CaptureOptions(new BrowserOptions, new ImageOptions), shared: true); + $capture = new PageCapture(new ChromeDriver, $browser, new CaptureOptions(new BrowserOptions, new ImageOptions), shared: true); expect(fn () => $capture->capture('hello', SourceType::Html))->toThrow( RuntimeException::class, @@ -625,6 +628,6 @@ protected function newCapture(): CapturesPage $page->shouldReceive('close')->once(); $page->shouldNotReceive('screenshot'); - expect(fn () => (new PageCapture($browser, $options, driver: $driver))->capture('hello', SourceType::Html)) + expect(fn () => (new PageCapture($driver, $browser, $options))->capture('hello', SourceType::Html)) ->toThrow(RuntimeException::class, 'preparation failed'); }); From 956ea55cdfec67ac67978da3cea6dfb04b11c0cd Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Fri, 16 Oct 2026 12:30:25 -0400 Subject: [PATCH 5/5] Give driver capture contexts distinct names --- README.md | 2 +- .../Chrome/{CaptureContext.php => ChromeCaptureContext.php} | 4 ++-- src/Drivers/Chrome/PageCapture.php | 2 +- .../{CaptureContext.php => GotenbergCaptureContext.php} | 4 ++-- src/Drivers/GotenbergDriver.php | 4 ++-- tests/Unit/BeforeCaptureTest.php | 6 ++++-- 6 files changed, 12 insertions(+), 10 deletions(-) rename src/Drivers/Chrome/{CaptureContext.php => ChromeCaptureContext.php} (81%) rename src/Drivers/Gotenberg/{CaptureContext.php => GotenbergCaptureContext.php} (81%) diff --git a/README.md b/README.md index 5fde3f8..cd05218 100644 --- a/README.md +++ b/README.md @@ -205,7 +205,7 @@ Chrome invokes callbacks after the source loads and before image bounds are meas Gotenberg invokes callbacks immediately before sending its HTTP request. `request()` returns a mutable `DirectoryTree\Screenshot\Drivers\Gotenberg\Request`. Use `set()` to add or replace a multipart field. Its public `url` and `options` properties expose the destination and Guzzle request options, including headers and multipart files. This does not provide access to Gotenberg's remote browser page. -Chrome and Gotenberg each provide their own context implementation. Only Chrome contexts expose `page()`, and only Gotenberg contexts expose `request()`. Callbacks run in registration order, their return values are ignored, and exceptions propagate through the driver's existing cleanup. A callback added to a source is copied into subsequently created output builders; callbacks added to an output affect only that builder. Callbacks run for every actual capture, including captures triggered by `save()` and `store()`. +`ChromeCaptureContext` and `GotenbergCaptureContext` implement the shared contract. Only Chrome contexts expose `page()`, and only Gotenberg contexts expose `request()`. Callbacks run in registration order, their return values are ignored, and exceptions propagate through the driver's existing cleanup. A callback added to a source is copied into subsequently created output builders; callbacks added to an output affect only that builder. Callbacks run for every actual capture, including captures triggered by `save()` and `store()`. The screenshot fake records capture options without executing callbacks. Custom drivers can implement `Contracts\CaptureContext` to expose their own resources and invoke registered callbacks through `$options->prepare($context)`. diff --git a/src/Drivers/Chrome/CaptureContext.php b/src/Drivers/Chrome/ChromeCaptureContext.php similarity index 81% rename from src/Drivers/Chrome/CaptureContext.php rename to src/Drivers/Chrome/ChromeCaptureContext.php index 4883380..1394ed1 100644 --- a/src/Drivers/Chrome/CaptureContext.php +++ b/src/Drivers/Chrome/ChromeCaptureContext.php @@ -2,11 +2,11 @@ namespace DirectoryTree\Screenshot\Drivers\Chrome; -use DirectoryTree\Screenshot\Contracts\CaptureContext as CaptureContextContract; +use DirectoryTree\Screenshot\Contracts\CaptureContext; use DirectoryTree\Screenshot\Drivers\ChromeDriver; use HeadlessChromium\Page; -class CaptureContext implements CaptureContextContract +class ChromeCaptureContext implements CaptureContext { /** * Create a context for the prepared page. diff --git a/src/Drivers/Chrome/PageCapture.php b/src/Drivers/Chrome/PageCapture.php index 97d2765..ae60cca 100644 --- a/src/Drivers/Chrome/PageCapture.php +++ b/src/Drivers/Chrome/PageCapture.php @@ -45,7 +45,7 @@ public function capture(string $source, SourceType $type): ImageResult|PdfResult $this->configurePage($page); $this->loadPage($page, $source, $type); - $this->options->prepare(new CaptureContext($this->driver, $page)); + $this->options->prepare(new ChromeCaptureContext($this->driver, $page)); $result = $this->newCapture()->capture($page); $failed = false; diff --git a/src/Drivers/Gotenberg/CaptureContext.php b/src/Drivers/Gotenberg/GotenbergCaptureContext.php similarity index 81% rename from src/Drivers/Gotenberg/CaptureContext.php rename to src/Drivers/Gotenberg/GotenbergCaptureContext.php index f98b270..97fc344 100644 --- a/src/Drivers/Gotenberg/CaptureContext.php +++ b/src/Drivers/Gotenberg/GotenbergCaptureContext.php @@ -2,10 +2,10 @@ namespace DirectoryTree\Screenshot\Drivers\Gotenberg; -use DirectoryTree\Screenshot\Contracts\CaptureContext as CaptureContextContract; +use DirectoryTree\Screenshot\Contracts\CaptureContext; use DirectoryTree\Screenshot\Drivers\GotenbergDriver; -class CaptureContext implements CaptureContextContract +class GotenbergCaptureContext implements CaptureContext { /** * Create a context for the prepared request. diff --git a/src/Drivers/GotenbergDriver.php b/src/Drivers/GotenbergDriver.php index 5e6e814..993594a 100644 --- a/src/Drivers/GotenbergDriver.php +++ b/src/Drivers/GotenbergDriver.php @@ -4,7 +4,7 @@ use DirectoryTree\Screenshot\CaptureOptions; use DirectoryTree\Screenshot\Contracts\Driver; -use DirectoryTree\Screenshot\Drivers\Gotenberg\CaptureContext; +use DirectoryTree\Screenshot\Drivers\Gotenberg\GotenbergCaptureContext; use DirectoryTree\Screenshot\Drivers\Gotenberg\ImageOutput; use DirectoryTree\Screenshot\Drivers\Gotenberg\Output; use DirectoryTree\Screenshot\Drivers\Gotenberg\PdfOutput; @@ -71,7 +71,7 @@ protected function capturePage(string $source, SourceType $type, CaptureOptions 'allow_redirects' => false, ]); - $options->prepare(new CaptureContext($this, $request)); + $options->prepare(new GotenbergCaptureContext($this, $request)); $response = $this->http->request('POST', $request->url, $request->options); diff --git a/tests/Unit/BeforeCaptureTest.php b/tests/Unit/BeforeCaptureTest.php index e696ba9..79a51d5 100644 --- a/tests/Unit/BeforeCaptureTest.php +++ b/tests/Unit/BeforeCaptureTest.php @@ -1,7 +1,9 @@ toBeInstanceOf(CaptureContext::class); expect($context->driver())->toBe($driver); @@ -76,7 +78,7 @@ $driver = new GotenbergDriver; $request = new Request('http://localhost:3000', ['multipart' => []]); - $context = new DirectoryTree\Screenshot\Drivers\Gotenberg\CaptureContext($driver, $request); + $context = new GotenbergCaptureContext($driver, $request); expect($context)->toBeInstanceOf(CaptureContext::class); expect($context->driver())->toBe($driver);