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); + } +});