Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/Exceptions/FileNotReadableException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace Pest\Browser\Exceptions;

use RuntimeException;

/**
* @internal
*/
final class FileNotReadableException extends RuntimeException
{
//
}
15 changes: 15 additions & 0 deletions src/Exceptions/FileTooLargeException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace Pest\Browser\Exceptions;

use RuntimeException;

/**
* @internal
*/
final class FileTooLargeException extends RuntimeException
{
//
}
6 changes: 5 additions & 1 deletion src/Playwright/Locator.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Generator;
use Pest\Browser\Playwright\Concerns\InteractsWithPlaywright;
use Pest\Browser\Support\FilePayload;
use Pest\Browser\Support\Selector;
use RuntimeException;

Expand Down Expand Up @@ -651,10 +652,13 @@ public function tap(?array $options = null): void

/**
* Set input files for a file input element.
*
* The file is sent by value rather than by path, as Playwright 1.61.0
* and above reject `localPaths` for websocket clients.
*/
public function setInputFiles(string $path): void
{
$params = ['localPaths' => [$path]];
$params = ['payloads' => [FilePayload::fromPath($path)->toArray()]];
$response = $this->sendMessage('setInputFiles', $params);

$this->processVoidResponse($response);
Expand Down
106 changes: 106 additions & 0 deletions src/Support/FilePayload.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<?php

declare(strict_types=1);

namespace Pest\Browser\Support;

use Pest\Browser\Exceptions\FileNotReadableException;
use Pest\Browser\Exceptions\FileTooLargeException;
use Symfony\Component\Mime\MimeTypes;

/**
* A file to be uploaded, expressed as an inline Playwright payload.
*
* Playwright 1.61.0 and above refuse `localPaths` unless the client is
* collocated with the server, a flag only set by the in-process and stdio
* drivers — never by `run-server`. As this plugin always connects over a
* websocket, files are sent by value instead of by path.
*
* @internal
*/
final readonly class FilePayload
{
/**
* The largest buffer Playwright accepts for an inline payload.
*/
private const int MAX_SIZE_IN_BYTES = 50 * 1024 * 1024;

/**
* The MIME type used when the extension cannot be mapped to a known type.
*/
private const string FALLBACK_MIME_TYPE = 'application/octet-stream';

/**
* Creates a new file payload instance.
*/
private function __construct(
private string $name,
private string $mimeType,
private string $contents,
) {
//
}

/**
* Create a payload from the file at the given path.
*/
public static function fromPath(string $path): self
{
if (! is_file($path) || ! is_readable($path)) {
throw new FileNotReadableException("The file [{$path}] does not exist or is not readable.");
}

$size = filesize($path);

if ($size === false) {
throw new FileNotReadableException("The size of the file [{$path}] could not be determined.");
}

if ($size > 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;
}
}
121 changes: 121 additions & 0 deletions tests/Unit/Support/FilePayloadTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
<?php

declare(strict_types=1);

use Pest\Browser\Exceptions\FileNotReadableException;
use Pest\Browser\Exceptions\FileTooLargeException;
use Pest\Browser\Support\FilePayload;

/**
* Create a temporary file with the given contents and extension.
*/
function temporaryFile(string $contents, string $extension = ''): string
{
$path = tempnam(sys_get_temp_dir(), 'pest_payload');

if ($extension !== '') {
unlink($path);
$path .= '.'.$extension;
}

file_put_contents($path, $contents);

return $path;
}

test('sends the file by value instead of by path', function (): void {
$path = temporaryFile('hello world', 'txt');

$payload = FilePayload::fromPath($path)->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);
}
});