From 8414042ce5a4c7acfc458d5d00be340e936a212e Mon Sep 17 00:00:00 2001 From: Pascal Baljet Date: Sat, 10 Jan 2026 17:23:05 +0100 Subject: [PATCH 01/10] feat: add download support --- src/Api/AwaitableWebpage.php | 2 + src/Api/Concerns/InteractsWithDownloads.php | 44 ++++ src/Api/DownloadCollector.php | 91 ++++++++ src/Api/PendingDownload.php | 231 ++++++++++++++++++++ src/Api/Webpage.php | 1 + src/Playwright/Client.php | 140 ++++++++++-- src/Playwright/DownloadEvent.php | 50 +++++ src/Playwright/Page.php | 22 ++ tests/Browser/Webpage/DownloadTest.php | 225 +++++++++++++++++++ 9 files changed, 791 insertions(+), 15 deletions(-) create mode 100644 src/Api/Concerns/InteractsWithDownloads.php create mode 100644 src/Api/DownloadCollector.php create mode 100644 src/Api/PendingDownload.php create mode 100644 src/Playwright/DownloadEvent.php create mode 100644 tests/Browser/Webpage/DownloadTest.php diff --git a/src/Api/AwaitableWebpage.php b/src/Api/AwaitableWebpage.php index 71ac96b1..f3b0a0ae 100644 --- a/src/Api/AwaitableWebpage.php +++ b/src/Api/AwaitableWebpage.php @@ -28,6 +28,8 @@ public function __construct( private array $nonAwaitableMethods = [ 'assertScreenshotMatches', 'assertNoAccessibilityIssues', + 'expectDownload', + 'expectDownloads', ], ) { // diff --git a/src/Api/Concerns/InteractsWithDownloads.php b/src/Api/Concerns/InteractsWithDownloads.php new file mode 100644 index 00000000..28645257 --- /dev/null +++ b/src/Api/Concerns/InteractsWithDownloads.php @@ -0,0 +1,44 @@ +page->pendingDownload(); + + $callback($this); + + return $download; + } + + /** + * Executes a callback and captures all downloads it triggers. + * + * @param callable(self): void $callback + * @return Collection + */ + public function expectDownloads(callable $callback, ?int $count = null): Collection + { + $collector = $this->page->downloadCollector(); + + try { + $callback($this); + + return collect($collector->all($count)); + } finally { + $collector->stop(); + } + } +} diff --git a/src/Api/DownloadCollector.php b/src/Api/DownloadCollector.php new file mode 100644 index 00000000..310934e1 --- /dev/null +++ b/src/Api/DownloadCollector.php @@ -0,0 +1,91 @@ + + */ + 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 PendingDownload($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 { + if (count($this->downloads) < $count) { + throw BrowserExpectationFailedException::from( + $this->page, + new ExpectationFailedException( + sprintf('Expected %d downloads, but only %d received', $count, count($this->downloads)) + ), + ); + } + }); + + if (count($this->downloads) > $count) { + throw BrowserExpectationFailedException::from( + $this->page, + new ExpectationFailedException( + sprintf('Expected %d downloads, but %d received', $count, count($this->downloads)) + ), + ); + } + } +} diff --git a/src/Api/PendingDownload.php b/src/Api/PendingDownload.php new file mode 100644 index 00000000..2fef534d --- /dev/null +++ b/src/Api/PendingDownload.php @@ -0,0 +1,231 @@ +url = $url; + $this->suggestedFilename = $suggestedFilename; + $this->artifactGuid = $artifactGuid; + } + + /** + * Returns the download URL. + */ + public function url(): string + { + $this->wait(); + + return (string) $this->url; + } + + /** + * Returns the suggested filename for the download. + */ + public function suggestedFilename(): string + { + $this->wait(); + + return (string) $this->suggestedFilename; + } + + /** + * Saves the download to the given path. + */ + public function saveAs(string $path): self + { + $this->wait(); + + iterator_to_array($this->artifact('saveAs', ['path' => $path])); + + return $this; + } + + /** + * Returns the path where the download was saved. + */ + public function path(): string + { + $this->wait(); + + foreach ($this->artifact('pathAfterFinished') as $message) { + $result = $message['result'] ?? []; + $value = is_array($result) ? ($result['value'] ?? null) : null; + + if (is_string($value)) { + return $value; + } + } + + return ''; + } + + /** + * 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 + { + $this->wait(); + + foreach ($this->artifact('failure') as $message) { + $result = $message['result'] ?? []; + + if (is_array($result) && array_key_exists('value', $result)) { + $value = $result['value']; + + return is_string($value) ? $value : null; + } + } + + return null; + } + + /** + * 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. + * + * @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); + } + + /** + * Waits for the download to be resolved. + */ + private function wait(): 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/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..2d81105f 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\DownloadCollector; +use Pest\Browser\Api\PendingDownload; 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, PendingDownload $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 === null) { + 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..86905594 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\DownloadCollector; +use Pest\Browser\Api\PendingDownload; 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 pending download that will capture the next download event. + */ + public function pendingDownload(): PendingDownload + { + $download = new PendingDownload($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..ba5f8139 --- /dev/null +++ b/tests/Browser/Webpage/DownloadTest.php @@ -0,0 +1,225 @@ +tempFiles = []; +}); + +afterEach(function (): void { + collect($this->tempFiles)->each(fn ($path) => @unlink($path)); +}); + +it('captures a download with fluent assertions', function (): void { + Route::get('/', fn () => 'Download'); + Route::get('/file', fn () => downloadResponse('Hello!', 'report.pdf')); + + visit('/') + ->expectDownload(fn ($page) => $page->click('a[download]')) + ->assertFilename('report.pdf') + ->assertUrlContains('/file') + ->assertSuccessful(); +}); + +it('saves a download to disk', function (): void { + Route::get('/', fn () => 'Download'); + Route::get('/file', fn () => downloadResponse('File contents here', 'data.txt')); + + $path = tempPath($this, '.txt'); + + visit('/') + ->expectDownload(fn ($page) => $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 () => 'Download'); + Route::get('/file', fn () => downloadResponse('content', 'temp.bin')); + + $download = visit('/') + ->expectDownload(fn ($page) => $page->click('a[download]')); + + expect(file_exists($download->path()))->toBeTrue(); +}); + +it('handles JavaScript-triggered downloads', function (): void { + Route::get('/', fn () => ' + + + '); + Route::get('/file', fn () => downloadResponse('JS Download!', 'dynamic.txt')); + + visit('/') + ->expectDownload(fn ($page) => $page->click('#btn')) + ->assertFilename('dynamic.txt') + ->assertSuccessful(); +}); + +it('handles binary file downloads', function (): void { + Route::get('/', fn () => '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) => $page->click('a[download]')) + ->assertFilename('pixel.png') + ->saveAs($path); + + expect(filesize($path))->toBeGreaterThan(0); +}); + +it('reads download contents directly', function (): void { + Route::get('/', fn () => 'Download'); + Route::get('/file', fn () => downloadResponse('Hello, World!', 'data.txt')); + + $download = visit('/') + ->expectDownload(fn ($page) => $page->click('a[download]')); + + expect($download->contents())->toBe('Hello, World!'); +}); + +it('asserts filename contains', function (): void { + Route::get('/', fn () => 'Download'); + Route::get('/file', fn () => downloadResponse('PDF content', 'invoice-2024-001.pdf')); + + visit('/') + ->expectDownload(fn ($page) => $page->click('a[download]')) + ->assertFilenameContains('invoice') + ->assertFilenameContains('2024'); +}); + +it('asserts content contains', function (): void { + Route::get('/', fn () => 'Download'); + Route::get('/file', fn () => downloadResponse("Name,Email\nJohn,john@example.com", 'report.csv')); + + visit('/') + ->expectDownload(fn ($page) => $page->click('a[download]')) + ->assertContentContains('John') + ->assertContentContains('john@example.com'); +}); + +it('chains assertions fluidly', function (): void { + Route::get('/', fn () => 'Download'); + Route::get('/file', fn () => downloadResponse('Fluent!', 'chain.txt')); + + $path = tempPath($this, '.txt'); + + visit('/') + ->expectDownload(fn ($page) => $page->click('a[download]')) + ->assertSuccessful() + ->assertFilename('chain.txt') + ->assertUrlContains('/file') + ->saveAs($path) + ->assertSuccessful(); +}); + +it('captures multiple downloads as a collection', function (): void { + Route::get('/', fn () => ' + + + '); + Route::get('/file/{name}', fn ($name) => downloadResponse("Content: {$name}", $name)); + + $downloads = visit('/')->expectDownloads( + fn ($page) => $page->click('#all'), + count: 3 + ); + + expect($downloads)->toHaveCount(3); + + // Collection higher-order magic + $downloads->each->assertSuccessful(); + + expect($downloads->map->suggestedFilename()->sort()->values()->all()) + ->toBe(['a.txt', 'b.txt', 'c.txt']); +}); + +it('fails when more downloads than expected', function (): void { + Route::get('/', fn () => ' + + + '); + Route::get('/file/{name}', fn ($name) => downloadResponse("Content: {$name}", $name)); + + visit('/')->expectDownloads( + fn ($page) => $page->click('#all'), + count: 2 + ); +})->throws(ExpectationFailedException::class, 'Expected 2 downloads, but 3 received'); + +it('saves multiple downloads with collection methods', function (): void { + Route::get('/', fn () => ' + + + '); + Route::get('/file/{name}', fn ($name) => downloadResponse("File: {$name}", $name)); + + $test = $this; + $downloads = visit('/')->expectDownloads( + fn ($page) => $page->click('#all'), + count: 2 + ); + + $paths = $downloads->map(fn ($d) => tap(tempPath($test, '.txt'), fn ($p) => $d->saveAs($p))); + + $paths->each(fn ($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; +} From 7068e81011d5f93f65a409778d47e7c825a19a34 Mon Sep 17 00:00:00 2001 From: Pascal Baljet Date: Sun, 11 Jan 2026 19:20:22 +0100 Subject: [PATCH 02/10] Rector --- src/Playwright/Client.php | 2 +- tests/Browser/Webpage/DownloadTest.php | 77 +++++++++++++------------- 2 files changed, 40 insertions(+), 39 deletions(-) diff --git a/src/Playwright/Client.php b/src/Playwright/Client.php index 2d81105f..dde77590 100644 --- a/src/Playwright/Client.php +++ b/src/Playwright/Client.php @@ -190,7 +190,7 @@ private function handleDownload(array $response): void { $event = DownloadEvent::fromResponse($response); - if ($event === null) { + if (! $event instanceof DownloadEvent) { return; } diff --git a/tests/Browser/Webpage/DownloadTest.php b/tests/Browser/Webpage/DownloadTest.php index ba5f8139..83541107 100644 --- a/tests/Browser/Webpage/DownloadTest.php +++ b/tests/Browser/Webpage/DownloadTest.php @@ -4,6 +4,7 @@ use Illuminate\Http\Response; use Illuminate\Support\Facades\Route; +use Pest\Browser\Api\Webpage; use PHPUnit\Framework\ExpectationFailedException; beforeEach(function (): void { @@ -11,28 +12,28 @@ }); afterEach(function (): void { - collect($this->tempFiles)->each(fn ($path) => @unlink($path)); + collect($this->tempFiles)->each(fn ($path): bool => @unlink($path)); }); it('captures a download with fluent assertions', function (): void { - Route::get('/', fn () => 'Download'); - Route::get('/file', fn () => downloadResponse('Hello!', 'report.pdf')); + Route::get('/', fn (): string => 'Download'); + Route::get('/file', fn (): Response => downloadResponse('Hello!', 'report.pdf')); visit('/') - ->expectDownload(fn ($page) => $page->click('a[download]')) + ->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 () => 'Download'); - Route::get('/file', fn () => downloadResponse('File contents here', 'data.txt')); + Route::get('/', fn (): string => 'Download'); + Route::get('/file', fn (): Response => downloadResponse('File contents here', 'data.txt')); $path = tempPath($this, '.txt'); visit('/') - ->expectDownload(fn ($page) => $page->click('a[download]')) + ->expectDownload(fn ($page): Webpage => $page->click('a[download]')) ->saveAs($path) ->assertSuccessful(); @@ -40,17 +41,17 @@ }); it('retrieves the temporary download path', function (): void { - Route::get('/', fn () => 'Download'); - Route::get('/file', fn () => downloadResponse('content', 'temp.bin')); + Route::get('/', fn (): string => 'Download'); + Route::get('/file', fn (): Response => downloadResponse('content', 'temp.bin')); $download = visit('/') - ->expectDownload(fn ($page) => $page->click('a[download]')); + ->expectDownload(fn ($page): Webpage => $page->click('a[download]')); expect(file_exists($download->path()))->toBeTrue(); }); it('handles JavaScript-triggered downloads', function (): void { - Route::get('/', fn () => ' + Route::get('/', fn (): string => ' '); - Route::get('/file', fn () => downloadResponse('JS Download!', 'dynamic.txt')); + Route::get('/file', fn (): Response => downloadResponse('JS Download!', 'dynamic.txt')); visit('/') - ->expectDownload(fn ($page) => $page->click('#btn')) + ->expectDownload(fn ($page): Webpage => $page->click('#btn')) ->assertFilename('dynamic.txt') ->assertSuccessful(); }); it('handles binary file downloads', function (): void { - Route::get('/', fn () => 'Download'); + Route::get('/', fn (): string => 'Download'); Route::get('/image', function () { $png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='); @@ -81,7 +82,7 @@ $path = tempPath($this, '.png'); visit('/') - ->expectDownload(fn ($page) => $page->click('a[download]')) + ->expectDownload(fn ($page): Webpage => $page->click('a[download]')) ->assertFilename('pixel.png') ->saveAs($path); @@ -89,43 +90,43 @@ }); it('reads download contents directly', function (): void { - Route::get('/', fn () => 'Download'); - Route::get('/file', fn () => downloadResponse('Hello, World!', 'data.txt')); + Route::get('/', fn (): string => 'Download'); + Route::get('/file', fn (): Response => downloadResponse('Hello, World!', 'data.txt')); $download = visit('/') - ->expectDownload(fn ($page) => $page->click('a[download]')); + ->expectDownload(fn ($page): Webpage => $page->click('a[download]')); expect($download->contents())->toBe('Hello, World!'); }); it('asserts filename contains', function (): void { - Route::get('/', fn () => 'Download'); - Route::get('/file', fn () => downloadResponse('PDF content', 'invoice-2024-001.pdf')); + Route::get('/', fn (): string => 'Download'); + Route::get('/file', fn (): Response => downloadResponse('PDF content', 'invoice-2024-001.pdf')); visit('/') - ->expectDownload(fn ($page) => $page->click('a[download]')) + ->expectDownload(fn ($page): Webpage => $page->click('a[download]')) ->assertFilenameContains('invoice') ->assertFilenameContains('2024'); }); it('asserts content contains', function (): void { - Route::get('/', fn () => 'Download'); - Route::get('/file', fn () => downloadResponse("Name,Email\nJohn,john@example.com", 'report.csv')); + Route::get('/', fn (): string => 'Download'); + Route::get('/file', fn (): Response => downloadResponse("Name,Email\nJohn,john@example.com", 'report.csv')); visit('/') - ->expectDownload(fn ($page) => $page->click('a[download]')) + ->expectDownload(fn ($page): Webpage => $page->click('a[download]')) ->assertContentContains('John') ->assertContentContains('john@example.com'); }); it('chains assertions fluidly', function (): void { - Route::get('/', fn () => 'Download'); - Route::get('/file', fn () => downloadResponse('Fluent!', 'chain.txt')); + Route::get('/', fn (): string => 'Download'); + Route::get('/file', fn (): Response => downloadResponse('Fluent!', 'chain.txt')); $path = tempPath($this, '.txt'); visit('/') - ->expectDownload(fn ($page) => $page->click('a[download]')) + ->expectDownload(fn ($page): Webpage => $page->click('a[download]')) ->assertSuccessful() ->assertFilename('chain.txt') ->assertUrlContains('/file') @@ -134,7 +135,7 @@ }); it('captures multiple downloads as a collection', function (): void { - Route::get('/', fn () => ' + Route::get('/', fn (): string => ' '); - Route::get('/file/{name}', fn ($name) => downloadResponse("Content: {$name}", $name)); + Route::get('/file/{name}', fn (string $name): Response => downloadResponse("Content: {$name}", $name)); $downloads = visit('/')->expectDownloads( - fn ($page) => $page->click('#all'), + fn ($page): Webpage => $page->click('#all'), count: 3 ); @@ -163,7 +164,7 @@ }); it('fails when more downloads than expected', function (): void { - Route::get('/', fn () => ' + Route::get('/', fn (): string => ' '); - Route::get('/file/{name}', fn ($name) => downloadResponse("Content: {$name}", $name)); + Route::get('/file/{name}', fn (string $name): Response => downloadResponse("Content: {$name}", $name)); visit('/')->expectDownloads( - fn ($page) => $page->click('#all'), + fn ($page): Webpage => $page->click('#all'), count: 2 ); })->throws(ExpectationFailedException::class, 'Expected 2 downloads, but 3 received'); it('saves multiple downloads with collection methods', function (): void { - Route::get('/', fn () => ' + Route::get('/', fn (): string => ' '); - Route::get('/file/{name}', fn ($name) => downloadResponse("File: {$name}", $name)); + Route::get('/file/{name}', fn (string $name): Response => downloadResponse("File: {$name}", $name)); $test = $this; $downloads = visit('/')->expectDownloads( - fn ($page) => $page->click('#all'), + fn ($page): Webpage => $page->click('#all'), count: 2 ); - $paths = $downloads->map(fn ($d) => tap(tempPath($test, '.txt'), fn ($p) => $d->saveAs($p))); + $paths = $downloads->map(fn ($d) => tap(tempPath($test, '.txt'), fn (string $p): Pest\Browser\Api\PendingDownload => $d->saveAs($p))); - $paths->each(fn ($path) => expect(file_exists($path))->toBeTrue()); + $paths->each(fn ($path): Pest\Mixins\Expectation => expect(file_exists($path))->toBeTrue()); }); // Helpers From 8a2e91a03b8bc8bf98fc2eb795f0794ee924912f Mon Sep 17 00:00:00 2001 From: Pascal Baljet Date: Sun, 11 Jan 2026 19:29:06 +0100 Subject: [PATCH 03/10] CI fixes --- tests/Browser/Webpage/DownloadTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Browser/Webpage/DownloadTest.php b/tests/Browser/Webpage/DownloadTest.php index 83541107..4d0f02ce 100644 --- a/tests/Browser/Webpage/DownloadTest.php +++ b/tests/Browser/Webpage/DownloadTest.php @@ -203,9 +203,9 @@ count: 2 ); - $paths = $downloads->map(fn ($d) => tap(tempPath($test, '.txt'), fn (string $p): Pest\Browser\Api\PendingDownload => $d->saveAs($p))); + $paths = $downloads->map(fn ($d) => tap(tempPath($test, '.txt'), fn ($p) => $d->saveAs($p))); - $paths->each(fn ($path): Pest\Mixins\Expectation => expect(file_exists($path))->toBeTrue()); + $paths->each(fn ($path) => expect(file_exists($path))->toBeTrue()); }); // Helpers From 3a0cc89b06c25bcdde71ec8082cd48d6ce65a4a7 Mon Sep 17 00:00:00 2001 From: Pascal Baljet Date: Sun, 11 Jan 2026 20:24:06 +0100 Subject: [PATCH 04/10] Rector --- tests/Browser/Webpage/DownloadTest.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/Browser/Webpage/DownloadTest.php b/tests/Browser/Webpage/DownloadTest.php index 4d0f02ce..5c27cb61 100644 --- a/tests/Browser/Webpage/DownloadTest.php +++ b/tests/Browser/Webpage/DownloadTest.php @@ -4,7 +4,9 @@ use Illuminate\Http\Response; use Illuminate\Support\Facades\Route; +use Pest\Browser\Api\PendingDownload; use Pest\Browser\Api\Webpage; +use Pest\Expectation; use PHPUnit\Framework\ExpectationFailedException; beforeEach(function (): void { @@ -203,9 +205,9 @@ count: 2 ); - $paths = $downloads->map(fn ($d) => tap(tempPath($test, '.txt'), fn ($p) => $d->saveAs($p))); + $paths = $downloads->map(fn ($d) => tap(tempPath($test, '.txt'), fn (string $p): PendingDownload => $d->saveAs($p))); - $paths->each(fn ($path) => expect(file_exists($path))->toBeTrue()); + $paths->each(fn (string $path): Expectation => expect(file_exists($path))->toBeTrue()); }); // Helpers From eca7584a7862a81320d83718f856548e92371880 Mon Sep 17 00:00:00 2001 From: Pascal Baljet Date: Sun, 11 Jan 2026 20:48:47 +0100 Subject: [PATCH 05/10] Refactor --- src/Api/DownloadCollector.php | 15 ++++----------- src/Api/PendingDownload.php | 14 +++++++------- 2 files changed, 11 insertions(+), 18 deletions(-) diff --git a/src/Api/DownloadCollector.php b/src/Api/DownloadCollector.php index 310934e1..6e4b47ba 100644 --- a/src/Api/DownloadCollector.php +++ b/src/Api/DownloadCollector.php @@ -69,23 +69,16 @@ public function stop(): void private function waitForCount(int $count): void { Execution::instance()->waitForExpectation(function () use ($count): void { - if (count($this->downloads) < $count) { + $actual = count($this->downloads); + + if ($actual !== $count) { throw BrowserExpectationFailedException::from( $this->page, new ExpectationFailedException( - sprintf('Expected %d downloads, but only %d received', $count, count($this->downloads)) + sprintf('Expected %d downloads, but %d received', $count, $actual) ), ); } }); - - if (count($this->downloads) > $count) { - throw BrowserExpectationFailedException::from( - $this->page, - new ExpectationFailedException( - sprintf('Expected %d downloads, but %d received', $count, count($this->downloads)) - ), - ); - } } } diff --git a/src/Api/PendingDownload.php b/src/Api/PendingDownload.php index 2fef534d..ce6da6eb 100644 --- a/src/Api/PendingDownload.php +++ b/src/Api/PendingDownload.php @@ -55,7 +55,7 @@ public function resolve(string $url, string $suggestedFilename, string $artifact */ public function url(): string { - $this->wait(); + $this->ensureResolved(); return (string) $this->url; } @@ -65,7 +65,7 @@ public function url(): string */ public function suggestedFilename(): string { - $this->wait(); + $this->ensureResolved(); return (string) $this->suggestedFilename; } @@ -75,7 +75,7 @@ public function suggestedFilename(): string */ public function saveAs(string $path): self { - $this->wait(); + $this->ensureResolved(); iterator_to_array($this->artifact('saveAs', ['path' => $path])); @@ -87,7 +87,7 @@ public function saveAs(string $path): self */ public function path(): string { - $this->wait(); + $this->ensureResolved(); foreach ($this->artifact('pathAfterFinished') as $message) { $result = $message['result'] ?? []; @@ -114,7 +114,7 @@ public function contents(): string */ public function failure(): ?string { - $this->wait(); + $this->ensureResolved(); foreach ($this->artifact('failure') as $message) { $result = $message['result'] ?? []; @@ -211,9 +211,9 @@ private function artifact(string $method, array $params = []): Generator } /** - * Waits for the download to be resolved. + * Ensures the download has been resolved. */ - private function wait(): void + private function ensureResolved(): void { if ($this->artifactGuid !== null) { return; From 472876f9d639b4f81020a9cf5df196b5a3130460 Mon Sep 17 00:00:00 2001 From: Pascal Baljet Date: Sun, 11 Jan 2026 20:50:33 +0100 Subject: [PATCH 06/10] Refactor --- src/Api/PendingDownload.php | 46 +++++++++++++++++-------------------- 1 file changed, 21 insertions(+), 25 deletions(-) diff --git a/src/Api/PendingDownload.php b/src/Api/PendingDownload.php index ce6da6eb..19f97b4b 100644 --- a/src/Api/PendingDownload.php +++ b/src/Api/PendingDownload.php @@ -87,18 +87,7 @@ public function saveAs(string $path): self */ public function path(): string { - $this->ensureResolved(); - - foreach ($this->artifact('pathAfterFinished') as $message) { - $result = $message['result'] ?? []; - $value = is_array($result) ? ($result['value'] ?? null) : null; - - if (is_string($value)) { - return $value; - } - } - - return ''; + return $this->artifactValue('pathAfterFinished') ?? ''; } /** @@ -114,19 +103,7 @@ public function contents(): string */ public function failure(): ?string { - $this->ensureResolved(); - - foreach ($this->artifact('failure') as $message) { - $result = $message['result'] ?? []; - - if (is_array($result) && array_key_exists('value', $result)) { - $value = $result['value']; - - return is_string($value) ? $value : null; - } - } - - return null; + return $this->artifactValue('failure'); } /** @@ -197,6 +174,25 @@ public function assertFailed(): self 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. * From 55bf6a805d272b8d2ad634fe6e6efb1e8cc30e7f Mon Sep 17 00:00:00 2001 From: Pascal Baljet Date: Mon, 12 Jan 2026 13:03:45 +0100 Subject: [PATCH 07/10] Renamed `PendingDownload` to `Download` --- src/Api/Concerns/InteractsWithDownloads.php | 6 +++--- src/Api/{PendingDownload.php => Download.php} | 7 ++----- src/Api/DownloadCollector.php | 6 +++--- src/Playwright/Client.php | 6 +++--- src/Playwright/Page.php | 8 ++++---- tests/Browser/Webpage/DownloadTest.php | 4 ++-- 6 files changed, 17 insertions(+), 20 deletions(-) rename src/Api/{PendingDownload.php => Download.php} (98%) diff --git a/src/Api/Concerns/InteractsWithDownloads.php b/src/Api/Concerns/InteractsWithDownloads.php index 28645257..cf8c64e9 100644 --- a/src/Api/Concerns/InteractsWithDownloads.php +++ b/src/Api/Concerns/InteractsWithDownloads.php @@ -5,7 +5,7 @@ namespace Pest\Browser\Api\Concerns; use Illuminate\Support\Collection; -use Pest\Browser\Api\PendingDownload; +use Pest\Browser\Api\Download; trait InteractsWithDownloads { @@ -14,7 +14,7 @@ trait InteractsWithDownloads * * @param callable(self): void $callback */ - public function expectDownload(callable $callback): PendingDownload + public function expectDownload(callable $callback): Download { $download = $this->page->pendingDownload(); @@ -27,7 +27,7 @@ public function expectDownload(callable $callback): PendingDownload * Executes a callback and captures all downloads it triggers. * * @param callable(self): void $callback - * @return Collection + * @return Collection */ public function expectDownloads(callable $callback, ?int $count = null): Collection { diff --git a/src/Api/PendingDownload.php b/src/Api/Download.php similarity index 98% rename from src/Api/PendingDownload.php rename to src/Api/Download.php index 19f97b4b..e158263e 100644 --- a/src/Api/PendingDownload.php +++ b/src/Api/Download.php @@ -11,10 +11,7 @@ use Pest\Browser\Playwright\Page; use PHPUnit\Framework\ExpectationFailedException; -/** - * @internal - */ -final class PendingDownload +final class Download { /** * The download URL. @@ -32,7 +29,7 @@ final class PendingDownload private ?string $artifactGuid = null; /** - * Creates a new pending download instance. + * Creates a new download instance. */ public function __construct( private readonly Page $page, diff --git a/src/Api/DownloadCollector.php b/src/Api/DownloadCollector.php index 6e4b47ba..dda0f610 100644 --- a/src/Api/DownloadCollector.php +++ b/src/Api/DownloadCollector.php @@ -18,7 +18,7 @@ final class DownloadCollector /** * The collected downloads. * - * @var array + * @var array */ private array $downloads = []; @@ -36,7 +36,7 @@ public function __construct( */ public function add(string $url, string $suggestedFilename, string $artifactGuid): void { - $download = new PendingDownload($this->page); + $download = new Download($this->page); $download->resolve($url, $suggestedFilename, $artifactGuid); $this->downloads[] = $download; } @@ -44,7 +44,7 @@ public function add(string $url, string $suggestedFilename, string $artifactGuid /** * Returns the collected downloads, optionally waiting for an expected count. * - * @return array + * @return array */ public function all(?int $count = null): array { diff --git a/src/Playwright/Client.php b/src/Playwright/Client.php index dde77590..e655acf3 100644 --- a/src/Playwright/Client.php +++ b/src/Playwright/Client.php @@ -6,8 +6,8 @@ use Amp\Websocket\Client\WebsocketConnection; use Generator; +use Pest\Browser\Api\Download; use Pest\Browser\Api\DownloadCollector; -use Pest\Browser\Api\PendingDownload; use Pest\Browser\Exceptions\PlaywrightOutdatedException; use PHPUnit\Framework\ExpectationFailedException; @@ -36,7 +36,7 @@ final class Client /** * Pending downloads awaiting resolution, keyed by page GUID. * - * @var array + * @var array */ private array $pendingDownloads = []; @@ -138,7 +138,7 @@ public function timeout(): int /** * Registers a pending download for the given page. */ - public function expectDownload(string $pageGuid, PendingDownload $download): void + public function expectDownload(string $pageGuid, Download $download): void { $this->pendingDownloads[$pageGuid] = $download; } diff --git a/src/Playwright/Page.php b/src/Playwright/Page.php index 86905594..c0d8c1d7 100644 --- a/src/Playwright/Page.php +++ b/src/Playwright/Page.php @@ -5,8 +5,8 @@ namespace Pest\Browser\Playwright; use Generator; +use Pest\Browser\Api\Download; use Pest\Browser\Api\DownloadCollector; -use Pest\Browser\Api\PendingDownload; use Pest\Browser\Execution; use Pest\Browser\Support\ImageDiffView; use Pest\Browser\Support\JavaScriptSerializer; @@ -569,11 +569,11 @@ public function expectScreenshot(bool $fullPage, bool $openDiff): void } /** - * Creates a pending download that will capture the next download event. + * Creates a download that will capture the next download event. */ - public function pendingDownload(): PendingDownload + public function pendingDownload(): Download { - $download = new PendingDownload($this); + $download = new Download($this); Client::instance()->expectDownload($this->guid, $download); diff --git a/tests/Browser/Webpage/DownloadTest.php b/tests/Browser/Webpage/DownloadTest.php index 5c27cb61..ed80db7c 100644 --- a/tests/Browser/Webpage/DownloadTest.php +++ b/tests/Browser/Webpage/DownloadTest.php @@ -4,7 +4,7 @@ use Illuminate\Http\Response; use Illuminate\Support\Facades\Route; -use Pest\Browser\Api\PendingDownload; +use Pest\Browser\Api\Download; use Pest\Browser\Api\Webpage; use Pest\Expectation; use PHPUnit\Framework\ExpectationFailedException; @@ -205,7 +205,7 @@ count: 2 ); - $paths = $downloads->map(fn ($d) => tap(tempPath($test, '.txt'), fn (string $p): PendingDownload => $d->saveAs($p))); + $paths = $downloads->map(fn ($d) => tap(tempPath($test, '.txt'), fn (string $p): Download => $d->saveAs($p))); $paths->each(fn (string $path): Expectation => expect(file_exists($path))->toBeTrue()); }); From fc61d85150c892972e4d6655c664fa35eb32b547 Mon Sep 17 00:00:00 2001 From: Pascal Baljet Date: Mon, 12 Jan 2026 13:13:28 +0100 Subject: [PATCH 08/10] Update AwaitableWebpage.php --- src/Api/AwaitableWebpage.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Api/AwaitableWebpage.php b/src/Api/AwaitableWebpage.php index f3b0a0ae..71ac96b1 100644 --- a/src/Api/AwaitableWebpage.php +++ b/src/Api/AwaitableWebpage.php @@ -28,8 +28,6 @@ public function __construct( private array $nonAwaitableMethods = [ 'assertScreenshotMatches', 'assertNoAccessibilityIssues', - 'expectDownload', - 'expectDownloads', ], ) { // From f5d3c5662f201ebcae8689cfbd76dd728bd65ff0 Mon Sep 17 00:00:00 2001 From: Pascal Baljet Date: Mon, 12 Jan 2026 16:27:21 +0100 Subject: [PATCH 09/10] Remove `Illuminate\Support\Collection` usage --- src/Api/Concerns/InteractsWithDownloads.php | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/Api/Concerns/InteractsWithDownloads.php b/src/Api/Concerns/InteractsWithDownloads.php index cf8c64e9..7acf62ea 100644 --- a/src/Api/Concerns/InteractsWithDownloads.php +++ b/src/Api/Concerns/InteractsWithDownloads.php @@ -4,7 +4,6 @@ namespace Pest\Browser\Api\Concerns; -use Illuminate\Support\Collection; use Pest\Browser\Api\Download; trait InteractsWithDownloads @@ -27,16 +26,16 @@ public function expectDownload(callable $callback): Download * Executes a callback and captures all downloads it triggers. * * @param callable(self): void $callback - * @return Collection + * @return array */ - public function expectDownloads(callable $callback, ?int $count = null): Collection + public function expectDownloads(callable $callback, ?int $count = null): array { $collector = $this->page->downloadCollector(); try { $callback($this); - return collect($collector->all($count)); + return $collector->all($count); } finally { $collector->stop(); } From a56ff15f1bd30a3af64c1917a464afccdff45168 Mon Sep 17 00:00:00 2001 From: Pascal Baljet Date: Mon, 12 Jan 2026 16:50:50 +0100 Subject: [PATCH 10/10] Update DownloadTest.php --- tests/Browser/Webpage/DownloadTest.php | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/tests/Browser/Webpage/DownloadTest.php b/tests/Browser/Webpage/DownloadTest.php index ed80db7c..f75d34fa 100644 --- a/tests/Browser/Webpage/DownloadTest.php +++ b/tests/Browser/Webpage/DownloadTest.php @@ -6,7 +6,6 @@ use Illuminate\Support\Facades\Route; use Pest\Browser\Api\Download; use Pest\Browser\Api\Webpage; -use Pest\Expectation; use PHPUnit\Framework\ExpectationFailedException; beforeEach(function (): void { @@ -136,7 +135,7 @@ ->assertSuccessful(); }); -it('captures multiple downloads as a collection', function (): void { +it('captures multiple downloads', function (): void { Route::get('/', fn (): string => '