From 9e758b2a50cafa9d10f5fd0a3d8dccfc8bb6979e Mon Sep 17 00:00:00 2001 From: Valery Ivashchanka Date: Mon, 20 Jul 2026 03:17:55 +0300 Subject: [PATCH] fix: attach() is broken with Playwright 1.61.0 and above MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Locator::setInputFiles()` sends the attached file's local path via the `localPaths` parameter. Playwright 1.61.0 added a server-side guard that rejects it unless `isClientCollocatedWithServer` is set — a flag only set by the in-process and stdio drivers, never by `run-server`. Since this plugin always connects to `run-server` over a websocket, every upload fails with "localPaths are not allowed when the client is not local". Files are now sent by value as an inline `payloads` entry, which is accepted regardless of how the client is connected. The `buffer` field is typed as binary by the protocol, which is base64 over the JSON channel — the same encoding the plugin already assumes when decoding screenshots in `Support\Screenshot`. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Exceptions/FileNotReadableException.php | 15 +++ src/Exceptions/FileTooLargeException.php | 15 +++ src/Playwright/Locator.php | 6 +- src/Support/FilePayload.php | 106 +++++++++++++++++ tests/Unit/Support/FilePayloadTest.php | 121 ++++++++++++++++++++ 5 files changed, 262 insertions(+), 1 deletion(-) create mode 100644 src/Exceptions/FileNotReadableException.php create mode 100644 src/Exceptions/FileTooLargeException.php create mode 100644 src/Support/FilePayload.php create mode 100644 tests/Unit/Support/FilePayloadTest.php diff --git a/src/Exceptions/FileNotReadableException.php b/src/Exceptions/FileNotReadableException.php new file mode 100644 index 00000000..9028d287 --- /dev/null +++ b/src/Exceptions/FileNotReadableException.php @@ -0,0 +1,15 @@ + [$path]]; + $params = ['payloads' => [FilePayload::fromPath($path)->toArray()]]; $response = $this->sendMessage('setInputFiles', $params); $this->processVoidResponse($response); diff --git a/src/Support/FilePayload.php b/src/Support/FilePayload.php new file mode 100644 index 00000000..0eb7404d --- /dev/null +++ b/src/Support/FilePayload.php @@ -0,0 +1,106 @@ + self::MAX_SIZE_IN_BYTES) { + throw new FileTooLargeException( + "The file [{$path}] exceeds the maximum upload size of 50 MB supported by Playwright." + ); + } + + $contents = file_get_contents($path); + + if ($contents === false) { + throw new FileNotReadableException("The file [{$path}] could not be read."); + } + + return new self(basename($path), self::guessMimeType($path), $contents); + } + + /** + * Return the payload in the shape expected by the Playwright protocol. + * + * The `buffer` field is typed as binary by the protocol, which is + * base64 over the JSON channel. + * + * @return array{name: string, mimeType: string, buffer: string} + */ + public function toArray(): array + { + return [ + 'name' => $this->name, + 'mimeType' => $this->mimeType, + 'buffer' => base64_encode($this->contents), + ]; + } + + /** + * Guess the MIME type of the file from its extension. + */ + private static function guessMimeType(string $path): string + { + $extension = pathinfo($path, PATHINFO_EXTENSION); + + if ($extension === '') { + return self::FALLBACK_MIME_TYPE; + } + + $mimeTypes = (new MimeTypes())->getMimeTypes($extension); + + return $mimeTypes[0] ?? self::FALLBACK_MIME_TYPE; + } +} diff --git a/tests/Unit/Support/FilePayloadTest.php b/tests/Unit/Support/FilePayloadTest.php new file mode 100644 index 00000000..1a3b022b --- /dev/null +++ b/tests/Unit/Support/FilePayloadTest.php @@ -0,0 +1,121 @@ +toArray(); + + expect($payload)->toHaveKeys(['name', 'mimeType', 'buffer']) + ->and($payload)->not->toHaveKey('localPaths') + ->and($payload['buffer'])->not->toBe($path); + + unlink($path); +}); + +test('encodes the buffer as base64', function (): void { + $path = temporaryFile('hello world', 'txt'); + + $payload = FilePayload::fromPath($path)->toArray(); + + expect($payload['buffer'])->toBe(base64_encode('hello world')) + ->and(base64_decode($payload['buffer'], true))->toBe('hello world'); + + unlink($path); +}); + +test('preserves binary contents without corruption', function (): void { + $contents = ''; + for ($byte = 0; $byte < 256; $byte++) { + $contents .= pack('C', $byte); + } + $contents .= "\x00\xC3\x28\xFF\xFE invalid utf-8 \r\n "; + + $path = temporaryFile($contents, 'bin'); + + $payload = FilePayload::fromPath($path)->toArray(); + $decoded = (string) base64_decode($payload['buffer'], true); + + expect(hash('sha256', $decoded))->toBe(hash('sha256', $contents)) + ->and($decoded)->toBe($contents); + + unlink($path); +}); + +test('uses the file name rather than the full path', function (): void { + $path = temporaryFile('contents', 'txt'); + + expect(FilePayload::fromPath($path)->toArray()['name'])->toBe(basename($path)); + + unlink($path); +}); + +test('guesses the mime type from the extension', function (): void { + $path = temporaryFile('{}', 'json'); + + expect(FilePayload::fromPath($path)->toArray()['mimeType'])->toBe('application/json'); + + unlink($path); +}); + +test('falls back to a generic mime type for unknown extensions', function (): void { + $path = temporaryFile('contents', 'unknownext'); + + expect(FilePayload::fromPath($path)->toArray()['mimeType'])->toBe('application/octet-stream'); + + unlink($path); +}); + +test('falls back to a generic mime type when there is no extension', function (): void { + $path = temporaryFile('contents'); + + expect(FilePayload::fromPath($path)->toArray()['mimeType'])->toBe('application/octet-stream'); + + unlink($path); +}); + +test('fails when the file does not exist', function (): void { + FilePayload::fromPath('/this/path/does/not/exist.txt'); +})->throws(FileNotReadableException::class); + +test('fails when the path is a directory', function (): void { + FilePayload::fromPath(sys_get_temp_dir()); +})->throws(FileNotReadableException::class); + +test('fails when the file exceeds the playwright payload limit', function (): void { + $path = temporaryFile(''); + + $handle = fopen($path, 'wb'); + fseek($handle, 50 * 1024 * 1024); + fwrite($handle, 'x'); + fclose($handle); + + try { + expect(fn (): array => FilePayload::fromPath($path)->toArray()) + ->toThrow(FileTooLargeException::class); + } finally { + unlink($path); + } +});