Skip to content

Commit 21f6688

Browse files
committed
feat: implement JSHandle and JavaScriptSerializer for enhanced JavaScript evaluation
1 parent cc41076 commit 21f6688

8 files changed

Lines changed: 758 additions & 19 deletions

File tree

src/Playwright/Concerns/InteractsWithPlaywright.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
use Generator;
88
use Pest\Browser\Playwright\Client;
99
use Pest\Browser\Playwright\Element;
10-
use Pest\Browser\Playwright\Locator;
1110
use Pest\Browser\Playwright\Page;
11+
use Pest\Browser\Support\JavaScriptSerializer;
1212

1313
/**
1414
* @internal
@@ -30,10 +30,10 @@ private function sendMessage(string $method, array $params = []): Generator
3030
*/
3131
private function processResultResponse(Generator $response): mixed
3232
{
33-
/** @var array{result: array{value: mixed}} $message */
33+
/** @var array{result?: array{value: mixed}} $message */
3434
foreach ($response as $message) {
3535
if (isset($message['result']['value'])) {
36-
return $message['result']['value'];
36+
return JavaScriptSerializer::parseValue($message['result']['value']);
3737
}
3838
}
3939

src/Playwright/JSHandle.php

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Pest\Browser\Playwright;
6+
7+
use Pest\Browser\Playwright\Concerns\InteractsWithPlaywright;
8+
use Pest\Browser\Support\JavaScriptSerializer;
9+
10+
/**
11+
* JSHandle represents a handle to a JavaScript object in the browser.
12+
* It can be used to interact with the object or evaluate expressions on it.
13+
*
14+
* @internal
15+
*/
16+
final class JSHandle
17+
{
18+
use InteractsWithPlaywright;
19+
20+
/**
21+
* Constructs new JSHandle
22+
*/
23+
public function __construct(
24+
public string $guid,
25+
) {
26+
//
27+
}
28+
29+
/**
30+
* Evaluate a JavaScript expression on this handle.
31+
*/
32+
public function evaluate(string $pageFunction, mixed $arg = null): mixed
33+
{
34+
$params = [
35+
'expression' => $pageFunction,
36+
'arg' => JavaScriptSerializer::serializeArgument($arg),
37+
];
38+
39+
$response = $this->sendMessage('evaluateExpression', $params);
40+
41+
return $this->processResultResponse($response);
42+
}
43+
44+
/**
45+
* Get the JSON representation of this handle's value.
46+
*/
47+
public function jsonValue(): mixed
48+
{
49+
$response = $this->sendMessage('jsonValue');
50+
51+
return $this->processResultResponse($response);
52+
}
53+
54+
/**
55+
* Dispose of this handle.
56+
*/
57+
public function dispose(): void
58+
{
59+
$response = $this->sendMessage('dispose');
60+
$this->processVoidResponse($response);
61+
}
62+
63+
/**
64+
* Get the string representation of this handle.
65+
*/
66+
public function toString(): string
67+
{
68+
return $this->jsonValue();
69+
}
70+
}

src/Playwright/Page.php

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use Generator;
88
use Pest\Browser\Playwright\Concerns\InteractsWithPlaywright;
99
use Pest\Browser\ServerManager;
10+
use Pest\Browser\Support\JavaScriptSerializer;
1011
use Pest\Browser\Support\Screenshot;
1112
use Pest\Browser\Support\Selector;
1213
use RuntimeException;
@@ -25,9 +26,7 @@ public function __construct(
2526
public string $guid,
2627
public string $frameGuid,
2728
public string $url = '',
28-
) {
29-
//
30-
}
29+
) {}
3130

3231
/**
3332
* Get the current URL of the page.
@@ -536,31 +535,42 @@ public function selectOption(
536535
*/
537536
public function evaluate(string $pageFunction, mixed $arg = null): mixed
538537
{
539-
$params = ['expression' => $pageFunction];
540-
541-
if ($arg !== null) {
542-
$params['arg'] = $arg;
543-
}
538+
$params = [
539+
'expression' => $pageFunction,
540+
'arg' => JavaScriptSerializer::serializeArgument($arg),
541+
];
544542

545-
$response = $this->sendMessage('evaluate', $params);
543+
$response = $this->sendMessage('evaluateExpression', $params);
546544

547545
return $this->processResultResponse($response);
548546
}
549547

550548
/**
551549
* Evaluates a JavaScript expression and returns a JSHandle.
552550
*/
553-
public function evaluateHandle(string $pageFunction, mixed $arg = null): mixed
551+
public function evaluateHandle(string $pageFunction, mixed $arg = null): JSHandle
554552
{
555-
$params = ['expression' => $pageFunction];
553+
$params = [
554+
'expression' => $pageFunction,
555+
'arg' => JavaScriptSerializer::serializeArgument($arg),
556+
];
556557

557-
if ($arg !== null) {
558-
$params['arg'] = $arg;
559-
}
558+
$response = $this->sendMessage('evaluateExpressionHandle', $params); /** @var array{method?: string|null, params: array{type?: string|null, guid?: string}} $message */
559+
foreach ($response as $message) {
560+
if (
561+
isset($message['method'], $message['params']['type'], $message['params']['guid'])
562+
&& $message['method'] === '__create__'
563+
&& $message['params']['type'] === 'JSHandle'
564+
) {
565+
return new JSHandle($message['params']['guid']);
566+
}
560567

561-
$response = $this->sendMessage('evaluateHandle', $params);
568+
if (isset($message['result']['handle'])) {
569+
return new JSHandle($message['result']['handle']['guid']);
570+
}
571+
}
562572

563-
return $this->processResultResponse($response);
573+
throw new RuntimeException('Failed to create JSHandle from evaluate response');
564574
}
565575

566576
/**
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Pest\Browser\Support;
6+
7+
use DateTimeImmutable;
8+
use stdClass;
9+
10+
/**
11+
* Handles serialization and deserialization of JavaScript values.
12+
*
13+
* @internal
14+
*/
15+
final class JavaScriptSerializer
16+
{
17+
/**
18+
* Serialize arguments for JavaScript evaluation according to Playwright protocol.
19+
*/
20+
public static function serializeArgument(mixed $value): array
21+
{
22+
return [
23+
'value' => self::serializeValue($value),
24+
'handles' => [],
25+
];
26+
}
27+
28+
/**
29+
* Serialize a value according to Playwright's serialization format.
30+
*/
31+
public static function serializeValue(mixed $value): array
32+
{
33+
if ($value === null) {
34+
return ['v' => 'null'];
35+
}
36+
37+
if (is_bool($value)) {
38+
return ['b' => $value];
39+
}
40+
41+
if (is_int($value) || is_float($value)) {
42+
if (is_float($value) && is_nan($value)) {
43+
return ['v' => 'NaN'];
44+
}
45+
if (is_float($value) && is_infinite($value)) {
46+
return ['v' => $value > 0 ? 'Infinity' : '-Infinity'];
47+
}
48+
if (is_int($value) && ($value < -9007199254740992 || $value > 9007199254740991)) {
49+
return ['bi' => (string) $value];
50+
}
51+
52+
return ['n' => $value];
53+
}
54+
55+
if (is_string($value)) {
56+
return ['s' => $value];
57+
}
58+
59+
if (is_array($value)) {
60+
$isAssoc = array_keys($value) !== range(0, count($value) - 1);
61+
if ($isAssoc) {
62+
$result = [];
63+
foreach ($value as $key => $val) {
64+
$result[] = ['k' => $key, 'v' => self::serializeValue($val)];
65+
}
66+
return ['o' => $result];
67+
} else {
68+
$result = [];
69+
foreach ($value as $item) {
70+
$result[] = self::serializeValue($item);
71+
}
72+
return ['a' => $result];
73+
}
74+
}
75+
76+
if (is_object($value)) {
77+
if ($value instanceof DateTimeImmutable) {
78+
return ['d' => $value->format('c')];
79+
}
80+
81+
$result = [];
82+
foreach (get_object_vars($value) as $key => $val) {
83+
$result[] = ['k' => $key, 'v' => self::serializeValue($val)];
84+
}
85+
return ['o' => $result];
86+
}
87+
88+
// Fallback for unsupported types
89+
return ['s' => (string) $value];
90+
}
91+
92+
/**
93+
* Parse a value from JavaScript according to Playwright protocol.
94+
*/
95+
public static function parseValue(mixed $value): mixed
96+
{
97+
if (! is_array($value)) {
98+
return $value;
99+
}
100+
101+
// Handle primitive values
102+
if (isset($value['v'])) {
103+
return match ($value['v']) {
104+
'null' => null,
105+
'undefined' => null,
106+
'NaN' => NAN,
107+
'Infinity' => INF,
108+
'-Infinity' => -INF,
109+
default => $value['v'],
110+
};
111+
}
112+
113+
if (isset($value['b'])) {
114+
return (bool) $value['b'];
115+
}
116+
117+
if (isset($value['n'])) {
118+
return is_int($value['n']) ? $value['n'] : (float) $value['n'];
119+
}
120+
121+
if (isset($value['s'])) {
122+
return (string) $value['s'];
123+
}
124+
125+
if (isset($value['bi'])) {
126+
return (string) $value['bi'];
127+
}
128+
129+
if (isset($value['d'])) {
130+
return new DateTimeImmutable($value['d']);
131+
}
132+
133+
// Handle arrays
134+
if (isset($value['a'])) {
135+
return array_map(fn ($item): mixed => self::parseValue($item), $value['a']);
136+
}
137+
138+
// Handle objects
139+
if (isset($value['o'])) {
140+
$result = [];
141+
foreach ($value['o'] as $item) {
142+
$result[$item['k']] = self::parseValue($item['v']);
143+
}
144+
return $result;
145+
}
146+
147+
return $value;
148+
}
149+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use Pest\Browser\Playwright\JSHandle;
6+
use Pest\Browser\Support\JavaScriptSerializer;
7+
8+
it('can evaluate basic expressions on a JSHandle', function (): void {
9+
$page = page('/test/frame-tests');
10+
11+
// Create a JSHandle for a DOM element
12+
$handle = $page->evaluateHandle('document.querySelector("#test-content")');
13+
expect($handle)->toBeInstanceOf(JSHandle::class);
14+
15+
// Evaluate a simple property access on the handle
16+
$textContent = $handle->evaluate('node => node.textContent');
17+
expect($textContent)->toContain('This is the main content for testing');
18+
});
19+
20+
it('can evaluate expressions that modify a JSHandle element', function (): void {
21+
$page = page('/test/frame-tests');
22+
23+
// Create a JSHandle for an input element
24+
$handle = $page->evaluateHandle('document.querySelector("#test-input")');
25+
expect($handle)->toBeInstanceOf(JSHandle::class);
26+
27+
// Set the value of the input
28+
$handle->evaluate('input => { input.value = "test value"; return true; }');
29+
30+
// Verify the value was changed
31+
$value = $page->inputValue('#test-input');
32+
expect($value)->toBe('test value');
33+
});
34+
35+
it('can pass arguments when evaluating expressions on a JSHandle', function (): void {
36+
$page = page('/test/frame-tests');
37+
38+
// Create a JSHandle
39+
$handle = $page->evaluateHandle('document.querySelector("#test-content")');
40+
expect($handle)->toBeInstanceOf(JSHandle::class);
41+
42+
// Pass a simple string argument
43+
$result = $handle->evaluate('(node, text) => { node.textContent = text; return node.textContent; }', 'Updated content');
44+
expect($result)->toBe('Updated content');
45+
46+
// Verify the content was actually changed in the page
47+
$content = $page->textContent('#test-content');
48+
expect($content)->toBe('Updated content');
49+
});
50+
51+
it('can evaluate expressions on JSHandles that return primitives', function (): void {
52+
$page = page('/test/frame-tests');
53+
54+
$handle = $page->evaluateHandle('document.querySelector("h1")');
55+
expect($handle)->toBeInstanceOf(JSHandle::class);
56+
57+
// Return a string
58+
$text = $handle->evaluate('h1 => h1.textContent');
59+
expect($text)->toBeString();
60+
expect($text)->toContain('PESTPHP');
61+
62+
// Return a boolean
63+
$hasChildren = $handle->evaluate('h1 => h1.hasChildNodes()');
64+
expect($hasChildren)->toBeTrue();
65+
66+
// Return a number
67+
$childCount = $handle->evaluate('h1 => h1.childNodes.length');
68+
expect($childCount)->toBeGreaterThan(0);
69+
});

0 commit comments

Comments
 (0)