diff --git a/bin/lib/handlers.js b/bin/lib/handlers.js index 2c1ea6d..13ebdd2 100644 --- a/bin/lib/handlers.js +++ b/bin/lib/handlers.js @@ -40,6 +40,7 @@ class ContextHandler extends BaseHandler { cookies: async () => ({ cookies: await context.cookies(command.urls) }), storageState: async () => ({ storageState: await context.storageState(command.options) }), clipboardText: () => this.getClipboardText(context), + close: () => this.closeContext(context, command.contextId), newPage: () => this.createNewPage(context, command) }); @@ -77,6 +78,35 @@ class ContextHandler extends BaseHandler { return { pageId }; } + async closeContext(context, contextId) { + try { + await context.close(); + } catch (error) { + logger.error('Failed to close context', { contextId, error: error?.message }); + throw error; + } finally { + this.cleanupContextResources(contextId); + } + } + + cleanupContextResources(contextId) { + this.contexts.delete(contextId); + this.contextThrottling?.delete?.(contextId); + + for (const [pageId, mappedContextId] of this.pageContexts.entries()) { + if (mappedContextId === contextId) { + this.pageContexts.delete(pageId); + this.pages.delete(pageId); + } + } + + for (const [routeId, info] of this.routes.entries()) { + if (info?.contextId === contextId) { + this.routes.delete(routeId); + } + } + } + async waitForPopup(context, command) { const timeout = command.timeout || 30000; const requestId = command.requestId || this.generateId('popup_req'); @@ -155,6 +185,7 @@ class PageHandler extends BaseHandler { waitForURL: () => page.waitForURL(command.url, command.options), waitForSelector: () => page.waitForSelector(command.selector, command.options), screenshot: () => PromiseUtils.wrapBinary(page.screenshot(command.options)), + pdf: () => PromiseUtils.wrapBinary(page.pdf(command.options || {})), evaluateHandle: () => this.evaluateHandle(page, command), addScriptTag: () => page.addScriptTag(command.options), addStyleTag: () => page.addStyleTag(command.options).then(() => ({ success: true })), diff --git a/bin/playwright-server.js b/bin/playwright-server.js index 36d40aa..cccc6a7 100644 --- a/bin/playwright-server.js +++ b/bin/playwright-server.js @@ -30,7 +30,8 @@ class PlaywrightServer extends BaseHandler { pageContexts: this.pageContexts, dialogs: this.dialogs, elementHandles: this.elementHandles, responses: this.responses, routes: this.routes, generateId: this.generateId.bind(this), extractRequestData: this.extractRequestData.bind(this), serializeResponse: this.serializeResponse.bind(this), - sendFramedResponse, routeCounter: { value: this.counters.route }, + sendFramedResponse, + routeCounter: { value: this.counters.route }, setupPageEventListeners: this.setupPageEventListeners.bind(this) }; this.contextHandler = new ContextHandler(deps); diff --git a/docs/examples/pdf.php b/docs/examples/pdf.php new file mode 100644 index 0000000..747b77a --- /dev/null +++ b/docs/examples/pdf.php @@ -0,0 +1,32 @@ +newPage(); + +$page->goto('https://example.com'); + +$pdfPath = __DIR__.'/example.pdf'; +$page->pdf($pdfPath, ['format' => 'A4']); +echo 'PDF saved to: '.$pdfPath."\n"; + +$pdfBytes = $page->pdfContent(); +echo 'Inline PDF bytes: '.strlen($pdfBytes)."\n"; + +$page->close(); +$browser->close(); diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index b2cb9e0..0ffcc89 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -70,6 +70,13 @@ echo $page->title() . PHP_EOL; // Outputs: "Example Domain" // Take a screenshot and save it as 'screenshot.png'. $page->screenshot('screenshot.png'); +// Export the page to PDF on disk. +$page->pdf('invoice.pdf', ['format' => 'A4']); + +// Or grab the PDF bytes directly without keeping files around. +$pdfBytes = $page->pdfContent(); +file_put_contents('inline-invoice.pdf', $pdfBytes); + // Close the browser context and all its pages. $context->close(); ``` diff --git a/src/Page/Options/PdfOptions.php b/src/Page/Options/PdfOptions.php new file mode 100644 index 0000000..a0fa30d --- /dev/null +++ b/src/Page/Options/PdfOptions.php @@ -0,0 +1,273 @@ +path = self::normalizeNullableString($path); + $this->format = self::normalizeNullableString($format); + $this->landscape = $landscape; + $this->scale = null; + if (null !== $scale) { + if ($scale < self::SCALE_MIN || $scale > self::SCALE_MAX) { + throw new RuntimeException(sprintf('PDF scale must be between %.1f and %.1f.', self::SCALE_MIN, self::SCALE_MAX)); + } + + $this->scale = round($scale, 2); + } + + $this->printBackground = $printBackground; + $this->width = self::normalizeNullableString($width); + $this->height = self::normalizeNullableString($height); + $this->margin = self::normalizeMargin($margin); + $this->displayHeaderFooter = $displayHeaderFooter; + $this->footerTemplate = self::normalizeNullableString($footerTemplate); + $this->headerTemplate = self::normalizeNullableString($headerTemplate); + $this->outline = $outline; + $this->pageRanges = self::normalizeNullableString($pageRanges); + $this->preferCSSPageSize = $preferCSSPageSize; + $this->tagged = $tagged; + } + + /** + * @param array|self $options + */ + public static function from(array|self $options): self + { + if ($options instanceof self) { + return $options; + } + + return self::fromArray($options); + } + + /** + * @param array $options + */ + public static function fromArray(array $options): self + { + $scale = null; + if (array_key_exists('scale', $options)) { + if (!is_numeric($options['scale'])) { + throw new RuntimeException('PDF option "scale" must be numeric.'); + } + + $scale = (float) $options['scale']; + } + + return new self( + path: isset($options['path']) ? (string) $options['path'] : null, + format: isset($options['format']) ? (string) $options['format'] : null, + landscape: isset($options['landscape']) ? (bool) $options['landscape'] : null, + scale: $scale, + printBackground: isset($options['printBackground']) ? (bool) $options['printBackground'] : null, + width: isset($options['width']) ? (string) $options['width'] : null, + height: isset($options['height']) ? (string) $options['height'] : null, + margin: $options['margin'] ?? null, + displayHeaderFooter: isset($options['displayHeaderFooter']) ? (bool) $options['displayHeaderFooter'] : null, + footerTemplate: isset($options['footerTemplate']) ? (string) $options['footerTemplate'] : null, + headerTemplate: isset($options['headerTemplate']) ? (string) $options['headerTemplate'] : null, + outline: isset($options['outline']) ? (bool) $options['outline'] : null, + pageRanges: isset($options['pageRanges']) ? (string) $options['pageRanges'] : null, + preferCSSPageSize: isset($options['preferCSSPageSize']) ? (bool) $options['preferCSSPageSize'] : null, + tagged: isset($options['tagged']) ? (bool) $options['tagged'] : null, + ); + } + + public function path(): ?string + { + return $this->path; + } + + public function withPath(?string $path): self + { + return new self( + path: $path, + format: $this->format, + landscape: $this->landscape, + scale: $this->scale, + printBackground: $this->printBackground, + width: $this->width, + height: $this->height, + margin: $this->margin, + displayHeaderFooter: $this->displayHeaderFooter, + footerTemplate: $this->footerTemplate, + headerTemplate: $this->headerTemplate, + outline: $this->outline, + pageRanges: $this->pageRanges, + preferCSSPageSize: $this->preferCSSPageSize, + tagged: $this->tagged, + ); + } + + /** + * @return PdfOptionsArray + */ + public function toArray(): array + { + $options = []; + + if (null !== $this->path) { + $options['path'] = $this->path; + } + if (null !== $this->format) { + $options['format'] = $this->format; + } + if (null !== $this->landscape) { + $options['landscape'] = $this->landscape; + } + if (null !== $this->scale) { + $options['scale'] = $this->scale; + } + if (null !== $this->printBackground) { + $options['printBackground'] = $this->printBackground; + } + if (null !== $this->width) { + $options['width'] = $this->width; + } + if (null !== $this->height) { + $options['height'] = $this->height; + } + if (null !== $this->margin) { + $options['margin'] = $this->margin; + } + if (null !== $this->displayHeaderFooter) { + $options['displayHeaderFooter'] = $this->displayHeaderFooter; + } + if (null !== $this->footerTemplate) { + $options['footerTemplate'] = $this->footerTemplate; + } + if (null !== $this->headerTemplate) { + $options['headerTemplate'] = $this->headerTemplate; + } + if (null !== $this->outline) { + $options['outline'] = $this->outline; + } + if (null !== $this->pageRanges) { + $options['pageRanges'] = $this->pageRanges; + } + if (null !== $this->preferCSSPageSize) { + $options['preferCSSPageSize'] = $this->preferCSSPageSize; + } + if (null !== $this->tagged) { + $options['tagged'] = $this->tagged; + } + + return $options; + } + + private static function normalizeNullableString(?string $value): ?string + { + if (null === $value) { + return null; + } + + $trimmed = trim($value); + + return '' === $trimmed ? null : $trimmed; + } + + /** + * @return PdfMargins|null + */ + private static function normalizeMargin(mixed $margin): ?array + { + if (null === $margin) { + return null; + } + + if (!is_array($margin)) { + throw new RuntimeException('PDF option "margin" must be an array of edge => size.'); + } + + $normalized = []; + foreach (['top', 'right', 'bottom', 'left'] as $edge) { + if (!array_key_exists($edge, $margin)) { + continue; + } + + $value = $margin[$edge]; + if (null === $value) { + continue; + } + + $normalizedValue = self::normalizeNullableString((string) $value); + if (null !== $normalizedValue) { + $normalized[$edge] = $normalizedValue; + } + } + + return [] === $normalized ? null : $normalized; + } +} diff --git a/src/Page/Page.php b/src/Page/Page.php index 0498e47..2d357a2 100644 --- a/src/Page/Page.php +++ b/src/Page/Page.php @@ -43,6 +43,7 @@ use Playwright\Network\Response; use Playwright\Network\ResponseInterface; use Playwright\Network\Route; +use Playwright\Page\Options\PdfOptions; use Playwright\Screenshot\ScreenshotHelper; use Playwright\Transport\TransportInterface; use Psr\Log\LoggerInterface; @@ -338,6 +339,77 @@ public function screenshotAuto(string $suffix = '', array $options = []): string return $path; } + /** + * Generate a PDF of the page. + * + * @param array|PdfOptions $options + */ + public function pdf(?string $path = null, array|PdfOptions $options = []): string + { + $options = PdfOptions::from($options); + $providedPath = $path ?? $options->path(); + $finalPath = $this->resolvePdfPath(is_string($providedPath) ? $providedPath : null); + + $options = $options->withPath($finalPath)->toArray(); + + $this->logger->debug('Generating PDF', ['path' => $finalPath, 'options' => $options]); + + try { + $this->sendCommand('pdf', ['options' => $options]); + $this->logger->info('PDF saved successfully', ['path' => $finalPath]); + } catch (\Throwable $e) { + $this->logger->error('Failed to generate PDF', [ + 'path' => $finalPath, + 'error' => $e->getMessage(), + 'exception' => $e, + ]); + + throw $e; + } + + return $finalPath; + } + + /** + * Generate a PDF and return its binary contents without persisting the file. + * + * @param array|PdfOptions $options + */ + public function pdfContent(array|PdfOptions $options = []): string + { + $options = PdfOptions::from($options); + + if (null !== $options->path()) { + throw new RuntimeException('Do not provide a "path" option when requesting inline PDF content.'); + } + + $directory = $this->getPdfDirectory(); + ScreenshotHelper::ensureDirectoryExists($directory); + + $tempPath = tempnam($directory, 'pw_pdf_'); + if (false === $tempPath) { + throw new RuntimeException('Failed to allocate a temporary PDF file.'); + } + + // Remove the placeholder so Playwright can create the file fresh. + @unlink($tempPath); + + try { + $this->pdf($tempPath, $options); + + $content = file_get_contents($tempPath); + if (false === $content) { + throw new RuntimeException('Unable to read generated PDF content.'); + } + + return $content; + } finally { + if (is_string($tempPath) && file_exists($tempPath)) { + @unlink($tempPath); + } + } + } + /** * Get the effective screenshot directory. */ @@ -350,6 +422,32 @@ private function getScreenshotDirectory(): string return rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.'playwright'; } + private function getPdfDirectory(): string + { + return $this->getScreenshotDirectory(); + } + + private function resolvePdfPath(?string $path): string + { + $candidate = null; + if (is_string($path) && '' !== trim($path)) { + $candidate = $path; + } + + if (null !== $candidate) { + $directory = dirname($candidate) ?: '.'; + ScreenshotHelper::ensureDirectoryExists($directory); + + return $candidate; + } + + return ScreenshotHelper::generateFilename( + $this->url(), + $this->getPdfDirectory(), + 'pdf' + ); + } + public function content(): ?string { $response = $this->sendCommand('content'); diff --git a/src/Page/PageInterface.php b/src/Page/PageInterface.php index e910ede..8c5292b 100644 --- a/src/Page/PageInterface.php +++ b/src/Page/PageInterface.php @@ -22,6 +22,7 @@ use Playwright\Input\MouseInterface; use Playwright\Locator\LocatorInterface; use Playwright\Network\ResponseInterface; +use Playwright\Page\Options\PdfOptions; interface PageInterface { @@ -94,6 +95,16 @@ public function type(string $selector, string $text, array $options = []): self; */ public function screenshot(?string $path = null, array $options = []): string; + /** + * @param array|PdfOptions $options + */ + public function pdf(?string $path = null, array|PdfOptions $options = []): string; + + /** + * @param array|PdfOptions $options + */ + public function pdfContent(array|PdfOptions $options = []): string; + public function content(): ?string; public function evaluate(string $expression, mixed $arg = null): mixed; diff --git a/src/Screenshot/ScreenshotHelper.php b/src/Screenshot/ScreenshotHelper.php index e079276..3fca420 100644 --- a/src/Screenshot/ScreenshotHelper.php +++ b/src/Screenshot/ScreenshotHelper.php @@ -26,18 +26,19 @@ final class ScreenshotHelper /** * Generate an auto screenshot filename based on current datetime and URL. * - * Format: YYYYMMDD_HHMMSS_mmm_url-slug.png + * Format: YYYYMMDD_HHMMSS_mmm_url-slug. * Example: 20240811_143052_123_github-com-smnandre.png */ - public static function generateFilename(string $url, string $directory): string + public static function generateFilename(string $url, string $directory, string $extension = 'png'): string { $now = microtime(true); $datetime = date('Ymd_His', (int) $now); $milliseconds = sprintf('%03d', ($now - floor($now)) * 1000); $urlSlug = self::slugifyUrl($url, 40); + $safeExtension = ltrim($extension, '.'); - $filename = sprintf('%s_%s_%s.png', $datetime, $milliseconds, $urlSlug); + $filename = sprintf('%s_%s_%s.%s', $datetime, $milliseconds, $urlSlug, $safeExtension ?: 'png'); self::ensureDirectoryExists($directory); diff --git a/tests/Integration/Page/PdfIntegrationTest.php b/tests/Integration/Page/PdfIntegrationTest.php new file mode 100644 index 0000000..d10f931 --- /dev/null +++ b/tests/Integration/Page/PdfIntegrationTest.php @@ -0,0 +1,141 @@ +pdfDir = sys_get_temp_dir().'/playwright-pdf-test-'.uniqid('', true); + mkdir($this->pdfDir, 0755, true); + + $config = new PlaywrightConfig( + screenshotDir: $this->pdfDir, + headless: true + ); + + $this->setUpPlaywright(null, $config); + $this->installRouteServer($this->page, [ + '/invoice.html' => <<<'HTML' + + + + Invoice + + + +
+

Playwright PHP

+

Invoice #PW-001

+
+
+

Summary

+ + + + + + + + + + + +
ItemQtyPrice
Browser automation consulting1$4,000
PDF implementation1$2,500
Total$6,500
+
+ + + HTML, + ]); + $this->page->goto($this->routeUrl('/invoice.html')); + } + + protected function tearDown(): void + { + $this->tearDownPlaywright(); + + $this->cleanPdfDirectory(); + } + + #[Test] + public function itGeneratesPdfToProvidedPath(): void + { + $pdfPath = $this->pdfDir.'/invoice.pdf'; + + $result = $this->page->pdf($pdfPath, ['format' => 'A4']); + + $this->assertSame($pdfPath, $result); + $this->assertFileExists($pdfPath); + $this->assertPdfSignature($pdfPath); + } + + #[Test] + public function itReturnsPdfContentWithoutLeavingArtifacts(): void + { + $content = $this->page->pdfContent(['printBackground' => true]); + + $this->assertNotEmpty($content); + $this->assertStringStartsWith('%PDF', $content); + + $this->assertDirectoryHasNoArtifacts(); + } + + private function assertPdfSignature(string $path): void + { + $data = file_get_contents($path); + $this->assertNotFalse($data); + $this->assertStringStartsWith('%PDF', $data); + $this->assertGreaterThan(200, strlen($data)); + } + + private function assertDirectoryHasNoArtifacts(): void + { + $files = array_diff(scandir($this->pdfDir) ?: [], ['.', '..']); + $this->assertEmpty($files, 'Temporary PDF artifacts should be cleaned up'); + } + + private function cleanPdfDirectory(): void + { + if (!is_dir($this->pdfDir)) { + return; + } + + foreach (array_diff(scandir($this->pdfDir) ?: [], ['.', '..']) as $file) { + @unlink($this->pdfDir.DIRECTORY_SEPARATOR.$file); + } + + @rmdir($this->pdfDir); + } +} diff --git a/tests/Unit/Page/Options/PdfOptionsTest.php b/tests/Unit/Page/Options/PdfOptionsTest.php new file mode 100644 index 0000000..c6c1589 --- /dev/null +++ b/tests/Unit/Page/Options/PdfOptionsTest.php @@ -0,0 +1,194 @@ + ' /tmp/foo.pdf ', + 'format' => "\tA4\n", + 'landscape' => 1, + 'scale' => '1.234', + 'printBackground' => 0, + 'width' => ' 100px ', + 'height' => ' 200px ', + 'margin' => [ + 'top' => ' 10px ', + 'right' => '5px', + 'bottom' => '', + 'left' => null, + ], + ]); + + $this->assertSame([ + 'path' => '/tmp/foo.pdf', + 'format' => 'A4', + 'landscape' => true, + 'scale' => 1.23, + 'printBackground' => false, + 'width' => '100px', + 'height' => '200px', + 'margin' => [ + 'top' => '10px', + 'right' => '5px', + ], + ], $options->toArray()); + } + + public function testFromReturnsSameInstance(): void + { + $options = new PdfOptions(path: '/tmp/example.pdf'); + + $this->assertSame($options, PdfOptions::from($options)); + } + + public function testFromCreatesFromArray(): void + { + $options = PdfOptions::from(['path' => '/tmp/example.pdf']); + + $this->assertSame('/tmp/example.pdf', $options->path()); + } + + public function testScaleMustBeWithinRange(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('PDF scale must be between 0.1 and 2.0.'); + + new PdfOptions(scale: 3.5); + } + + public function testScaleMustBeNumeric(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('PDF option "scale" must be numeric.'); + + PdfOptions::fromArray(['scale' => 'foo']); + } + + public function testMarginMustBeArray(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('PDF option "margin" must be an array of edge => size.'); + + PdfOptions::fromArray(['margin' => '10px']); + } + + public function testWithPathReturnsNewInstance(): void + { + $options = new PdfOptions(path: '/tmp/original.pdf'); + $updated = $options->withPath('/tmp/updated.pdf'); + + $this->assertNotSame($options, $updated); + $this->assertSame('/tmp/original.pdf', $options->path()); + $this->assertSame('/tmp/updated.pdf', $updated->path()); + } + + public function testEmptyMarginsAreDropped(): void + { + $options = PdfOptions::fromArray([ + 'margin' => [ + 'top' => ' ', + 'right' => "\n", + ], + ]); + + $this->assertArrayNotHasKey('margin', $options->toArray()); + } + + public function testAllNewParametersIncluded(): void + { + $options = PdfOptions::fromArray([ + 'path' => '/tmp/test.pdf', + 'displayHeaderFooter' => true, + 'footerTemplate' => '
Page {pageNumber}
', + 'headerTemplate' => "\t
Title
\n", + 'outline' => false, + 'pageRanges' => ' 1-5, 8, 11-13 ', + 'preferCSSPageSize' => true, + 'tagged' => false, + ]); + + $result = $options->toArray(); + + $this->assertSame('/tmp/test.pdf', $result['path']); + $this->assertTrue($result['displayHeaderFooter']); + $this->assertSame('
Page {pageNumber}
', $result['footerTemplate']); + $this->assertSame('
Title
', $result['headerTemplate']); + $this->assertFalse($result['outline']); + $this->assertSame('1-5, 8, 11-13', $result['pageRanges']); + $this->assertTrue($result['preferCSSPageSize']); + $this->assertFalse($result['tagged']); + } + + public function testNewParametersExcludedWhenNull(): void + { + $options = PdfOptions::fromArray([ + 'path' => '/tmp/test.pdf', + ]); + + $result = $options->toArray(); + + $this->assertArrayHasKey('path', $result); + $this->assertArrayNotHasKey('displayHeaderFooter', $result); + $this->assertArrayNotHasKey('footerTemplate', $result); + $this->assertArrayNotHasKey('headerTemplate', $result); + $this->assertArrayNotHasKey('outline', $result); + $this->assertArrayNotHasKey('pageRanges', $result); + $this->assertArrayNotHasKey('preferCSSPageSize', $result); + $this->assertArrayNotHasKey('tagged', $result); + } + + public function testEmptyStringParametersAreNormalized(): void + { + $options = PdfOptions::fromArray([ + 'footerTemplate' => ' ', + 'headerTemplate' => '', + 'pageRanges' => "\t\n", + ]); + + $result = $options->toArray(); + + $this->assertArrayNotHasKey('footerTemplate', $result); + $this->assertArrayNotHasKey('headerTemplate', $result); + $this->assertArrayNotHasKey('pageRanges', $result); + } + + public function testWithPathPreservesNewParameters(): void + { + $options = new PdfOptions( + path: '/tmp/original.pdf', + displayHeaderFooter: true, + outline: true, + tagged: true + ); + + $updated = $options->withPath('/tmp/updated.pdf'); + + $result = $updated->toArray(); + + $this->assertSame('/tmp/updated.pdf', $result['path']); + $this->assertTrue($result['displayHeaderFooter']); + $this->assertTrue($result['outline']); + $this->assertTrue($result['tagged']); + } +} diff --git a/tests/Unit/Page/PagePdfTest.php b/tests/Unit/Page/PagePdfTest.php new file mode 100644 index 0000000..3069079 --- /dev/null +++ b/tests/Unit/Page/PagePdfTest.php @@ -0,0 +1,103 @@ +createMock(TransportInterface::class); + $context = $this->createMock(BrowserContextInterface::class); + + $expectedPath = sys_get_temp_dir().'/playwright-pdf-unit-test.pdf'; + + $transport->expects($this->once()) + ->method('send') + ->with($this->callback(function (array $payload) use ($expectedPath) { + $this->assertSame('page.pdf', $payload['action']); + $this->assertSame('page-unit', $payload['pageId']); + $this->assertSame($expectedPath, $payload['options']['path'] ?? null); + + return true; + })) + ->willReturn([]); + + $page = new Page($transport, $context, 'page-unit'); + + $result = $page->pdf($expectedPath); + + $this->assertSame($expectedPath, $result); + } + + public function testPdfContentReturnsBinaryAndCleansUpTempFile(): void + { + $transport = $this->createMock(TransportInterface::class); + $context = $this->createMock(BrowserContextInterface::class); + + $pdfDir = sys_get_temp_dir().'/playwright-pdf-content-'.uniqid('', true); + mkdir($pdfDir, 0755, true); + + $config = new PlaywrightConfig(screenshotDir: $pdfDir); + $pdfBytes = '%PDF-1.4 mock'; + + $transport->expects($this->once()) + ->method('send') + ->willReturnCallback(function (array $payload) use ($pdfBytes): array { + $this->assertSame('page.pdf', $payload['action']); + $path = $payload['options']['path'] ?? null; + $this->assertIsString($path); + file_put_contents($path, $pdfBytes); + + return []; + }); + + $page = new Page($transport, $context, 'page-unit', $config); + + $content = $page->pdfContent(); + + $this->assertSame($pdfBytes, $content); + $this->assertDirectoryHasNoFiles($pdfDir); + + rmdir($pdfDir); + } + + public function testPdfContentRejectsPathOption(): void + { + $transport = $this->createMock(TransportInterface::class); + $context = $this->createMock(BrowserContextInterface::class); + + $page = new Page($transport, $context, 'page-unit'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Do not provide a "path" option when requesting inline PDF content.'); + + $page->pdfContent(['path' => '/tmp/should-not-be-used.pdf']); + } + + private function assertDirectoryHasNoFiles(string $directory): void + { + $files = array_diff(scandir($directory) ?: [], ['.', '..']); + $this->assertEmpty($files, sprintf('Directory %s should be empty', $directory)); + } +}