diff --git a/src/Api/Concerns/InteractsWithDownloads.php b/src/Api/Concerns/InteractsWithDownloads.php new file mode 100644 index 00000000..7acf62ea --- /dev/null +++ b/src/Api/Concerns/InteractsWithDownloads.php @@ -0,0 +1,43 @@ +page->pendingDownload(); + + $callback($this); + + return $download; + } + + /** + * Executes a callback and captures all downloads it triggers. + * + * @param callable(self): void $callback + * @return array + */ + public function expectDownloads(callable $callback, ?int $count = null): array + { + $collector = $this->page->downloadCollector(); + + try { + $callback($this); + + return $collector->all($count); + } finally { + $collector->stop(); + } + } +} diff --git a/src/Api/Download.php b/src/Api/Download.php new file mode 100644 index 00000000..e158263e --- /dev/null +++ b/src/Api/Download.php @@ -0,0 +1,224 @@ +url = $url; + $this->suggestedFilename = $suggestedFilename; + $this->artifactGuid = $artifactGuid; + } + + /** + * Returns the download URL. + */ + public function url(): string + { + $this->ensureResolved(); + + return (string) $this->url; + } + + /** + * Returns the suggested filename for the download. + */ + public function suggestedFilename(): string + { + $this->ensureResolved(); + + return (string) $this->suggestedFilename; + } + + /** + * Saves the download to the given path. + */ + public function saveAs(string $path): self + { + $this->ensureResolved(); + + iterator_to_array($this->artifact('saveAs', ['path' => $path])); + + return $this; + } + + /** + * Returns the path where the download was saved. + */ + public function path(): string + { + return $this->artifactValue('pathAfterFinished') ?? ''; + } + + /** + * Returns the contents of the downloaded file. + */ + public function contents(): string + { + return (string) file_get_contents($this->path()); + } + + /** + * Returns the failure message, or null if successful. + */ + public function failure(): ?string + { + return $this->artifactValue('failure'); + } + + /** + * Checks if the download was successful. + */ + public function isSuccessful(): bool + { + return $this->failure() === null; + } + + /** + * Assert the download has the expected filename. + */ + public function assertFilename(string $expected): self + { + expect($this->suggestedFilename())->toBe($expected); + + return $this; + } + + /** + * Assert the download filename contains the expected string. + */ + public function assertFilenameContains(string $expected): self + { + expect($this->suggestedFilename())->toContain($expected); + + return $this; + } + + /** + * Assert the download URL contains the expected string. + */ + public function assertUrlContains(string $expected): self + { + expect($this->url())->toContain($expected); + + return $this; + } + + /** + * Assert the download content contains the expected string. + */ + public function assertContentContains(string $expected): self + { + expect($this->contents())->toContain($expected); + + return $this; + } + + /** + * Assert the download was successful. + */ + public function assertSuccessful(): self + { + expect($this->isSuccessful())->toBeTrue(); + + return $this; + } + + /** + * Assert the download failed. + */ + public function assertFailed(): self + { + expect($this->isSuccessful())->toBeFalse(); + + return $this; + } + + /** + * Executes a method on the download artifact and extracts the result value. + */ + private function artifactValue(string $method): ?string + { + $this->ensureResolved(); + + foreach ($this->artifact($method) as $message) { + $result = $message['result'] ?? []; + $value = is_array($result) ? ($result['value'] ?? null) : null; + + if (is_string($value) || $value === null) { + return $value; + } + } + + return null; + } + + /** + * Executes a method on the download artifact. + * + * @param array $params + * @return Generator> + */ + private function artifact(string $method, array $params = []): Generator + { + assert($this->artifactGuid !== null); + + return Client::instance()->execute($this->artifactGuid, $method, $params); + } + + /** + * Ensures the download has been resolved. + */ + private function ensureResolved(): void + { + if ($this->artifactGuid !== null) { + return; + } + + Execution::instance()->waitForExpectation(function (): void { + if ($this->artifactGuid === null) { + throw BrowserExpectationFailedException::from( + $this->page, + new ExpectationFailedException('No download started'), + ); + } + }); + } +} diff --git a/src/Api/DownloadCollector.php b/src/Api/DownloadCollector.php new file mode 100644 index 00000000..dda0f610 --- /dev/null +++ b/src/Api/DownloadCollector.php @@ -0,0 +1,84 @@ + + */ + private array $downloads = []; + + public function __construct( + private readonly Page $page, + private readonly string $pageGuid, + ) { + Client::instance()->startCollectingDownloads($this->pageGuid, $this); + } + + /** + * Adds a download to the collection. + * + * @internal This method is called by the Client when a download event is received. + */ + public function add(string $url, string $suggestedFilename, string $artifactGuid): void + { + $download = new Download($this->page); + $download->resolve($url, $suggestedFilename, $artifactGuid); + $this->downloads[] = $download; + } + + /** + * Returns the collected downloads, optionally waiting for an expected count. + * + * @return array + */ + public function all(?int $count = null): array + { + if ($count !== null) { + $this->waitForCount($count); + } + + return $this->downloads; + } + + /** + * Stops collecting and cleans up. + */ + public function stop(): void + { + Client::instance()->stopCollectingDownloads($this->pageGuid); + } + + /** + * Waits until the expected number of downloads have been collected. + */ + private function waitForCount(int $count): void + { + Execution::instance()->waitForExpectation(function () use ($count): void { + $actual = count($this->downloads); + + if ($actual !== $count) { + throw BrowserExpectationFailedException::from( + $this->page, + new ExpectationFailedException( + sprintf('Expected %d downloads, but %d received', $count, $actual) + ), + ); + } + }); + } +} diff --git a/src/Api/Webpage.php b/src/Api/Webpage.php index d42876a7..488bad19 100644 --- a/src/Api/Webpage.php +++ b/src/Api/Webpage.php @@ -12,6 +12,7 @@ final readonly class Webpage { use Concerns\HasWaitCapabilities, + Concerns\InteractsWithDownloads, Concerns\InteractsWithElements, Concerns\InteractsWithFrames, Concerns\InteractsWithScreen, diff --git a/src/Playwright/Client.php b/src/Playwright/Client.php index 876e7af3..e655acf3 100644 --- a/src/Playwright/Client.php +++ b/src/Playwright/Client.php @@ -6,6 +6,8 @@ use Amp\Websocket\Client\WebsocketConnection; use Generator; +use Pest\Browser\Api\Download; +use Pest\Browser\Api\DownloadCollector; use Pest\Browser\Exceptions\PlaywrightOutdatedException; use PHPUnit\Framework\ExpectationFailedException; @@ -31,6 +33,20 @@ final class Client */ private int $timeout = 5_000; + /** + * Pending downloads awaiting resolution, keyed by page GUID. + * + * @var array + */ + private array $pendingDownloads = []; + + /** + * Download collectors for capturing multiple downloads, keyed by page GUID. + * + * @var array + */ + private array $downloadCollectors = []; + /** * Returns the current client instance. */ @@ -87,26 +103,17 @@ public function execute(string $guid, string $method, array $params = [], array $this->websocketConnection->sendText($requestJson); while (true) { - $responseJson = $this->fetch($this->websocketConnection); - /** @var array{id: string|null, params: array{add: string|null}, error: array{error: array{message: string|null}}} $response */ - $response = json_decode($responseJson, true); + $responseJson = $this->fetch($this->connection()); - if (isset($response['error']['error']['message'])) { - $message = $response['error']['error']['message']; + /** @var array{id?: string, method?: string, guid?: string, params?: array, result?: array, error?: array{error?: array{message?: string}}} $response */ + $response = (array) json_decode($responseJson, true); - if (str_contains($message, 'Playwright was just installed or updated')) { - throw new PlaywrightOutdatedException(); - } - - throw new ExpectationFailedException($message); - } + $this->handleError($response); + $this->handleDownload($response); yield $response; - if ( - (isset($response['id']) && $response['id'] === $requestId) - || (isset($params['waitUntil']) && isset($response['params']['add']) && $params['waitUntil'] === $response['params']['add']) - ) { + if ($this->isResponseComplete($response, $requestId, $params)) { break; } } @@ -128,6 +135,109 @@ public function timeout(): int return $this->timeout; } + /** + * Registers a pending download for the given page. + */ + public function expectDownload(string $pageGuid, Download $download): void + { + $this->pendingDownloads[$pageGuid] = $download; + } + + /** + * Starts collecting downloads for the given page. + */ + public function startCollectingDownloads(string $pageGuid, DownloadCollector $collector): void + { + $this->downloadCollectors[$pageGuid] = $collector; + } + + /** + * Stops collecting downloads for the given page. + */ + public function stopCollectingDownloads(string $pageGuid): void + { + unset($this->downloadCollectors[$pageGuid]); + } + + /** + * Handles error responses from Playwright. + * + * @param array $response + */ + private function handleError(array $response): void + { + $error = $response['error'] ?? null; + $errorInner = is_array($error) ? ($error['error'] ?? null) : null; + $errorMessage = is_array($errorInner) ? ($errorInner['message'] ?? null) : null; + + if (! is_string($errorMessage)) { + return; + } + + if (str_contains($errorMessage, 'Playwright was just installed or updated')) { + throw new PlaywrightOutdatedException(); + } + + throw new ExpectationFailedException($errorMessage); + } + + /** + * Handles download events from Playwright. + * + * @param array $response + */ + private function handleDownload(array $response): void + { + $event = DownloadEvent::fromResponse($response); + + if (! $event instanceof DownloadEvent) { + return; + } + + $collector = $this->downloadCollectors[$event->pageGuid] ?? null; + + if ($collector !== null) { + $collector->add($event->url, $event->suggestedFilename, $event->artifactGuid); + + return; + } + + $download = $this->pendingDownloads[$event->pageGuid] ?? null; + + if ($download !== null) { + $download->resolve($event->url, $event->suggestedFilename, $event->artifactGuid); + unset($this->pendingDownloads[$event->pageGuid]); + } + } + + /** + * Determines if the response completes the current request. + * + * @param array $response + * @param array $params + */ + private function isResponseComplete(array $response, string $requestId, array $params): bool + { + if (isset($response['id']) && $response['id'] === $requestId) { + return true; + } + + $responseParams = $response['params'] ?? null; + $responseParamsAdd = is_array($responseParams) ? ($responseParams['add'] ?? null) : null; + + return isset($params['waitUntil']) && $params['waitUntil'] === $responseParamsAdd; + } + + /** + * Returns the active WebSocket connection. + */ + private function connection(): WebsocketConnection + { + assert($this->websocketConnection instanceof WebsocketConnection); + + return $this->websocketConnection; + } + /** * Fetches the response from the Playwright server. */ diff --git a/src/Playwright/DownloadEvent.php b/src/Playwright/DownloadEvent.php new file mode 100644 index 00000000..527c2f46 --- /dev/null +++ b/src/Playwright/DownloadEvent.php @@ -0,0 +1,50 @@ + $response + */ + public static function fromResponse(array $response): ?self + { + if (($response['method'] ?? null) !== 'download') { + return null; + } + + $pageGuid = $response['guid'] ?? null; + $params = $response['params'] ?? []; + $params = is_array($params) ? $params : []; + $artifact = $params['artifact'] ?? []; + $artifact = is_array($artifact) ? $artifact : []; + + if (! is_string($pageGuid) + || ! is_string($params['url'] ?? null) + || ! is_string($params['suggestedFilename'] ?? null) + || ! is_string($artifact['guid'] ?? null)) { + return null; + } + + return new self( + $pageGuid, + $params['url'], + $params['suggestedFilename'], + $artifact['guid'], + ); + } +} diff --git a/src/Playwright/Page.php b/src/Playwright/Page.php index d97f3db3..c0d8c1d7 100644 --- a/src/Playwright/Page.php +++ b/src/Playwright/Page.php @@ -5,6 +5,8 @@ namespace Pest\Browser\Playwright; use Generator; +use Pest\Browser\Api\Download; +use Pest\Browser\Api\DownloadCollector; use Pest\Browser\Execution; use Pest\Browser\Support\ImageDiffView; use Pest\Browser\Support\JavaScriptSerializer; @@ -566,6 +568,26 @@ public function expectScreenshot(bool $fullPage, bool $openDiff): void } } + /** + * Creates a download that will capture the next download event. + */ + public function pendingDownload(): Download + { + $download = new Download($this); + + Client::instance()->expectDownload($this->guid, $download); + + return $download; + } + + /** + * Creates a download collector for this page. + */ + public function downloadCollector(): DownloadCollector + { + return new DownloadCollector($this, $this->guid); + } + /** * Closes the page. */ diff --git a/tests/Browser/Webpage/DownloadTest.php b/tests/Browser/Webpage/DownloadTest.php new file mode 100644 index 00000000..f75d34fa --- /dev/null +++ b/tests/Browser/Webpage/DownloadTest.php @@ -0,0 +1,231 @@ +tempFiles = []; +}); + +afterEach(function (): void { + collect($this->tempFiles)->each(fn ($path): bool => @unlink($path)); +}); + +it('captures a download with fluent assertions', function (): void { + Route::get('/', fn (): string => 'Download'); + Route::get('/file', fn (): Response => downloadResponse('Hello!', 'report.pdf')); + + visit('/') + ->expectDownload(fn ($page): Webpage => $page->click('a[download]')) + ->assertFilename('report.pdf') + ->assertUrlContains('/file') + ->assertSuccessful(); +}); + +it('saves a download to disk', function (): void { + Route::get('/', fn (): string => 'Download'); + Route::get('/file', fn (): Response => downloadResponse('File contents here', 'data.txt')); + + $path = tempPath($this, '.txt'); + + visit('/') + ->expectDownload(fn ($page): Webpage => $page->click('a[download]')) + ->saveAs($path) + ->assertSuccessful(); + + expect(file_get_contents($path))->toBe('File contents here'); +}); + +it('retrieves the temporary download path', function (): void { + Route::get('/', fn (): string => 'Download'); + Route::get('/file', fn (): Response => downloadResponse('content', 'temp.bin')); + + $download = visit('/') + ->expectDownload(fn ($page): Webpage => $page->click('a[download]')); + + expect(file_exists($download->path()))->toBeTrue(); +}); + +it('handles JavaScript-triggered downloads', function (): void { + Route::get('/', fn (): string => ' + + + '); + Route::get('/file', fn (): Response => downloadResponse('JS Download!', 'dynamic.txt')); + + visit('/') + ->expectDownload(fn ($page): Webpage => $page->click('#btn')) + ->assertFilename('dynamic.txt') + ->assertSuccessful(); +}); + +it('handles binary file downloads', function (): void { + Route::get('/', fn (): string => 'Download'); + Route::get('/image', function () { + $png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='); + + return response($png) + ->header('Content-Type', 'image/png') + ->header('Content-Disposition', 'attachment; filename="pixel.png"'); + }); + + $path = tempPath($this, '.png'); + + visit('/') + ->expectDownload(fn ($page): Webpage => $page->click('a[download]')) + ->assertFilename('pixel.png') + ->saveAs($path); + + expect(filesize($path))->toBeGreaterThan(0); +}); + +it('reads download contents directly', function (): void { + Route::get('/', fn (): string => 'Download'); + Route::get('/file', fn (): Response => downloadResponse('Hello, World!', 'data.txt')); + + $download = visit('/') + ->expectDownload(fn ($page): Webpage => $page->click('a[download]')); + + expect($download->contents())->toBe('Hello, World!'); +}); + +it('asserts filename contains', function (): void { + Route::get('/', fn (): string => 'Download'); + Route::get('/file', fn (): Response => downloadResponse('PDF content', 'invoice-2024-001.pdf')); + + visit('/') + ->expectDownload(fn ($page): Webpage => $page->click('a[download]')) + ->assertFilenameContains('invoice') + ->assertFilenameContains('2024'); +}); + +it('asserts content contains', function (): void { + Route::get('/', fn (): string => 'Download'); + Route::get('/file', fn (): Response => downloadResponse("Name,Email\nJohn,john@example.com", 'report.csv')); + + visit('/') + ->expectDownload(fn ($page): Webpage => $page->click('a[download]')) + ->assertContentContains('John') + ->assertContentContains('john@example.com'); +}); + +it('chains assertions fluidly', function (): void { + Route::get('/', fn (): string => 'Download'); + Route::get('/file', fn (): Response => downloadResponse('Fluent!', 'chain.txt')); + + $path = tempPath($this, '.txt'); + + visit('/') + ->expectDownload(fn ($page): Webpage => $page->click('a[download]')) + ->assertSuccessful() + ->assertFilename('chain.txt') + ->assertUrlContains('/file') + ->saveAs($path) + ->assertSuccessful(); +}); + +it('captures multiple downloads', function (): void { + Route::get('/', fn (): string => ' + + + '); + Route::get('/file/{name}', fn (string $name): Response => downloadResponse("Content: {$name}", $name)); + + $downloads = visit('/')->expectDownloads( + fn ($page): Webpage => $page->click('#all'), + count: 3 + ); + + expect($downloads)->toHaveCount(3); + + foreach ($downloads as $download) { + $download->assertSuccessful(); + } + + $filenames = array_map(fn (Download $d): string => $d->suggestedFilename(), $downloads); + sort($filenames); + + expect($filenames)->toBe(['a.txt', 'b.txt', 'c.txt']); +}); + +it('fails when more downloads than expected', function (): void { + Route::get('/', fn (): string => ' + + + '); + Route::get('/file/{name}', fn (string $name): Response => downloadResponse("Content: {$name}", $name)); + + visit('/')->expectDownloads( + fn ($page): Webpage => $page->click('#all'), + count: 2 + ); +})->throws(ExpectationFailedException::class, 'Expected 2 downloads, but 3 received'); + +it('saves multiple downloads', function (): void { + Route::get('/', fn (): string => ' + + + '); + Route::get('/file/{name}', fn (string $name): Response => downloadResponse("File: {$name}", $name)); + + $downloads = visit('/')->expectDownloads( + fn ($page): Webpage => $page->click('#all'), + count: 2 + ); + + foreach ($downloads as $download) { + $path = tempPath($this, '.txt'); + $download->saveAs($path); + expect(file_exists($path))->toBeTrue(); + } +}); + +// Helpers + +function downloadResponse(string $content, string $filename): Response +{ + return response($content) + ->header('Content-Type', 'application/octet-stream') + ->header('Content-Disposition', "attachment; filename=\"{$filename}\""); +} + +function tempPath(object $test, string $extension): string +{ + $path = sys_get_temp_dir().'/pest-download-'.uniqid().$extension; + $test->tempFiles[] = $path; + + return $path; +}