From 11d83b96ae6a375c864e640e2a0c814d45ffedae Mon Sep 17 00:00:00 2001 From: Hosmel Quintana Date: Thu, 15 Oct 2026 13:05:26 -0600 Subject: [PATCH 1/3] feat: add Gotenberg capture driver --- .github/workflows/tests.yml | 37 +++++ README.md | 74 ++++++++- composer.json | 4 +- config/screenshot.php | 20 ++- src/Drivers/Gotenberg/ImageCapture.php | 105 ++++++++++++ src/Drivers/GotenbergDriver.php | 165 +++++++++++++++++++ src/ScreenshotManager.php | 13 ++ tests/Browser/GotenbergTest.php | 194 +++++++++++++++++++++++ tests/Browser/PdfTest.php | 7 - tests/Feature/ScreenshotManagerTest.php | 41 +++++ tests/Pest.php | 10 ++ tests/Unit/GotenbergDriverTest.php | 202 ++++++++++++++++++++++++ 12 files changed, 858 insertions(+), 14 deletions(-) create mode 100644 src/Drivers/Gotenberg/ImageCapture.php create mode 100644 src/Drivers/GotenbergDriver.php create mode 100644 tests/Browser/GotenbergTest.php create mode 100644 tests/Unit/GotenbergDriverTest.php diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5369270..95d4bd1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -65,6 +65,43 @@ jobs: - run: composer install --prefer-dist --no-interaction - run: composer audit + gotenberg: + name: Gotenberg + runs-on: ubuntu-latest + timeout-minutes: 10 + + services: + gotenberg: + image: gotenberg/gotenberg:8.38.1 + ports: + - 3000:3000 + env: + API_ENABLE_BASIC_AUTH: 'true' + GOTENBERG_API_BASIC_AUTH_USERNAME: screenshot + GOTENBERG_API_BASIC_AUTH_PASSWORD: screenshot-test + gotenberg-nojs: + image: gotenberg/gotenberg:8.38.1 + ports: + - 3001:3000 + env: + CHROMIUM_DISABLE_JAVASCRIPT: 'true' + + steps: + - uses: actions/checkout@v6 + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: mbstring, dom, fileinfo, pdo_sqlite, sockets, zip, gd + coverage: none + - run: composer install --prefer-dist --no-interaction + - name: Execute Gotenberg integration tests + env: + SCREENSHOT_GOTENBERG_URL: http://localhost:3000 + SCREENSHOT_GOTENBERG_NOJS_URL: http://localhost:3001 + SCREENSHOT_GOTENBERG_USERNAME: screenshot + SCREENSHOT_GOTENBERG_PASSWORD: screenshot-test + run: composer test-gotenberg + installer: name: Chrome installer / ${{ matrix.os }} runs-on: ${{ matrix.os }} diff --git a/README.md b/README.md index 105a2a9..a2b16d3 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ --- -Screenshot captures HTML, Blade views, and web pages as PNG, JPEG, or PDF files using Chrome. Save captures locally or store them on any Laravel filesystem disk: +Screenshot captures HTML, Blade views, and web pages as PNG, JPEG, or PDF files using local Chrome or a remote Gotenberg service. Save captures locally or store them on any Laravel filesystem disk: ```php use DirectoryTree\Screenshot\Facades\Screenshot; @@ -37,9 +37,9 @@ Screenshot::view('invoices.preview', data: ['invoice' => $invoice]) ## Requirements - PHP 8.2+ and Laravel 12 or 13 for Laravel integration (PHP 8.3+ with Laravel 13) -- Chrome or Chromium, installable with `php artisan screenshot:install` +- Chrome or Chromium, installable with `php artisan screenshot:install`, or Gotenberg 8.38+ -Screenshot uses [Chrome PHP](https://github.com/chrome-php/chrome). Node.js and Puppeteer are not required. +The Chrome driver uses [Chrome PHP](https://github.com/chrome-php/chrome). Node.js and Puppeteer are not required. The Gotenberg driver uses Guzzle to send captures to your Gotenberg service. ## Installation @@ -364,9 +364,75 @@ $screenshot->html('

Hello, world!

') Pass `BrowserOptions`, `ImageOptions`, or `PdfOptions` objects through the `browser`, `image`, or `pdf` constructor arguments to set defaults. `view()` requires an injected Laravel view factory; `store()` requires a booted Laravel filesystem. +### Gotenberg + +Use [Gotenberg](https://gotenberg.dev) 8.38 or later to render PNG, JPEG, and PDF captures on a separate service. Local Chrome installation is unnecessary when using this driver. + +Start a service with JavaScript enabled (Gotenberg's default): + +```bash +docker run --rm -p 3000:3000 gotenberg/gotenberg:8.38.1 +``` + +Configure the service URL and optionally make it the default driver: + +```dotenv +SCREENSHOT_DRIVER=gotenberg +SCREENSHOT_GOTENBERG_URL=http://localhost:3000 +``` + +Captures must opt into JavaScript to match this service: + +```php +Screenshot::driver('gotenberg') + ->view('invoices.preview', ['invoice' => $invoice]) + ->javascript() + ->pdf() + ->save(storage_path('app/invoice.pdf')); + +Screenshot::driver('gotenberg') + ->url('https://example.com') + ->javascript() + ->size(1200, 800) + ->jpeg(85) + ->viewport() + ->capture(); +``` + +Alternatively, publish the configuration and set `capture.javascript` to `true` for all captures. Gotenberg cannot toggle JavaScript per request: `gotenberg.javascript` describes the remote service's setting, and captures with a different setting are rejected before sending content. To keep JavaScript disabled, start Gotenberg with `--chromium-disable-javascript`, set `SCREENSHOT_GOTENBERG_JAVASCRIPT=false`, and retain the default `capture.javascript=false`. + +Pass Guzzle options through `gotenberg.options`, for example to authenticate to the service: + +```php +'gotenberg' => [ + 'url' => env('SCREENSHOT_GOTENBERG_URL', 'http://localhost:3000'), + 'javascript' => true, + 'options' => [ + 'auth' => [env('GOTENBERG_USERNAME'), env('GOTENBERG_PASSWORD')], + 'connect_timeout' => 5, + ], +], +``` + +Outside Laravel, use the same driver directly: + +```php +use DirectoryTree\Screenshot\Drivers\GotenbergDriver; +use DirectoryTree\Screenshot\Screenshot; + +$screenshot = new Screenshot(new GotenbergDriver(url: 'http://localhost:3000')); +$screenshot->html('

Hello!

')->javascript()->png()->capture(); +``` + +The driver supports image viewport dimensions, full-page and viewport captures, image element selectors, JPEG quality, and the existing PDF paper, margin, orientation, background, and CSS page-size settings. `maxSize()` crops the returned image from the top-left without changing the layout viewport; install PHP's GD extension when images can exceed these limits. Gotenberg renders and transfers the complete image before this crop, and cropped JPEGs are re-encoded at the selected quality. + +The remote service must be able to reach URL sources and absolute asset URLs. Local application files and Docker host `localhost` addresses are not automatically accessible from Gotenberg. + +Gotenberg manages browser lifetime; shared socket keys and element selection for PDFs are unsupported and throw exceptions. `timeouts(browser: ...)` limits the entire HTTP request in milliseconds. The separate load timeout and `failOnTimeout()` setting do not control Gotenberg: service-side timeouts still apply, and a timed-out request always fails instead of returning partial content. PDF viewport dimensions are managed by Gotenberg. HTTP errors include Gotenberg's response and trace identifier, and redirects are rejected. + ### Custom Drivers -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: +The facade uses `ScreenshotManager` to resolve named drivers. Chrome (`chrome`) is the default, and Gotenberg is available as `gotenberg`. Calls such as `Screenshot::html()` are forwarded to the configured default driver: ```php Screenshot::html('

Hello!

')->capture(); diff --git a/composer.json b/composer.json index 5837649..cfb0410 100644 --- a/composer.json +++ b/composer.json @@ -35,6 +35,7 @@ "pestphp/pest-plugin-laravel": "^3.0|^4.0" }, "suggest": { + "ext-gd": "Required to crop Gotenberg images that exceed the configured maximum dimensions.", "ext-zip": "Required to download Chrome using screenshot:install." }, "autoload": { @@ -55,8 +56,9 @@ } }, "scripts": { - "test": "vendor/bin/pest --exclude-group=browser", + "test": "vendor/bin/pest --exclude-group=browser --exclude-group=gotenberg", "test-browser": "vendor/bin/pest --group=browser", + "test-gotenberg": "vendor/bin/pest --group=gotenberg", "format": "vendor/bin/pint", "lint": "vendor/bin/pint --test" }, diff --git a/config/screenshot.php b/config/screenshot.php index 60f10d4..7632054 100644 --- a/config/screenshot.php +++ b/config/screenshot.php @@ -7,8 +7,8 @@ | Default Driver |-------------------------------------------------------------------------- | - | Captures use this driver unless one is selected explicitly. Chrome is - | included by default. Register custom drivers using Screenshot::extend(). + | Captures use this driver unless one is selected explicitly. Chrome and + | Gotenberg are included. Register custom drivers using Screenshot::extend(). | */ @@ -35,6 +35,22 @@ 'options' => [], ], + /* + |-------------------------------------------------------------------------- + | Gotenberg + |-------------------------------------------------------------------------- + | + | HTTP options are passed to Guzzle. The JavaScript setting must match + | the remote service and each capture; it does not configure Gotenberg. + | + */ + + 'gotenberg' => [ + 'url' => env('SCREENSHOT_GOTENBERG_URL', 'http://localhost:3000'), + 'options' => [], + 'javascript' => env('SCREENSHOT_GOTENBERG_JAVASCRIPT', true), + ], + /* |-------------------------------------------------------------------------- | Shared Browser Sockets diff --git a/src/Drivers/Gotenberg/ImageCapture.php b/src/Drivers/Gotenberg/ImageCapture.php new file mode 100644 index 0000000..f7cd498 --- /dev/null +++ b/src/Drivers/Gotenberg/ImageCapture.php @@ -0,0 +1,105 @@ + $this->browser->getWidth(), + 'height' => $this->browser->getHeight(), + 'clip' => ! $this->options->getFullPage(), + 'format' => $this->options->getFormat()->value, + ]; + + if ($this->element !== null) { + $fields['selector'] = $this->element; + } + + if ($this->options->getFormat() === OutputFormat::Jpeg) { + $fields['quality'] = $this->options->getQuality(); + } + + return $fields; + } + + /** + * Validate the image and crop oversized results without scaling. + */ + public function result(string $contents): ImageResult + { + $result = new ImageResult(base64_encode($contents)); + + if ($result->mimeType() !== 'image/'.$this->options->getFormat()->value) { + throw new RuntimeException('Gotenberg returned an unexpected image format.'); + } + + $width = min($result->width(), $this->options->getMaxWidth()); + $height = min($result->height(), $this->options->getMaxHeight()); + + if ($width === $result->width() && $height === $result->height()) { + return $result; + } + + if (! extension_loaded('gd')) { + throw new RuntimeException('The GD extension is required to crop Gotenberg images to their maximum dimensions.'); + } + + $image = ErrorHandler::suppress(fn () => imagecreatefromstring($contents)); + + if ($image === false) { + throw new RuntimeException('Unable to decode the Gotenberg image for cropping.'); + } + + $cropped = imagecrop($image, [ + 'x' => 0, + 'y' => 0, + 'width' => $width, + 'height' => $height, + ]); + + if ($cropped === false) { + throw new RuntimeException('Unable to crop the Gotenberg image.'); + } + + imagesavealpha($cropped, true); + + ob_start(); + + try { + $written = $this->options->getFormat() === OutputFormat::Jpeg + ? imagejpeg($cropped, null, $this->options->getQuality()) + : imagepng($cropped); + + if (! $written) { + throw new RuntimeException('Unable to encode the cropped Gotenberg image.'); + } + + return new ImageResult( + base64_encode(ob_get_contents()) + ); + } finally { + ob_end_clean(); + } + } +} diff --git a/src/Drivers/GotenbergDriver.php b/src/Drivers/GotenbergDriver.php new file mode 100644 index 0000000..1f71d54 --- /dev/null +++ b/src/Drivers/GotenbergDriver.php @@ -0,0 +1,165 @@ +url); + + if (! $parts || ! in_array($parts['scheme'] ?? null, ['http', 'https'], true) + || empty($parts['host']) || isset($parts['query']) || isset($parts['fragment'])) { + throw new InvalidArgumentException('The Gotenberg URL must be an HTTP or HTTPS base URL without a query or fragment.'); + } + + $this->http = $http ?? new Client($this->httpOptions); + } + + /** + * Capture HTML or a URL through Gotenberg's Chromium routes. + */ + public function capture(string $source, SourceType $type, CaptureOptions $options): ImageResult|PdfResult + { + $options->validate(); + $this->validateOptions($options); + + $output = $options->output(); + $image = $output instanceof ImageOptions + ? new ImageCapture( + $options->browser(), + $output, + $options->element(), + ) + : null; + $fields = $image ? $image->fields() : $this->getPdfFields($output); + $contents = $this->capturePage($source, $type, $options, $fields); + + if ($image) { + return $image->result($contents); + } + + return new PdfResult(base64_encode($contents)); + } + + /** + * Send a capture request and return its output bytes. + */ + protected function capturePage(string $source, SourceType $type, CaptureOptions $options, array $fields): string + { + $route = $options->output() instanceof ImageOptions ? 'screenshot' : 'convert'; + + $response = $this->http->request('POST', rtrim($this->url, '/')."/forms/chromium/{$route}/{$type->value}", [ + 'multipart' => $this->getMultipart($source, $type, $fields), + 'timeout' => $options->browser()->getBrowserTimeout() / 1000, + 'http_errors' => false, + 'allow_redirects' => false, + ]); + + if ($response->getStatusCode() !== 200) { + throw new RuntimeException(sprintf( + 'Gotenberg capture failed (HTTP %d, trace: %s): %s', + $response->getStatusCode(), + $response->getHeaderLine('Gotenberg-Trace'), + (string) $response->getBody(), + )); + } + + return (string) $response->getBody(); + } + + /** + * Prepare the source and capture fields for a multipart request. + */ + protected function getMultipart(string $source, SourceType $type, array $fields): array + { + $multipart = []; + + if ($type === SourceType::Html) { + $multipart[] = [ + 'name' => 'files', + 'contents' => $source, + 'filename' => 'index.html', + 'headers' => ['Content-Type' => 'text/html'], + ]; + } else { + $multipart[] = ['name' => 'url', 'contents' => $source]; + } + + foreach ($fields as $name => $value) { + if (is_bool($value)) { + $value = $value ? 'true' : 'false'; + } + + $multipart[] = [ + 'name' => $name, + 'contents' => (string) $value, + ]; + } + + return $multipart; + } + + /** + * Reject settings that cannot be applied to the remote browser. + */ + protected function validateOptions(CaptureOptions $options): void + { + if ($options->browser()->getSocket() !== null) { + throw new InvalidArgumentException('Gotenberg manages its own browsers and does not support shared socket keys.'); + } + + if ($options->browser()->getJavascript() !== $this->javascript) { + throw new InvalidArgumentException('The capture JavaScript setting must match the Gotenberg service. Configure screenshot.gotenberg.javascript and use javascript() or screenshot.capture.javascript accordingly.'); + } + + if ($options->output() instanceof PdfOptions && $options->element() !== null) { + throw new InvalidArgumentException('Gotenberg does not support element selection for PDF captures.'); + } + } + + /** + * Translate the existing PDF layout into Gotenberg's form fields. + */ + protected function getPdfFields(PdfOptions $options): array + { + $fields = [ + 'paperWidth' => $options->getPaperWidth().'mm', + 'paperHeight' => $options->getPaperHeight().'mm', + 'landscape' => $options->getLandscape(), + 'printBackground' => $options->getBackground(), + 'preferCssPageSize' => $options->getPreferCssPageSize(), + ]; + + foreach ($options->getMargins() as $side => $margin) { + $fields['margin'.ucfirst($side)] = $margin.'mm'; + } + + return $fields; + } +} diff --git a/src/ScreenshotManager.php b/src/ScreenshotManager.php index 8414346..b66f528 100644 --- a/src/ScreenshotManager.php +++ b/src/ScreenshotManager.php @@ -5,6 +5,7 @@ use DirectoryTree\Screenshot\Contracts\Driver; use DirectoryTree\Screenshot\Contracts\Factory; use DirectoryTree\Screenshot\Drivers\ChromeDriver; +use DirectoryTree\Screenshot\Drivers\GotenbergDriver; use DirectoryTree\Screenshot\Enums\OutputFormat; use DirectoryTree\Screenshot\Enums\PaperSize; use DirectoryTree\Screenshot\Support\ChromeInstaller; @@ -65,6 +66,18 @@ protected function createChromeDriver(): ChromeDriver ); } + /** + * Create the Gotenberg capture driver. + */ + protected function createGotenbergDriver(): GotenbergDriver + { + return new GotenbergDriver( + $this->config->get('screenshot.gotenberg.url', 'http://localhost:3000'), + $this->config->get('screenshot.gotenberg.options', []), + $this->config->get('screenshot.gotenberg.javascript', true), + ); + } + /** * Create a screenshot factory with the application's capture defaults. */ diff --git a/tests/Browser/GotenbergTest.php b/tests/Browser/GotenbergTest.php new file mode 100644 index 0000000..24cdf9f --- /dev/null +++ b/tests/Browser/GotenbergTest.php @@ -0,0 +1,194 @@ +group('gotenberg'); + +function gotenbergScreenshot(bool $authenticate = true): Screenshot +{ + $url = getenv('SCREENSHOT_GOTENBERG_URL'); + + if (! $url) { + test()->markTestSkipped('Set SCREENSHOT_GOTENBERG_URL to run Gotenberg integration tests.'); + } + + $httpOptions = []; + + if ($authenticate && ($username = getenv('SCREENSHOT_GOTENBERG_USERNAME'))) { + $httpOptions['auth'] = [$username, getenv('SCREENSHOT_GOTENBERG_PASSWORD')]; + } + + return new Screenshot( + new GotenbergDriver($url, $httpOptions), + ); +} + +test('real Gotenberg renders full-page PNG and viewport JPEG captures', function () { + $source = gotenbergScreenshot()->html(file_get_contents(__DIR__.'/../Fixtures/document.html')) + ->javascript() + ->size(640, 480); + + $image = $source->png()->capture(); + + expect($image->mimeType())->toBe('image/png'); + expect($image->width())->toBe(640); + expect($image->height())->toBe(960); + + $viewport = $source->jpeg(73) + ->viewport() + ->capture(); + + expect($viewport->mimeType())->toBe('image/jpeg'); + expect($viewport->width())->toBe(640); + expect($viewport->height())->toBe(480); +}); + +test('real Gotenberg crops images without changing responsive layout', function (string $format) { + if (! extension_loaded('gd')) { + $this->markTestSkipped('Cropping requires GD.'); + } + + $result = gotenbergScreenshot()->html(<<<'HTML' + +
+ HTML) + ->javascript() + ->size(640, 480) + ->{$format}() + ->maxSize(100, 200) + ->capture(); + + expect($result->width())->toBe(100); + expect($result->height())->toBe(200); + + $image = imagecreatefromstring($result->contents()); + $color = imagecolorsforindex($image, imagecolorat($image, 0, 0)); + + expect($color['green'])->toBeGreaterThan(250); + expect($color['red'])->toBeLessThan(5); + + $marker = imagecolorsforindex($image, imagecolorat($image, 90, 10)); + + expect($marker['green'])->toBeLessThan(5); +})->with(['png', 'jpeg']); + +test('real Gotenberg applies the configured service JavaScript setting', function () { + $url = getenv('SCREENSHOT_GOTENBERG_NOJS_URL'); + + if (! $url) { + $this->markTestSkipped('Set SCREENSHOT_GOTENBERG_NOJS_URL to test a service with JavaScript disabled.'); + } + + $html = '
' + .''; + $factory = new Screenshot( + new GotenbergDriver(url: $url, javascript: false), + ); + $disabled = $factory->html($html) + ->size(400, 300) + ->capture(); + $enabled = gotenbergScreenshot()->html($html) + ->javascript() + ->size(400, 300) + ->capture(); + + expect($disabled->height())->toBe(300); + expect($enabled->height())->toBe(900); +}); + +test('real Gotenberg rejects requests without the configured service authentication', function () { + if (! getenv('SCREENSHOT_GOTENBERG_USERNAME')) { + $this->markTestSkipped('Configure service authentication to test unauthorized requests.'); + } + + $factory = gotenbergScreenshot(authenticate: false); + + expect( + fn () => $factory->html('content') + ->javascript() + ->pdf() + ->capture(), + )->toThrow(RuntimeException::class, 'HTTP 401'); +}); + +test('real Gotenberg crops selected elements to the maximum dimensions', function () { + if (! extension_loaded('gd')) { + $this->markTestSkipped('Cropping requires GD.'); + } + + $result = gotenbergScreenshot()->html(<<<'HTML' + +
Card
+ HTML) + ->javascript() + ->element('.card') + ->png() + ->maxSize(120, 70) + ->capture(); + + expect($result->width())->toBe(120); + expect($result->height())->toBe(70); +}); + +test('real Gotenberg renders a URL reachable from the service', function () { + $result = gotenbergScreenshot()->url('http://localhost:3000/health') + ->javascript() + ->size(400, 300) + ->png() + ->viewport() + ->capture(); + + expect($result->mimeType())->toBe('image/png'); + expect($result->width())->toBe(400); + expect($result->height())->toBe(300); +}); + +test('real Gotenberg prints PDFs with custom dimensions', function () { + $result = gotenbergScreenshot()->html('

Invoice

') + ->javascript() + ->pdf() + ->paperSize(100, 200) + ->margins(0, 0, 0, 0) + ->landscape() + ->capture(); + + expect($result->contents())->toStartWith('%PDF-'); + expect($result->mimeType())->toBe('application/pdf'); + + [$width, $height] = pdfPageSize($result->contents()); + + expect($width)->toEqualWithDelta(200 / 25.4 * 72, 1); + expect($height)->toEqualWithDelta(100 / 25.4 * 72, 1); +}); + +test('real Gotenberg honors CSS page sizes', function () { + $result = gotenbergScreenshot()->html('

CSS paper

') + ->javascript() + ->pdf() + ->preferCssPageSize() + ->capture(); + + [$width, $height] = pdfPageSize($result->contents()); + + expect($width)->toEqualWithDelta(120 / 25.4 * 72, 1); + expect($height)->toEqualWithDelta(180 / 25.4 * 72, 1); +}); + +test('real Gotenberg reports selectors that match no element', function () { + expect( + fn () => gotenbergScreenshot()->html('

Content

') + ->javascript() + ->element('#missing') + ->capture(), + )->toThrow(RuntimeException::class, 'HTTP 400'); +}); diff --git a/tests/Browser/PdfTest.php b/tests/Browser/PdfTest.php index 6b61ae0..9dc3c86 100644 --- a/tests/Browser/PdfTest.php +++ b/tests/Browser/PdfTest.php @@ -21,13 +21,6 @@ function pdfScreenshot(): Screenshot ); } -function pdfPageSize(string $contents): array -{ - preg_match('~/MediaBox\s*\[\s*0\s+0\s+([\d.]+)\s+([\d.]+)\s*\]~', $contents, $matches); - - return [(float) ($matches[1] ?? 0), (float) ($matches[2] ?? 0)]; -} - test('real Chrome prints a paginated PDF using the source viewport', function () { $result = pdfScreenshot()->html(file_get_contents(__DIR__.'/../Fixtures/document-pdf.html')) ->size(320, 240) diff --git a/tests/Feature/ScreenshotManagerTest.php b/tests/Feature/ScreenshotManagerTest.php index 88fbd2a..65df017 100644 --- a/tests/Feature/ScreenshotManagerTest.php +++ b/tests/Feature/ScreenshotManagerTest.php @@ -9,6 +9,47 @@ use DirectoryTree\Screenshot\ScreenshotManager; use DirectoryTree\Screenshot\Support\ChromeInstaller; use DirectoryTree\Screenshot\Testing\ScreenshotFake; +use GuzzleHttp\Handler\MockHandler; +use GuzzleHttp\HandlerStack; +use GuzzleHttp\Middleware; +use GuzzleHttp\Psr7\Response; + +test('the built-in Gotenberg driver renders views with configured defaults without resolving Chrome', function () { + $installer = Mockery::mock(ChromeInstaller::class); + $installer->shouldNotReceive('path'); + app()->instance(ChromeInstaller::class, $installer); + $history = []; + $mock = new MockHandler([ + new Response(200, [], file_get_contents(__DIR__.'/../../resources/blank.pdf')), + new Response(200, [], base64_decode(imageFixture())), + ]); + $stack = HandlerStack::create($mock); + + $stack->push(Middleware::history($history)); + + config([ + 'screenshot.default' => 'gotenberg', + 'screenshot.gotenberg.url' => 'https://gotenberg.example.test', + 'screenshot.gotenberg.options' => ['handler' => $stack], + 'screenshot.gotenberg.javascript' => false, + 'screenshot.capture.width' => 900, + ]); + + $manager = app(ScreenshotManager::class); + + expect($manager->getDrivers())->toBe([]); + + Screenshot::view('card', ['name' => '']) + ->pdf() + ->capture(); + Screenshot::driver('gotenberg') + ->html('image') + ->capture(); + + expect($manager->driver())->toBe($manager->driver('gotenberg')); + expect((string) $history[0]['request']->getBody())->toContain('<Steve>'); + expect((string) $history[1]['request']->getBody())->toContain("\r\n900\r\n"); +}); test('Chrome is the default driver and is resolved lazily', function () { $installer = Mockery::mock(ChromeInstaller::class); diff --git a/tests/Pest.php b/tests/Pest.php index 59687af..34ed576 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -13,6 +13,16 @@ function imageFixture(): string return 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aX1sAAAAASUVORK5CYII='; } +/** + * Get the dimensions of the first PDF page in points. + */ +function pdfPageSize(string $contents): array +{ + preg_match('~/MediaBox\s*\[\s*0\s+0\s+([\d.]+)\s+([\d.]+)\s*\]~', $contents, $matches); + + return [(float) ($matches[1] ?? 0), (float) ($matches[2] ?? 0)]; +} + /** * Get a path within an isolated temporary directory for the current test. */ diff --git a/tests/Unit/GotenbergDriverTest.php b/tests/Unit/GotenbergDriverTest.php new file mode 100644 index 0000000..3798663 --- /dev/null +++ b/tests/Unit/GotenbergDriverTest.php @@ -0,0 +1,202 @@ +history = []; + $this->mock = new MockHandler; + $stack = HandlerStack::create($this->mock); + + $stack->push(Middleware::history($this->history)); + + $this->factory = new Screenshot( + new GotenbergDriver( + url: 'https://gotenberg.example.test/api/', + httpOptions: [ + 'handler' => $stack, + 'auth' => ['user', 'password'], + ], + ), + ); +}); + +test('Gotenberg sends HTML as an in-memory multipart file and preserves PDF layout', function () { + $this->mock->append(new Response(200, [], file_get_contents(__DIR__.'/../../resources/blank.pdf'))); + + $result = $this->factory->html('

Invoice

') + ->javascript() + ->pdf() + ->paperSize(100, 200) + ->landscape() + ->margins(1, 2, 3, 4) + ->background(false) + ->preferCssPageSize() + ->capture(); + + expect($result->mimeType())->toBe('application/pdf'); + + $request = $this->history[0]['request']; + $body = (string) $request->getBody(); + + expect($request->getMethod())->toBe('POST'); + expect((string) $request->getUri())->toBe('https://gotenberg.example.test/api/forms/chromium/convert/html'); + expect($request->getHeaderLine('Authorization'))->toBe('Basic '.base64_encode('user:password')); + expect($request->getHeaderLine('Content-Type'))->toStartWith('multipart/form-data; boundary='); + expect($body)->toContain('name="files"; filename="index.html"', '

Invoice

'); + + $fields = [ + 'paperWidth' => '100mm', + 'paperHeight' => '200mm', + 'marginTop' => '1mm', + 'marginRight' => '2mm', + 'marginBottom' => '3mm', + 'marginLeft' => '4mm', + 'landscape' => 'true', + 'printBackground' => 'false', + 'preferCssPageSize' => 'true', + ]; + + foreach ($fields as $field => $value) { + expect($body)->toMatch('/name="'.preg_quote($field, '/').'"\r\n(?:[^\r\n]+\r\n)*\r\n'.preg_quote($value, '/').'\r\n/'); + } +}); + +test('Gotenberg selects the URL screenshot route with the original viewport', function () { + $this->mock->append(new Response(200, [], base64_decode(imageFixture()))); + + $result = $this->factory->url('https://example.com/report') + ->javascript() + ->size(640, 480) + ->png() + ->viewport() + ->maxSize(200, 100) + ->capture(); + + expect($result->mimeType())->toBe('image/png'); + expect($result->width())->toBe(1); + expect((string) $this->history[0]['request']->getUri())->toEndWith('/forms/chromium/screenshot/url'); + + $body = (string) $this->history[0]['request']->getBody(); + + expect($body)->toContain('https://example.com/report', 'name="width"', "\r\n640\r\n", "\r\n480\r\n", "\r\ntrue\r\n"); + expect($body)->not->toContain('filename=', 'name="quality"', 'name="selector"'); +}); + +test('Gotenberg selects the URL PDF route', function () { + $this->mock->append(new Response(200, [], file_get_contents(__DIR__.'/../../resources/blank.pdf'))); + + $this->factory->url('https://example.com/report') + ->javascript() + ->pdf() + ->capture(); + + expect((string) $this->history[0]['request']->getUri())->toEndWith('/forms/chromium/convert/url'); +}); + +test('Gotenberg forwards image selectors and rejects unexpected output formats', function () { + $this->mock->append(new Response(200, [], base64_decode(imageFixture()))); + + expect( + fn () => $this->factory->html('
Card
') + ->javascript() + ->element('#card') + ->jpeg(73) + ->capture(), + )->toThrow(RuntimeException::class, 'unexpected image format'); + + $body = (string) $this->history[0]['request']->getBody(); + + expect($body)->toContain('name="selector"', "\r\n#card\r\n", "\r\njpeg\r\n", "\r\n73\r\n"); +}); + +test('Gotenberg uses the browser timeout for the entire request', function () { + $this->mock->append(new Response(200, [], base64_decode(imageFixture()))); + + $this->factory->html('content') + ->javascript() + ->timeouts(browser: 1250) + ->capture(); + + expect($this->history[0]['options']['timeout'])->toBe(1.25); + expect($this->history[0]['options']['http_errors'])->toBeFalse(); + expect($this->history[0]['options']['allow_redirects'])->toBeFalse(); +}); + +test('Gotenberg reports HTTP errors and the remote trace', function (int $status) { + $this->mock->append(new Response($status, ['Gotenberg-Trace' => 'trace-id'], 'conversion failed')); + + expect( + fn () => $this->factory->html('content') + ->javascript() + ->capture(), + )->toThrow(RuntimeException::class, "Gotenberg capture failed (HTTP {$status}, trace: trace-id): conversion failed"); +})->with([302, 400, 401, 503]); + +test('Gotenberg preserves connection and timeout errors', function () { + $this->mock->append(new ConnectException( + 'Connection timed out', + new Request('POST', 'https://gotenberg.example.test'), + )); + + expect( + fn () => $this->factory->html('content') + ->javascript() + ->capture(), + )->toThrow(ConnectException::class, 'Connection timed out'); +}); + +test('Gotenberg rejects unsupported settings before sending requests', function (Closure $capture, string $message) { + expect(fn () => $capture($this->factory))->toThrow(InvalidArgumentException::class, $message); + expect($this->history)->toBe([]); +})->with([ + 'JavaScript mismatch' => [fn ($factory) => $factory->html('content')->capture(), 'JavaScript setting must match'], + 'sockets' => [ + fn ($factory) => $factory->html('content') + ->javascript() + ->socket('shared') + ->capture(), + 'shared socket keys', + ], + 'PDF elements' => [ + fn ($factory) => $factory->html('content') + ->javascript() + ->element('#card') + ->pdf() + ->capture(), + 'element selection for PDF', + ], +]); + +test('Gotenberg supports services configured with JavaScript disabled', function () { + $this->mock->append(new Response(200, [], base64_decode(imageFixture()))); + $driver = new GotenbergDriver( + javascript: false, + http: new Client(['handler' => $this->mock]), + ); + $factory = new Screenshot($driver); + + expect($factory->html('content')->capture()->width())->toBe(1); +}); + +test('Gotenberg validates successful response bodies', function (string $body, bool $pdf) { + $this->mock->append(new Response(200, [], $body)); + $source = $this->factory->html('content')->javascript(); + + expect(fn () => ($pdf ? $source->pdf() : $source->png())->capture()) + ->toThrow(InvalidArgumentException::class); +})->with([ + ['not an image', false], + ['not a PDF', true], +]); + +test('Gotenberg rejects invalid base URLs', function (string $url) { + expect(fn () => new GotenbergDriver($url))->toThrow(InvalidArgumentException::class); +})->with(['', 'file:///tmp', 'localhost:3000', 'https://example.test?token=secret', 'https://example.test/#fragment']); From 9595ed0a16c0c482770935e408a81ecc2efeb1db Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Fri, 16 Oct 2026 09:28:09 -0400 Subject: [PATCH 2/3] Simplify Gotenberg output handling and capture configuration --- src/Drivers/Chrome/ImageCapture.php | 40 ++++--- src/Drivers/Chrome/PageCapture.php | 12 +- src/Drivers/Chrome/PdfCapture.php | 22 ++-- src/Drivers/Gotenberg/ImageCapture.php | 105 ----------------- src/Drivers/Gotenberg/ImageOutput.php | 137 ++++++++++++++++++++++ src/Drivers/Gotenberg/Output.php | 24 ++++ src/Drivers/Gotenberg/PdfOutput.php | 62 ++++++++++ src/Drivers/GotenbergDriver.php | 100 +++++++--------- src/Screenshot.php | 3 +- src/ScreenshotManager.php | 3 +- src/Support/BrowserSocket.php | 6 +- tests/Browser/ElementTest.php | 5 +- tests/Browser/GotenbergTest.php | 80 +++++++------ tests/Unit/ChromeCaptureTest.php | 13 ++- tests/Unit/GotenbergDriverTest.php | 154 ++++++++++++++++++------- 15 files changed, 476 insertions(+), 290 deletions(-) delete mode 100644 src/Drivers/Gotenberg/ImageCapture.php create mode 100644 src/Drivers/Gotenberg/ImageOutput.php create mode 100644 src/Drivers/Gotenberg/Output.php create mode 100644 src/Drivers/Gotenberg/PdfOutput.php diff --git a/src/Drivers/Chrome/ImageCapture.php b/src/Drivers/Chrome/ImageCapture.php index cd28285..7a17519 100644 --- a/src/Drivers/Chrome/ImageCapture.php +++ b/src/Drivers/Chrome/ImageCapture.php @@ -2,7 +2,7 @@ namespace DirectoryTree\Screenshot\Drivers\Chrome; -use DirectoryTree\Screenshot\BrowserOptions; +use DirectoryTree\Screenshot\CaptureOptions; use DirectoryTree\Screenshot\Enums\OutputFormat; use DirectoryTree\Screenshot\ImageOptions; use DirectoryTree\Screenshot\ImageResult; @@ -14,11 +14,11 @@ class ImageCapture implements CapturesPage { /** * Create an image capture strategy. + * + * @param CaptureOptions $options */ public function __construct( - protected BrowserOptions $browser, - protected ImageOptions $options, - protected ?string $element = null, + protected CaptureOptions $options, ) {} /** @@ -26,25 +26,27 @@ public function __construct( */ public function capture(Page $page): ImageResult { + $options = $this->options->output(); + $clip = $this->clip($page); $parameters = [ - 'format' => $this->options->getFormat()->value, + 'format' => $options->getFormat()->value, 'captureBeyondViewport' => true, 'clip' => new Clip( $clip->getX(), $clip->getY(), - max(1, min($clip->getWidth(), $this->options->getMaxWidth())), - max(1, min($clip->getHeight(), $this->options->getMaxHeight())), + max(1, min($clip->getWidth(), $options->getMaxWidth())), + max(1, min($clip->getHeight(), $options->getMaxHeight())), ), ]; - if ($this->options->getFormat() === OutputFormat::Jpeg) { - $parameters['quality'] = $this->options->getQuality(); + if ($options->getFormat() === OutputFormat::Jpeg) { + $parameters['quality'] = $options->getQuality(); } return new ImageResult( $page->screenshot($parameters)->getBase64( - $this->browser->getBrowserTimeout() + $this->options->browser()->getBrowserTimeout() ) ); } @@ -54,15 +56,17 @@ public function capture(Page $page): ImageResult */ protected function clip(Page $page): Clip { - if ($this->element !== null) { + if ($this->options->element() !== null) { return $this->getElementClip($page); } - if ($this->options->getFullPage()) { - return $page->getFullPageClip($this->browser->getBrowserTimeout()); + $browser = $this->options->browser(); + + if ($this->options->output()->getFullPage()) { + return $page->getFullPageClip($browser->getBrowserTimeout()); } - return new Clip(0, 0, $this->browser->getWidth(), $this->browser->getHeight()); + return new Clip(0, 0, $browser->getWidth(), $browser->getHeight()); } /** @@ -70,13 +74,15 @@ protected function clip(Page $page): Clip */ protected function getElementClip(Page $page): Clip { - $element = $page->dom()->querySelector($this->element) - ?? throw new RuntimeException("No element matches the selector [{$this->element}]."); + $selector = $this->options->element(); + + $element = $page->dom()->querySelector($selector) + ?? throw new RuntimeException("No element matches the selector [{$selector}]."); $position = $element->getPosition('border'); if (! $position || $position->getWidth() <= 0 || $position->getHeight() <= 0) { - throw new RuntimeException("The element [{$this->element}] has no layout bounds."); + throw new RuntimeException("The element [{$selector}] has no layout bounds."); } return new Clip($position->getX(), $position->getY(), $position->getWidth(), $position->getHeight()); diff --git a/src/Drivers/Chrome/PageCapture.php b/src/Drivers/Chrome/PageCapture.php index 37dd097..6988a38 100644 --- a/src/Drivers/Chrome/PageCapture.php +++ b/src/Drivers/Chrome/PageCapture.php @@ -107,16 +107,8 @@ protected function newCapture(): CapturesPage $options = $this->options; return match ($options->format()) { - OutputFormat::Png, OutputFormat::Jpeg => new ImageCapture( - $options->browser(), - $options->output(), - $options->element() - ), - OutputFormat::Pdf => new PdfCapture( - $options->browser(), - $options->output(), - $options->element() - ), + OutputFormat::Png, OutputFormat::Jpeg => new ImageCapture($options), + OutputFormat::Pdf => new PdfCapture($options), }; } diff --git a/src/Drivers/Chrome/PdfCapture.php b/src/Drivers/Chrome/PdfCapture.php index a94e7ee..b34e28e 100644 --- a/src/Drivers/Chrome/PdfCapture.php +++ b/src/Drivers/Chrome/PdfCapture.php @@ -2,7 +2,7 @@ namespace DirectoryTree\Screenshot\Drivers\Chrome; -use DirectoryTree\Screenshot\BrowserOptions; +use DirectoryTree\Screenshot\CaptureOptions; use DirectoryTree\Screenshot\PdfOptions; use DirectoryTree\Screenshot\PdfResult; use HeadlessChromium\Page; @@ -12,11 +12,11 @@ class PdfCapture implements CapturesPage { /** * Create a PDF capture strategy. + * + * @param CaptureOptions $options */ public function __construct( - protected BrowserOptions $browser, - protected PdfOptions $options, - protected ?string $element = null, + protected CaptureOptions $options, ) {} /** @@ -24,15 +24,15 @@ public function __construct( */ public function capture(Page $page): PdfResult { - if ($this->element !== null) { + if ($this->options->element() !== null) { $this->isolateElement($page); } return new PdfResult( $page->pdf( - $this->options->toArray() + $this->options->output()->toArray() )->getBase64( - $this->browser->getBrowserTimeout() + $this->options->browser()->getBrowserTimeout() ) ); } @@ -42,7 +42,9 @@ public function capture(Page $page): PdfResult */ protected function isolateElement(Page $page): void { - $selector = json_encode($this->element, JSON_THROW_ON_ERROR); + $element = $this->options->element(); + + $selector = json_encode($element, JSON_THROW_ON_ERROR); $found = $page->evaluate(<< { @@ -59,10 +61,10 @@ protected function isolateElement(Page $page): void return true; })() - JS)->getReturnValue($this->browser->getBrowserTimeout()); + JS)->getReturnValue($this->options->browser()->getBrowserTimeout()); if (! $found) { - throw new RuntimeException("No element matches the selector [{$this->element}]."); + throw new RuntimeException("No element matches the selector [{$element}]."); } } } diff --git a/src/Drivers/Gotenberg/ImageCapture.php b/src/Drivers/Gotenberg/ImageCapture.php deleted file mode 100644 index f7cd498..0000000 --- a/src/Drivers/Gotenberg/ImageCapture.php +++ /dev/null @@ -1,105 +0,0 @@ - $this->browser->getWidth(), - 'height' => $this->browser->getHeight(), - 'clip' => ! $this->options->getFullPage(), - 'format' => $this->options->getFormat()->value, - ]; - - if ($this->element !== null) { - $fields['selector'] = $this->element; - } - - if ($this->options->getFormat() === OutputFormat::Jpeg) { - $fields['quality'] = $this->options->getQuality(); - } - - return $fields; - } - - /** - * Validate the image and crop oversized results without scaling. - */ - public function result(string $contents): ImageResult - { - $result = new ImageResult(base64_encode($contents)); - - if ($result->mimeType() !== 'image/'.$this->options->getFormat()->value) { - throw new RuntimeException('Gotenberg returned an unexpected image format.'); - } - - $width = min($result->width(), $this->options->getMaxWidth()); - $height = min($result->height(), $this->options->getMaxHeight()); - - if ($width === $result->width() && $height === $result->height()) { - return $result; - } - - if (! extension_loaded('gd')) { - throw new RuntimeException('The GD extension is required to crop Gotenberg images to their maximum dimensions.'); - } - - $image = ErrorHandler::suppress(fn () => imagecreatefromstring($contents)); - - if ($image === false) { - throw new RuntimeException('Unable to decode the Gotenberg image for cropping.'); - } - - $cropped = imagecrop($image, [ - 'x' => 0, - 'y' => 0, - 'width' => $width, - 'height' => $height, - ]); - - if ($cropped === false) { - throw new RuntimeException('Unable to crop the Gotenberg image.'); - } - - imagesavealpha($cropped, true); - - ob_start(); - - try { - $written = $this->options->getFormat() === OutputFormat::Jpeg - ? imagejpeg($cropped, null, $this->options->getQuality()) - : imagepng($cropped); - - if (! $written) { - throw new RuntimeException('Unable to encode the cropped Gotenberg image.'); - } - - return new ImageResult( - base64_encode(ob_get_contents()) - ); - } finally { - ob_end_clean(); - } - } -} diff --git a/src/Drivers/Gotenberg/ImageOutput.php b/src/Drivers/Gotenberg/ImageOutput.php new file mode 100644 index 0000000..983ee8b --- /dev/null +++ b/src/Drivers/Gotenberg/ImageOutput.php @@ -0,0 +1,137 @@ + $options + */ + public function __construct( + protected CaptureOptions $options, + ) {} + + /** + * Get the Chromium screenshot route. + */ + public function route(): string + { + return 'screenshot'; + } + + /** + * Get the screenshot form fields without changing the layout viewport. + */ + public function fields(): array + { + $browser = $this->options->browser(); + $options = $this->options->output(); + + $fields = [ + 'width' => $browser->getWidth(), + 'height' => $browser->getHeight(), + 'clip' => ! $options->getFullPage(), + 'format' => $options->getFormat()->value, + ]; + + if ($this->options->element() !== null) { + $fields['selector'] = $this->options->element(); + } + + if ($options->getFormat() === OutputFormat::Jpeg) { + $fields['quality'] = $options->getQuality(); + } + + return $fields; + } + + /** + * Validate the image and crop oversized results without scaling. + */ + public function result(string $contents): ImageResult + { + $options = $this->options->output(); + + $result = new ImageResult(base64_encode($contents)); + + if ($result->mimeType() !== 'image/'.$options->getFormat()->value) { + throw new RuntimeException('Gotenberg returned an unexpected image format.'); + } + + return $this->crop($result); + } + + /** + * Crop oversized images to their maximum dimensions without scaling. + */ + protected function crop(ImageResult $result): ImageResult + { + $options = $this->options->output(); + + $width = min($result->width(), $options->getMaxWidth()); + $height = min($result->height(), $options->getMaxHeight()); + + if ($width === $result->width() && $height === $result->height()) { + return $result; + } + + if (! extension_loaded('gd')) { + throw new RuntimeException('The GD extension is required to crop Gotenberg images to their maximum dimensions.'); + } + + $image = ErrorHandler::suppress(fn () => imagecreatefromstring($result->contents())); + + if ($image === false) { + throw new RuntimeException('Unable to decode the Gotenberg image for cropping.'); + } + + $cropped = imagecrop($image, [ + 'x' => 0, + 'y' => 0, + 'width' => $width, + 'height' => $height, + ]); + + if ($cropped === false) { + throw new RuntimeException('Unable to crop the Gotenberg image.'); + } + + imagesavealpha($cropped, true); + + return new ImageResult(base64_encode($this->encode($cropped))); + } + + /** + * Encode a cropped image using the configured output format. + */ + protected function encode(GdImage $image): string + { + $options = $this->options->output(); + + ob_start(); + + try { + $written = $options->getFormat() === OutputFormat::Jpeg + ? imagejpeg($image, null, $options->getQuality()) + : imagepng($image); + + if (! $written) { + throw new RuntimeException('Unable to encode the cropped Gotenberg image.'); + } + + return ob_get_contents(); + } finally { + ob_end_clean(); + } + } +} diff --git a/src/Drivers/Gotenberg/Output.php b/src/Drivers/Gotenberg/Output.php new file mode 100644 index 0000000..08cc929 --- /dev/null +++ b/src/Drivers/Gotenberg/Output.php @@ -0,0 +1,24 @@ + $options + */ + public function __construct( + protected CaptureOptions $options, + ) { + if ($options->element() !== null) { + throw new InvalidArgumentException('Gotenberg does not support element selection for PDF captures.'); + } + } + + /** + * Get the Chromium conversion route. + */ + public function route(): string + { + return 'convert'; + } + + /** + * Translate the PDF layout into Gotenberg's form fields. + */ + public function fields(): array + { + $options = $this->options->output(); + + $fields = [ + 'paperWidth' => $options->getPaperWidth().'mm', + 'paperHeight' => $options->getPaperHeight().'mm', + 'landscape' => $options->getLandscape(), + 'printBackground' => $options->getBackground(), + 'preferCssPageSize' => $options->getPreferCssPageSize(), + ]; + + foreach ($options->getMargins() as $side => $margin) { + $fields['margin'.ucfirst($side)] = $margin.'mm'; + } + + return $fields; + } + + /** + * Validate the PDF and create a capture result. + */ + public function result(string $contents): PdfResult + { + return new PdfResult(base64_encode($contents)); + } +} diff --git a/src/Drivers/GotenbergDriver.php b/src/Drivers/GotenbergDriver.php index 1f71d54..2b4c8df 100644 --- a/src/Drivers/GotenbergDriver.php +++ b/src/Drivers/GotenbergDriver.php @@ -4,41 +4,30 @@ use DirectoryTree\Screenshot\CaptureOptions; use DirectoryTree\Screenshot\Contracts\Driver; -use DirectoryTree\Screenshot\Drivers\Gotenberg\ImageCapture; +use DirectoryTree\Screenshot\Drivers\Gotenberg\ImageOutput; +use DirectoryTree\Screenshot\Drivers\Gotenberg\Output; +use DirectoryTree\Screenshot\Drivers\Gotenberg\PdfOutput; +use DirectoryTree\Screenshot\Enums\OutputFormat; use DirectoryTree\Screenshot\Enums\SourceType; -use DirectoryTree\Screenshot\ImageOptions; use DirectoryTree\Screenshot\ImageResult; -use DirectoryTree\Screenshot\PdfOptions; use DirectoryTree\Screenshot\PdfResult; use GuzzleHttp\Client; use GuzzleHttp\ClientInterface; +use Illuminate\Support\Str; use InvalidArgumentException; use RuntimeException; class GotenbergDriver implements Driver { - /** - * The HTTP client used to communicate with Gotenberg. - */ - protected ClientInterface $http; - /** * Connect to Gotenberg with the service's JavaScript setting. */ public function __construct( protected string $url = 'http://localhost:3000', - protected array $httpOptions = [], + protected ClientInterface $http = new Client, protected bool $javascript = true, - ?ClientInterface $http = null, ) { - $parts = parse_url($this->url); - - if (! $parts || ! in_array($parts['scheme'] ?? null, ['http', 'https'], true) - || empty($parts['host']) || isset($parts['query']) || isset($parts['fragment'])) { - throw new InvalidArgumentException('The Gotenberg URL must be an HTTP or HTTPS base URL without a query or fragment.'); - } - - $this->http = $http ?? new Client($this->httpOptions); + $this->validateUrl($url); } /** @@ -46,36 +35,35 @@ public function __construct( */ public function capture(string $source, SourceType $type, CaptureOptions $options): ImageResult|PdfResult { - $options->validate(); $this->validateOptions($options); - $output = $options->output(); - $image = $output instanceof ImageOptions - ? new ImageCapture( - $options->browser(), - $output, - $options->element(), - ) - : null; - $fields = $image ? $image->fields() : $this->getPdfFields($output); - $contents = $this->capturePage($source, $type, $options, $fields); - - if ($image) { - return $image->result($contents); - } + $output = $this->newOutput($options); + + $contents = $this->capturePage($source, $type, $options, $output); + + return $output->result($contents); + } - return new PdfResult(base64_encode($contents)); + /** + * Create an output handler for the selected format. + */ + protected function newOutput(CaptureOptions $options): Output + { + return match ($options->format()) { + OutputFormat::Png, OutputFormat::Jpeg => new ImageOutput($options), + OutputFormat::Pdf => new PdfOutput($options), + }; } /** * Send a capture request and return its output bytes. */ - protected function capturePage(string $source, SourceType $type, CaptureOptions $options, array $fields): string + protected function capturePage(string $source, SourceType $type, CaptureOptions $options, Output $output): string { - $route = $options->output() instanceof ImageOptions ? 'screenshot' : 'convert'; + $route = $output->route(); $response = $this->http->request('POST', rtrim($this->url, '/')."/forms/chromium/{$route}/{$type->value}", [ - 'multipart' => $this->getMultipart($source, $type, $fields), + 'multipart' => $this->getMultipart($source, $type, $output->fields()), 'timeout' => $options->browser()->getBrowserTimeout() / 1000, 'http_errors' => false, 'allow_redirects' => false, @@ -126,40 +114,30 @@ protected function getMultipart(string $source, SourceType $type, array $fields) } /** - * Reject settings that cannot be applied to the remote browser. + * Require an HTTP or HTTPS base URL without a query or fragment. */ - protected function validateOptions(CaptureOptions $options): void + protected function validateUrl(string $url): void { - if ($options->browser()->getSocket() !== null) { - throw new InvalidArgumentException('Gotenberg manages its own browsers and does not support shared socket keys.'); - } - - if ($options->browser()->getJavascript() !== $this->javascript) { - throw new InvalidArgumentException('The capture JavaScript setting must match the Gotenberg service. Configure screenshot.gotenberg.javascript and use javascript() or screenshot.capture.javascript accordingly.'); - } + $parts = parse_url($url); - if ($options->output() instanceof PdfOptions && $options->element() !== null) { - throw new InvalidArgumentException('Gotenberg does not support element selection for PDF captures.'); + if (! $parts || ! Str::isUrl($url, ['http', 'https']) || isset($parts['query']) || isset($parts['fragment'])) { + throw new InvalidArgumentException('The Gotenberg URL must be an HTTP or HTTPS base URL without a query or fragment.'); } } /** - * Translate the existing PDF layout into Gotenberg's form fields. + * Validate capture settings and their compatibility with the remote browser. */ - protected function getPdfFields(PdfOptions $options): array + protected function validateOptions(CaptureOptions $options): void { - $fields = [ - 'paperWidth' => $options->getPaperWidth().'mm', - 'paperHeight' => $options->getPaperHeight().'mm', - 'landscape' => $options->getLandscape(), - 'printBackground' => $options->getBackground(), - 'preferCssPageSize' => $options->getPreferCssPageSize(), - ]; - - foreach ($options->getMargins() as $side => $margin) { - $fields['margin'.ucfirst($side)] = $margin.'mm'; + $options->validate(); + + if ($options->browser()->getSocket() !== null) { + throw new InvalidArgumentException('Gotenberg manages its own browsers and does not support shared socket keys.'); } - return $fields; + if ($options->browser()->getJavascript() !== $this->javascript) { + throw new InvalidArgumentException('The capture JavaScript setting must match the Gotenberg service. Configure screenshot.gotenberg.javascript and use javascript() or screenshot.capture.javascript accordingly.'); + } } } diff --git a/src/Screenshot.php b/src/Screenshot.php index f1371e5..1e0d8fd 100644 --- a/src/Screenshot.php +++ b/src/Screenshot.php @@ -7,6 +7,7 @@ use DirectoryTree\Screenshot\Drivers\ChromeDriver; use DirectoryTree\Screenshot\Enums\SourceType; use Illuminate\Contracts\View\Factory as ViewFactory; +use Illuminate\Support\Str; use InvalidArgumentException; use LogicException; @@ -40,7 +41,7 @@ public function html(string $html): PendingSource */ public function url(string $url): PendingSource { - if (! filter_var($url, FILTER_VALIDATE_URL) || ! in_array(strtolower(parse_url($url, PHP_URL_SCHEME) ?? ''), ['http', 'https'], true)) { + if (! Str::isUrl($url, ['http', 'https'])) { throw new InvalidArgumentException('The screenshot URL must be a valid HTTP or HTTPS URL.'); } diff --git a/src/ScreenshotManager.php b/src/ScreenshotManager.php index b66f528..64505ab 100644 --- a/src/ScreenshotManager.php +++ b/src/ScreenshotManager.php @@ -11,6 +11,7 @@ use DirectoryTree\Screenshot\Support\ChromeInstaller; use DirectoryTree\Screenshot\Testing\ScreenshotFake; use DirectoryTree\Screenshot\Testing\ScreenshotManagerFake; +use GuzzleHttp\Client; use Illuminate\Support\Manager; /** @@ -73,7 +74,7 @@ protected function createGotenbergDriver(): GotenbergDriver { return new GotenbergDriver( $this->config->get('screenshot.gotenberg.url', 'http://localhost:3000'), - $this->config->get('screenshot.gotenberg.options', []), + new Client($this->config->get('screenshot.gotenberg.options', [])), $this->config->get('screenshot.gotenberg.javascript', true), ); } diff --git a/src/Support/BrowserSocket.php b/src/Support/BrowserSocket.php index 59cfba4..38c5de6 100644 --- a/src/Support/BrowserSocket.php +++ b/src/Support/BrowserSocket.php @@ -6,6 +6,7 @@ use HeadlessChromium\BrowserFactory; use HeadlessChromium\Exception\BrowserConnectionFailed; use HeadlessChromium\Exception\OperationTimedOut; +use Illuminate\Support\Str; use InvalidArgumentException; use RuntimeException; use Throwable; @@ -224,11 +225,8 @@ protected function read(): ?string } $uri = trim($contents); - $parts = parse_url($uri); - return $parts && in_array($parts['scheme'] ?? '', ['ws', 'wss'], true) && ! empty($parts['host']) - ? $uri - : null; + return Str::isUrl($uri, ['ws', 'wss']) ? $uri : null; } /** diff --git a/tests/Browser/ElementTest.php b/tests/Browser/ElementTest.php index 464b43e..ac354f2 100644 --- a/tests/Browser/ElementTest.php +++ b/tests/Browser/ElementTest.php @@ -1,6 +1,7 @@ evaluate("document.querySelector('input').value = 'Current value'")->getReturnValue(); - (new PdfCapture(new BrowserOptions, new PdfOptions, '#invoice'))->capture($page); + $capture = new PdfCapture(new CaptureOptions(new BrowserOptions, new PdfOptions, '#invoice')); + + $capture->capture($page); $page->getSession()->sendMessageSync(new Message('Emulation.setEmulatedMedia', ['media' => 'print'])); $layout = $page->evaluate(<<<'JS' diff --git a/tests/Browser/GotenbergTest.php b/tests/Browser/GotenbergTest.php index 24cdf9f..635755d 100644 --- a/tests/Browser/GotenbergTest.php +++ b/tests/Browser/GotenbergTest.php @@ -2,17 +2,12 @@ use DirectoryTree\Screenshot\Drivers\GotenbergDriver; use DirectoryTree\Screenshot\Screenshot; +use GuzzleHttp\Client; uses()->group('gotenberg'); function gotenbergScreenshot(bool $authenticate = true): Screenshot { - $url = getenv('SCREENSHOT_GOTENBERG_URL'); - - if (! $url) { - test()->markTestSkipped('Set SCREENSHOT_GOTENBERG_URL to run Gotenberg integration tests.'); - } - $httpOptions = []; if ($authenticate && ($username = getenv('SCREENSHOT_GOTENBERG_USERNAME'))) { @@ -20,7 +15,7 @@ function gotenbergScreenshot(bool $authenticate = true): Screenshot } return new Screenshot( - new GotenbergDriver($url, $httpOptions), + new GotenbergDriver(getenv('SCREENSHOT_GOTENBERG_URL'), new Client($httpOptions)), ); } @@ -42,13 +37,12 @@ function gotenbergScreenshot(bool $authenticate = true): Screenshot expect($viewport->mimeType())->toBe('image/jpeg'); expect($viewport->width())->toBe(640); expect($viewport->height())->toBe(480); -}); +})->skip( + fn () => ! getenv('SCREENSHOT_GOTENBERG_URL'), + 'Set SCREENSHOT_GOTENBERG_URL to run Gotenberg integration tests.', +); test('real Gotenberg crops images without changing responsive layout', function (string $format) { - if (! extension_loaded('gd')) { - $this->markTestSkipped('Cropping requires GD.'); - } - $result = gotenbergScreenshot()->html(<<<'HTML'
' .''; $factory = new Screenshot( - new GotenbergDriver(url: $url, javascript: false), + new GotenbergDriver(url: getenv('SCREENSHOT_GOTENBERG_NOJS_URL'), javascript: false), ); $disabled = $factory->html($html) ->size(400, 300) @@ -101,13 +92,15 @@ function gotenbergScreenshot(bool $authenticate = true): Screenshot expect($disabled->height())->toBe(300); expect($enabled->height())->toBe(900); -}); +})->skip( + fn () => ! getenv('SCREENSHOT_GOTENBERG_URL'), + 'Set SCREENSHOT_GOTENBERG_URL to run Gotenberg integration tests.', +)->skip( + fn () => ! getenv('SCREENSHOT_GOTENBERG_NOJS_URL'), + 'Set SCREENSHOT_GOTENBERG_NOJS_URL to test a service with JavaScript disabled.', +); test('real Gotenberg rejects requests without the configured service authentication', function () { - if (! getenv('SCREENSHOT_GOTENBERG_USERNAME')) { - $this->markTestSkipped('Configure service authentication to test unauthorized requests.'); - } - $factory = gotenbergScreenshot(authenticate: false); expect( @@ -116,13 +109,15 @@ function gotenbergScreenshot(bool $authenticate = true): Screenshot ->pdf() ->capture(), )->toThrow(RuntimeException::class, 'HTTP 401'); -}); +})->skip( + fn () => ! getenv('SCREENSHOT_GOTENBERG_URL'), + 'Set SCREENSHOT_GOTENBERG_URL to run Gotenberg integration tests.', +)->skip( + fn () => ! getenv('SCREENSHOT_GOTENBERG_USERNAME'), + 'Configure service authentication to test unauthorized requests.', +); test('real Gotenberg crops selected elements to the maximum dimensions', function () { - if (! extension_loaded('gd')) { - $this->markTestSkipped('Cropping requires GD.'); - } - $result = gotenbergScreenshot()->html(<<<'HTML'

CSS paper

') @@ -182,7 +186,10 @@ function gotenbergScreenshot(bool $authenticate = true): Screenshot expect($width)->toEqualWithDelta(120 / 25.4 * 72, 1); expect($height)->toEqualWithDelta(180 / 25.4 * 72, 1); -}); +})->skip( + fn () => ! getenv('SCREENSHOT_GOTENBERG_URL'), + 'Set SCREENSHOT_GOTENBERG_URL to run Gotenberg integration tests.', +); test('real Gotenberg reports selectors that match no element', function () { expect( @@ -191,4 +198,7 @@ function gotenbergScreenshot(bool $authenticate = true): Screenshot ->element('#missing') ->capture(), )->toThrow(RuntimeException::class, 'HTTP 400'); -}); +})->skip( + fn () => ! getenv('SCREENSHOT_GOTENBERG_URL'), + 'Set SCREENSHOT_GOTENBERG_URL to run Gotenberg integration tests.', +); diff --git a/tests/Unit/ChromeCaptureTest.php b/tests/Unit/ChromeCaptureTest.php index 66c8fbb..64740de 100644 --- a/tests/Unit/ChromeCaptureTest.php +++ b/tests/Unit/ChromeCaptureTest.php @@ -1,6 +1,7 @@ with(1234) ->andReturn(imageFixture()); - expect((new ImageCapture($browser, $options))->capture($page))->toBeInstanceOf(ImageResult::class); + $capture = new ImageCapture(new CaptureOptions($browser, $options)); + + expect($capture->capture($page))->toBeInstanceOf(ImageResult::class); })->with([ 'PNG' => OutputFormat::Png, 'JPEG' => OutputFormat::Jpeg, @@ -98,7 +101,9 @@ ->with(4321) ->andReturn(base64_encode(file_get_contents(__DIR__.'/../../resources/blank.pdf'))); - expect((new PdfCapture($browser, $options))->capture($page))->toBeInstanceOf(PdfResult::class); + $capture = new PdfCapture(new CaptureOptions($browser, $options)); + + expect($capture->capture($page))->toBeInstanceOf(PdfResult::class); }); test('element image bounds retain their document coordinates when capped', function ($fullPage) { @@ -127,5 +132,7 @@ })->andReturn($image); $image->shouldReceive('getBase64')->once()->with(1234)->andReturn(imageFixture()); - expect((new ImageCapture($browser, $options, '#invoice'))->capture($page))->toBeInstanceOf(ImageResult::class); + $capture = new ImageCapture(new CaptureOptions($browser, $options, '#invoice')); + + expect($capture->capture($page))->toBeInstanceOf(ImageResult::class); })->with(['full page' => true, 'viewport' => false]); diff --git a/tests/Unit/GotenbergDriverTest.php b/tests/Unit/GotenbergDriverTest.php index 3798663..6d2e129 100644 --- a/tests/Unit/GotenbergDriverTest.php +++ b/tests/Unit/GotenbergDriverTest.php @@ -10,28 +10,26 @@ use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; -beforeEach(function () { - $this->history = []; - $this->mock = new MockHandler; - $stack = HandlerStack::create($this->mock); +test('Gotenberg sends HTML as an in-memory multipart file and preserves PDF layout', function () { + $history = []; + $mock = new MockHandler([ + new Response(200, [], file_get_contents(__DIR__.'/../../resources/blank.pdf')), + ]); + $stack = HandlerStack::create($mock); - $stack->push(Middleware::history($this->history)); + $stack->push(Middleware::history($history)); - $this->factory = new Screenshot( + $factory = new Screenshot( new GotenbergDriver( url: 'https://gotenberg.example.test/api/', - httpOptions: [ + http: new Client([ 'handler' => $stack, 'auth' => ['user', 'password'], - ], + ]), ), ); -}); - -test('Gotenberg sends HTML as an in-memory multipart file and preserves PDF layout', function () { - $this->mock->append(new Response(200, [], file_get_contents(__DIR__.'/../../resources/blank.pdf'))); - $result = $this->factory->html('

Invoice

') + $result = $factory->html('

Invoice

') ->javascript() ->pdf() ->paperSize(100, 200) @@ -43,7 +41,7 @@ expect($result->mimeType())->toBe('application/pdf'); - $request = $this->history[0]['request']; + $request = $history[0]['request']; $body = (string) $request->getBody(); expect($request->getMethod())->toBe('POST'); @@ -70,9 +68,19 @@ }); test('Gotenberg selects the URL screenshot route with the original viewport', function () { - $this->mock->append(new Response(200, [], base64_decode(imageFixture()))); + $history = []; + $mock = new MockHandler([ + new Response(200, [], base64_decode(imageFixture())), + ]); + $stack = HandlerStack::create($mock); - $result = $this->factory->url('https://example.com/report') + $stack->push(Middleware::history($history)); + + $factory = new Screenshot( + new GotenbergDriver(http: new Client(['handler' => $stack])), + ); + + $result = $factory->url('https://example.com/report') ->javascript() ->size(640, 480) ->png() @@ -82,80 +90,132 @@ expect($result->mimeType())->toBe('image/png'); expect($result->width())->toBe(1); - expect((string) $this->history[0]['request']->getUri())->toEndWith('/forms/chromium/screenshot/url'); + expect((string) $history[0]['request']->getUri())->toEndWith('/forms/chromium/screenshot/url'); - $body = (string) $this->history[0]['request']->getBody(); + $body = (string) $history[0]['request']->getBody(); expect($body)->toContain('https://example.com/report', 'name="width"', "\r\n640\r\n", "\r\n480\r\n", "\r\ntrue\r\n"); expect($body)->not->toContain('filename=', 'name="quality"', 'name="selector"'); }); test('Gotenberg selects the URL PDF route', function () { - $this->mock->append(new Response(200, [], file_get_contents(__DIR__.'/../../resources/blank.pdf'))); + $history = []; + $mock = new MockHandler([ + new Response(200, [], file_get_contents(__DIR__.'/../../resources/blank.pdf')), + ]); + $stack = HandlerStack::create($mock); + + $stack->push(Middleware::history($history)); - $this->factory->url('https://example.com/report') + $factory = new Screenshot( + new GotenbergDriver(http: new Client(['handler' => $stack])), + ); + + $factory->url('https://example.com/report') ->javascript() ->pdf() ->capture(); - expect((string) $this->history[0]['request']->getUri())->toEndWith('/forms/chromium/convert/url'); + expect((string) $history[0]['request']->getUri())->toEndWith('/forms/chromium/convert/url'); }); test('Gotenberg forwards image selectors and rejects unexpected output formats', function () { - $this->mock->append(new Response(200, [], base64_decode(imageFixture()))); + $history = []; + $mock = new MockHandler([ + new Response(200, [], base64_decode(imageFixture())), + ]); + $stack = HandlerStack::create($mock); + + $stack->push(Middleware::history($history)); + + $factory = new Screenshot( + new GotenbergDriver(http: new Client(['handler' => $stack])), + ); expect( - fn () => $this->factory->html('
Card
') + fn () => $factory->html('
Card
') ->javascript() ->element('#card') ->jpeg(73) ->capture(), )->toThrow(RuntimeException::class, 'unexpected image format'); - $body = (string) $this->history[0]['request']->getBody(); + $body = (string) $history[0]['request']->getBody(); expect($body)->toContain('name="selector"', "\r\n#card\r\n", "\r\njpeg\r\n", "\r\n73\r\n"); }); test('Gotenberg uses the browser timeout for the entire request', function () { - $this->mock->append(new Response(200, [], base64_decode(imageFixture()))); + $history = []; + $mock = new MockHandler([ + new Response(200, [], base64_decode(imageFixture())), + ]); + $stack = HandlerStack::create($mock); + + $stack->push(Middleware::history($history)); - $this->factory->html('content') + $factory = new Screenshot( + new GotenbergDriver(http: new Client(['handler' => $stack])), + ); + + $factory->html('content') ->javascript() ->timeouts(browser: 1250) ->capture(); - expect($this->history[0]['options']['timeout'])->toBe(1.25); - expect($this->history[0]['options']['http_errors'])->toBeFalse(); - expect($this->history[0]['options']['allow_redirects'])->toBeFalse(); + expect($history[0]['options']['timeout'])->toBe(1.25); + expect($history[0]['options']['http_errors'])->toBeFalse(); + expect($history[0]['options']['allow_redirects'])->toBeFalse(); }); test('Gotenberg reports HTTP errors and the remote trace', function (int $status) { - $this->mock->append(new Response($status, ['Gotenberg-Trace' => 'trace-id'], 'conversion failed')); + $mock = new MockHandler([ + new Response($status, ['Gotenberg-Trace' => 'trace-id'], 'conversion failed'), + ]); + + $factory = new Screenshot( + new GotenbergDriver(http: new Client(['handler' => $mock])), + ); expect( - fn () => $this->factory->html('content') + fn () => $factory->html('content') ->javascript() ->capture(), )->toThrow(RuntimeException::class, "Gotenberg capture failed (HTTP {$status}, trace: trace-id): conversion failed"); })->with([302, 400, 401, 503]); test('Gotenberg preserves connection and timeout errors', function () { - $this->mock->append(new ConnectException( - 'Connection timed out', - new Request('POST', 'https://gotenberg.example.test'), - )); + $mock = new MockHandler([ + new ConnectException( + 'Connection timed out', + new Request('POST', 'https://gotenberg.example.test'), + ), + ]); + + $factory = new Screenshot( + new GotenbergDriver(http: new Client(['handler' => $mock])), + ); expect( - fn () => $this->factory->html('content') + fn () => $factory->html('content') ->javascript() ->capture(), )->toThrow(ConnectException::class, 'Connection timed out'); }); test('Gotenberg rejects unsupported settings before sending requests', function (Closure $capture, string $message) { - expect(fn () => $capture($this->factory))->toThrow(InvalidArgumentException::class, $message); - expect($this->history)->toBe([]); + $history = []; + $mock = new MockHandler; + $stack = HandlerStack::create($mock); + + $stack->push(Middleware::history($history)); + + $factory = new Screenshot( + new GotenbergDriver(http: new Client(['handler' => $stack])), + ); + + expect(fn () => $capture($factory))->toThrow(InvalidArgumentException::class, $message); + expect($history)->toBe([]); })->with([ 'JavaScript mismatch' => [fn ($factory) => $factory->html('content')->capture(), 'JavaScript setting must match'], 'sockets' => [ @@ -176,10 +236,13 @@ ]); test('Gotenberg supports services configured with JavaScript disabled', function () { - $this->mock->append(new Response(200, [], base64_decode(imageFixture()))); + $mock = new MockHandler([ + new Response(200, [], base64_decode(imageFixture())), + ]); + $driver = new GotenbergDriver( javascript: false, - http: new Client(['handler' => $this->mock]), + http: new Client(['handler' => $mock]), ); $factory = new Screenshot($driver); @@ -187,8 +250,15 @@ }); test('Gotenberg validates successful response bodies', function (string $body, bool $pdf) { - $this->mock->append(new Response(200, [], $body)); - $source = $this->factory->html('content')->javascript(); + $mock = new MockHandler([ + new Response(200, [], $body), + ]); + + $factory = new Screenshot( + new GotenbergDriver(http: new Client(['handler' => $mock])), + ); + + $source = $factory->html('content')->javascript(); expect(fn () => ($pdf ? $source->pdf() : $source->png())->capture()) ->toThrow(InvalidArgumentException::class); From e911e9569abdb8ffd7d11b757ff56e14509c74a8 Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Fri, 16 Oct 2026 09:33:01 -0400 Subject: [PATCH 3/3] Run Chrome and Gotenberg tests together --- .github/workflows/tests.yml | 61 ++++++++++++--------------------- composer.json | 3 +- tests/Browser/GotenbergTest.php | 2 +- 3 files changed, 24 insertions(+), 42 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 95d4bd1..a3b1eeb 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -65,43 +65,6 @@ jobs: - run: composer install --prefer-dist --no-interaction - run: composer audit - gotenberg: - name: Gotenberg - runs-on: ubuntu-latest - timeout-minutes: 10 - - services: - gotenberg: - image: gotenberg/gotenberg:8.38.1 - ports: - - 3000:3000 - env: - API_ENABLE_BASIC_AUTH: 'true' - GOTENBERG_API_BASIC_AUTH_USERNAME: screenshot - GOTENBERG_API_BASIC_AUTH_PASSWORD: screenshot-test - gotenberg-nojs: - image: gotenberg/gotenberg:8.38.1 - ports: - - 3001:3000 - env: - CHROMIUM_DISABLE_JAVASCRIPT: 'true' - - steps: - - uses: actions/checkout@v6 - - uses: shivammathur/setup-php@v2 - with: - php-version: '8.4' - extensions: mbstring, dom, fileinfo, pdo_sqlite, sockets, zip, gd - coverage: none - - run: composer install --prefer-dist --no-interaction - - name: Execute Gotenberg integration tests - env: - SCREENSHOT_GOTENBERG_URL: http://localhost:3000 - SCREENSHOT_GOTENBERG_NOJS_URL: http://localhost:3001 - SCREENSHOT_GOTENBERG_USERNAME: screenshot - SCREENSHOT_GOTENBERG_PASSWORD: screenshot-test - run: composer test-gotenberg - installer: name: Chrome installer / ${{ matrix.os }} runs-on: ${{ matrix.os }} @@ -126,16 +89,32 @@ jobs: run: vendor/bin/pest tests/Browser/InstallTest.php browser: - name: Chromium + name: Chrome / Gotenberg runs-on: ubuntu-latest timeout-minutes: 10 + services: + gotenberg: + image: gotenberg/gotenberg:8.38.1 + ports: + - 3000:3000 + env: + API_ENABLE_BASIC_AUTH: 'true' + GOTENBERG_API_BASIC_AUTH_USERNAME: screenshot + GOTENBERG_API_BASIC_AUTH_PASSWORD: screenshot-test + gotenberg-nojs: + image: gotenberg/gotenberg:8.38.1 + ports: + - 3001:3000 + env: + CHROMIUM_DISABLE_JAVASCRIPT: 'true' + steps: - uses: actions/checkout@v6 - uses: shivammathur/setup-php@v2 with: php-version: '8.4' - extensions: mbstring, dom, fileinfo, pdo_sqlite, sockets, zip + extensions: mbstring, dom, fileinfo, pdo_sqlite, sockets, zip, gd coverage: none - run: composer install --prefer-dist --no-interaction - name: Execute browser tests @@ -143,4 +122,8 @@ jobs: CHROME_PATH: /usr/bin/google-chrome SCREENSHOT_NO_SANDBOX: '1' SCREENSHOT_TEST_INSTALL: '1' + SCREENSHOT_GOTENBERG_URL: http://localhost:3000 + SCREENSHOT_GOTENBERG_NOJS_URL: http://localhost:3001 + SCREENSHOT_GOTENBERG_USERNAME: screenshot + SCREENSHOT_GOTENBERG_PASSWORD: screenshot-test run: composer test-browser diff --git a/composer.json b/composer.json index cfb0410..6ed83c4 100644 --- a/composer.json +++ b/composer.json @@ -56,9 +56,8 @@ } }, "scripts": { - "test": "vendor/bin/pest --exclude-group=browser --exclude-group=gotenberg", + "test": "vendor/bin/pest --exclude-group=browser", "test-browser": "vendor/bin/pest --group=browser", - "test-gotenberg": "vendor/bin/pest --group=gotenberg", "format": "vendor/bin/pint", "lint": "vendor/bin/pint --test" }, diff --git a/tests/Browser/GotenbergTest.php b/tests/Browser/GotenbergTest.php index 635755d..423e97f 100644 --- a/tests/Browser/GotenbergTest.php +++ b/tests/Browser/GotenbergTest.php @@ -4,7 +4,7 @@ use DirectoryTree\Screenshot\Screenshot; use GuzzleHttp\Client; -uses()->group('gotenberg'); +uses()->group('browser'); function gotenbergScreenshot(bool $authenticate = true): Screenshot {