From 3024aecfd6178075a9e3b209c00951d840b3bdcb Mon Sep 17 00:00:00 2001 From: g4zz0l1 Date: Thu, 9 Apr 2026 12:32:02 +0200 Subject: [PATCH 1/3] fix: use file_get_contents to prevent large JS file truncation ReadableResourceStream sets the php://temp stream to non-blocking mode, causing AMPHP's fiber event loop to deliver only the first 8192-byte chunk. Files larger than 8KB are silently truncated, producing 'SyntaxError: Unexpected end of input' in Playwright. Replacing fopen/fread/php://temp/ReadableResourceStream with a plain file_get_contents + string response body sends the full file content in one shot, bypassing the async read-chunk limitation. This also fixes the 0-byte fread crash from #1635 without needing a separate filesize() guard. Fixes pestphp/pest#1664 --- src/Drivers/LaravelHttpServer.php | 24 +++++------------------- 1 file changed, 5 insertions(+), 19 deletions(-) diff --git a/src/Drivers/LaravelHttpServer.php b/src/Drivers/LaravelHttpServer.php index 97ae5fdb..b10c4da2 100644 --- a/src/Drivers/LaravelHttpServer.php +++ b/src/Drivers/LaravelHttpServer.php @@ -4,7 +4,6 @@ namespace Pest\Browser\Drivers; -use Amp\ByteStream\ReadableResourceStream; use Amp\Http\Cookie\RequestCookie; use Amp\Http\Server\DefaultErrorHandler; use Amp\Http\Server\HttpServer as AmpHttpServer; @@ -317,9 +316,9 @@ private function handleRequest(AmpRequest $request): Response */ private function asset(string $filepath): Response { - $file = fopen($filepath, 'r'); + $rawContent = file_get_contents($filepath); - if ($file === false) { + if ($rawContent === false) { return new Response(404); } @@ -329,26 +328,13 @@ private function asset(string $filepath): Response $contentType = $contentType[0] ?? 'application/octet-stream'; if (str_ends_with($filepath, '.js')) { - $temporaryStream = fopen('php://temp', 'r+'); - assert($temporaryStream !== false, 'Failed to open temporary stream.'); - - // @phpstan-ignore-next-line - $temporaryContent = fread($file, (int) filesize($filepath)); - - assert($temporaryContent !== false, 'Failed to open temporary stream.'); - - $content = $this->rewriteAssetUrl($temporaryContent); - - fwrite($temporaryStream, $content); - - rewind($temporaryStream); - - $file = $temporaryStream; + $rawContent = $this->rewriteAssetUrl($rawContent); } return new Response(200, [ 'Content-Type' => $contentType, - ], new ReadableResourceStream($file)); + 'Content-Length' => (string) strlen($rawContent), + ], $rawContent); } /** From aea01e719047f3653ef0886f22f970e3d5ea7bc3 Mon Sep 17 00:00:00 2001 From: g4zz0l1 Date: Thu, 9 Apr 2026 12:41:24 +0200 Subject: [PATCH 2/3] fix: limit file_get_contents to JS files, keep streaming for other assets Address review feedback: only use file_get_contents for .js files where the php://temp + ReadableResourceStream truncation occurs. Non-JS assets (images, fonts, CSS) continue to use ReadableResourceStream on the real file handle, which is not affected by the 8KB chunk issue and is more memory-efficient for large binary files. Also adds an explanatory comment on why file_get_contents is used. --- src/Drivers/LaravelHttpServer.php | 34 ++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/src/Drivers/LaravelHttpServer.php b/src/Drivers/LaravelHttpServer.php index b10c4da2..9689823e 100644 --- a/src/Drivers/LaravelHttpServer.php +++ b/src/Drivers/LaravelHttpServer.php @@ -4,6 +4,7 @@ namespace Pest\Browser\Drivers; +use Amp\ByteStream\ReadableResourceStream; use Amp\Http\Cookie\RequestCookie; use Amp\Http\Server\DefaultErrorHandler; use Amp\Http\Server\HttpServer as AmpHttpServer; @@ -316,25 +317,38 @@ private function handleRequest(AmpRequest $request): Response */ private function asset(string $filepath): Response { - $rawContent = file_get_contents($filepath); - - if ($rawContent === false) { - return new Response(404); - } - $mimeTypes = new MimeTypes(); $contentType = $mimeTypes->getMimeTypes(pathinfo($filepath, PATHINFO_EXTENSION)); - $contentType = $contentType[0] ?? 'application/octet-stream'; if (str_ends_with($filepath, '.js')) { - $rawContent = $this->rewriteAssetUrl($rawContent); + // ReadableResourceStream sets php://temp to non-blocking mode, causing + // AMPHP's event loop to deliver only the first 8192-byte chunk for files + // written to a php://temp stream. Using file_get_contents + a string body + // sends the full content in one shot, avoiding the truncation. + $content = file_get_contents($filepath); + + if ($content === false) { + return new Response(404); + } + + $content = $this->rewriteAssetUrl($content); + + return new Response(200, [ + 'Content-Type' => $contentType, + 'Content-Length' => (string) strlen($content), + ], $content); + } + + $file = fopen($filepath, 'r'); + + if ($file === false) { + return new Response(404); } return new Response(200, [ 'Content-Type' => $contentType, - 'Content-Length' => (string) strlen($rawContent), - ], $rawContent); + ], new ReadableResourceStream($file)); } /** From 1136de9e65d60cfa831d004c151b8ab06d9452cc Mon Sep 17 00:00:00 2001 From: g4zz0l1 Date: Thu, 9 Apr 2026 12:42:38 +0200 Subject: [PATCH 3/3] test: add regression test for JS files >8KB being served in full Adds a test that creates a JS file slightly above 8192 bytes with a sentinel string at the end, then asserts the full content is returned. Without the fix, ReadableResourceStream would truncate the response to the first 8KB, causing the sentinel assertion to fail. --- .../Unit/Drivers/Laravel/LaravelHttpServerTest.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/Unit/Drivers/Laravel/LaravelHttpServerTest.php b/tests/Unit/Drivers/Laravel/LaravelHttpServerTest.php index 7491d6a6..f92c9340 100644 --- a/tests/Unit/Drivers/Laravel/LaravelHttpServerTest.php +++ b/tests/Unit/Drivers/Laravel/LaravelHttpServerTest.php @@ -21,6 +21,19 @@ ->assertDontSee('http://localhost'); }); +it('serves JS files larger than 8192 bytes in full without truncation', function (): void { + // Create a JS file well over 8KB with a sentinel value at the very end. + // Before the fix, ReadableResourceStream on a php://temp stream would only + // deliver the first 8192-byte chunk, so the sentinel would be missing. + $padding = str_repeat('//' . str_repeat('x', 78) . PHP_EOL, 110); // ~8250 bytes of padding + @file_put_contents( + public_path('large.js'), + $padding . "console.log('END_SENTINEL');", + ); + + visit('/large.js')->assertSee('END_SENTINEL'); +}); + it('includes cookies set in the test', function (): void { Route::get('/cookies', fn (Request $request): array => $request->cookies->all());