Skip to content

Commit 07278fa

Browse files
likemusicclaude
andcommitted
fix: upload files by value so attach() works with a remote Playwright server
`Locator::setInputFiles()` sent the local filesystem path of the attached file via the `localPaths` parameter. Playwright rejects that parameter whenever the client is not collocated with the server, failing with "localPaths are not allowed when the client is not local". Files are now sent by value as an inline `payloads` entry instead. The `buffer` field is typed as binary by the Playwright 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) <noreply@anthropic.com>
1 parent 734236a commit 07278fa

5 files changed

Lines changed: 262 additions & 1 deletion

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Pest\Browser\Exceptions;
6+
7+
use RuntimeException;
8+
9+
/**
10+
* @internal
11+
*/
12+
final class FileNotReadableException extends RuntimeException
13+
{
14+
//
15+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Pest\Browser\Exceptions;
6+
7+
use RuntimeException;
8+
9+
/**
10+
* @internal
11+
*/
12+
final class FileTooLargeException extends RuntimeException
13+
{
14+
//
15+
}

‎src/Playwright/Locator.php‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use Generator;
88
use Pest\Browser\Playwright\Concerns\InteractsWithPlaywright;
9+
use Pest\Browser\Support\FilePayload;
910
use Pest\Browser\Support\Selector;
1011
use RuntimeException;
1112

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

652653
/**
653654
* Set input files for a file input element.
655+
*
656+
* The file is sent by value rather than by path, so that uploads keep
657+
* working when the Playwright server is not collocated with the client.
654658
*/
655659
public function setInputFiles(string $path): void
656660
{
657-
$params = ['localPaths' => [$path]];
661+
$params = ['payloads' => [FilePayload::fromPath($path)->toArray()]];
658662
$response = $this->sendMessage('setInputFiles', $params);
659663

660664
$this->processVoidResponse($response);

‎src/Support/FilePayload.php‎

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Pest\Browser\Support;
6+
7+
use Pest\Browser\Exceptions\FileNotReadableException;
8+
use Pest\Browser\Exceptions\FileTooLargeException;
9+
use Symfony\Component\Mime\MimeTypes;
10+
11+
/**
12+
* A file to be uploaded, expressed as an inline Playwright payload.
13+
*
14+
* Playwright refuses `localPaths` whenever the client is not collocated with
15+
* the server, so files are sent by value instead of by path. This keeps
16+
* uploads working against a remote `playwright run-server`, and behaves
17+
* identically when the server is local.
18+
*
19+
* @internal
20+
*/
21+
final readonly class FilePayload
22+
{
23+
/**
24+
* The largest buffer Playwright accepts for an inline payload.
25+
*/
26+
private const int MAX_SIZE_IN_BYTES = 50 * 1024 * 1024;
27+
28+
/**
29+
* The MIME type used when the extension cannot be mapped to a known type.
30+
*/
31+
private const string FALLBACK_MIME_TYPE = 'application/octet-stream';
32+
33+
/**
34+
* Creates a new file payload instance.
35+
*/
36+
private function __construct(
37+
private string $name,
38+
private string $mimeType,
39+
private string $contents,
40+
) {
41+
//
42+
}
43+
44+
/**
45+
* Create a payload from the file at the given path.
46+
*/
47+
public static function fromPath(string $path): self
48+
{
49+
if (! is_file($path) || ! is_readable($path)) {
50+
throw new FileNotReadableException("The file [{$path}] does not exist or is not readable.");
51+
}
52+
53+
$size = filesize($path);
54+
55+
if ($size === false) {
56+
throw new FileNotReadableException("The size of the file [{$path}] could not be determined.");
57+
}
58+
59+
if ($size > self::MAX_SIZE_IN_BYTES) {
60+
throw new FileTooLargeException(
61+
"The file [{$path}] exceeds the maximum upload size of 50 MB supported by Playwright."
62+
);
63+
}
64+
65+
$contents = file_get_contents($path);
66+
67+
if ($contents === false) {
68+
throw new FileNotReadableException("The file [{$path}] could not be read.");
69+
}
70+
71+
return new self(basename($path), self::guessMimeType($path), $contents);
72+
}
73+
74+
/**
75+
* Return the payload in the shape expected by the Playwright protocol.
76+
*
77+
* The `buffer` field is typed as binary by the protocol, which is
78+
* base64 over the JSON channel.
79+
*
80+
* @return array{name: string, mimeType: string, buffer: string}
81+
*/
82+
public function toArray(): array
83+
{
84+
return [
85+
'name' => $this->name,
86+
'mimeType' => $this->mimeType,
87+
'buffer' => base64_encode($this->contents),
88+
];
89+
}
90+
91+
/**
92+
* Guess the MIME type of the file from its extension.
93+
*/
94+
private static function guessMimeType(string $path): string
95+
{
96+
$extension = pathinfo($path, PATHINFO_EXTENSION);
97+
98+
if ($extension === '') {
99+
return self::FALLBACK_MIME_TYPE;
100+
}
101+
102+
$mimeTypes = (new MimeTypes())->getMimeTypes($extension);
103+
104+
return $mimeTypes[0] ?? self::FALLBACK_MIME_TYPE;
105+
}
106+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use Pest\Browser\Exceptions\FileNotReadableException;
6+
use Pest\Browser\Exceptions\FileTooLargeException;
7+
use Pest\Browser\Support\FilePayload;
8+
9+
/**
10+
* Create a temporary file with the given contents and extension.
11+
*/
12+
function temporaryFile(string $contents, string $extension = ''): string
13+
{
14+
$path = tempnam(sys_get_temp_dir(), 'pest_payload');
15+
16+
if ($extension !== '') {
17+
unlink($path);
18+
$path .= '.'.$extension;
19+
}
20+
21+
file_put_contents($path, $contents);
22+
23+
return $path;
24+
}
25+
26+
test('sends the file by value instead of by path', function (): void {
27+
$path = temporaryFile('hello world', 'txt');
28+
29+
$payload = FilePayload::fromPath($path)->toArray();
30+
31+
expect($payload)->toHaveKeys(['name', 'mimeType', 'buffer'])
32+
->and($payload)->not->toHaveKey('localPaths')
33+
->and($payload['buffer'])->not->toBe($path);
34+
35+
unlink($path);
36+
});
37+
38+
test('encodes the buffer as base64', function (): void {
39+
$path = temporaryFile('hello world', 'txt');
40+
41+
$payload = FilePayload::fromPath($path)->toArray();
42+
43+
expect($payload['buffer'])->toBe(base64_encode('hello world'))
44+
->and(base64_decode($payload['buffer'], true))->toBe('hello world');
45+
46+
unlink($path);
47+
});
48+
49+
test('preserves binary contents without corruption', function (): void {
50+
$contents = '';
51+
for ($byte = 0; $byte < 256; $byte++) {
52+
$contents .= pack('C', $byte);
53+
}
54+
$contents .= "\x00\xC3\x28\xFF\xFE invalid utf-8 \r\n ";
55+
56+
$path = temporaryFile($contents, 'bin');
57+
58+
$payload = FilePayload::fromPath($path)->toArray();
59+
$decoded = (string) base64_decode($payload['buffer'], true);
60+
61+
expect(hash('sha256', $decoded))->toBe(hash('sha256', $contents))
62+
->and($decoded)->toBe($contents);
63+
64+
unlink($path);
65+
});
66+
67+
test('uses the file name rather than the full path', function (): void {
68+
$path = temporaryFile('contents', 'txt');
69+
70+
expect(FilePayload::fromPath($path)->toArray()['name'])->toBe(basename($path));
71+
72+
unlink($path);
73+
});
74+
75+
test('guesses the mime type from the extension', function (): void {
76+
$path = temporaryFile('{}', 'json');
77+
78+
expect(FilePayload::fromPath($path)->toArray()['mimeType'])->toBe('application/json');
79+
80+
unlink($path);
81+
});
82+
83+
test('falls back to a generic mime type for unknown extensions', function (): void {
84+
$path = temporaryFile('contents', 'unknownext');
85+
86+
expect(FilePayload::fromPath($path)->toArray()['mimeType'])->toBe('application/octet-stream');
87+
88+
unlink($path);
89+
});
90+
91+
test('falls back to a generic mime type when there is no extension', function (): void {
92+
$path = temporaryFile('contents');
93+
94+
expect(FilePayload::fromPath($path)->toArray()['mimeType'])->toBe('application/octet-stream');
95+
96+
unlink($path);
97+
});
98+
99+
test('fails when the file does not exist', function (): void {
100+
FilePayload::fromPath('/this/path/does/not/exist.txt');
101+
})->throws(FileNotReadableException::class);
102+
103+
test('fails when the path is a directory', function (): void {
104+
FilePayload::fromPath(sys_get_temp_dir());
105+
})->throws(FileNotReadableException::class);
106+
107+
test('fails when the file exceeds the playwright payload limit', function (): void {
108+
$path = temporaryFile('');
109+
110+
$handle = fopen($path, 'wb');
111+
fseek($handle, 50 * 1024 * 1024);
112+
fwrite($handle, 'x');
113+
fclose($handle);
114+
115+
try {
116+
expect(fn (): array => FilePayload::fromPath($path)->toArray())
117+
->toThrow(FileTooLargeException::class);
118+
} finally {
119+
unlink($path);
120+
}
121+
});

0 commit comments

Comments
 (0)