Skip to content

Commit e6ba8cb

Browse files
committed
Proof of concept
1 parent e947827 commit e6ba8cb

File tree

11 files changed

+719
-11
lines changed

11 files changed

+719
-11
lines changed

composer.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,17 @@
1717
"php": "^8.1",
1818
"ext-json": "*",
1919
"ext-zip": "*",
20+
"ext-sockets": "*",
2021
"guzzlehttp/guzzle": "^7.5",
2122
"illuminate/console": "^10.0|^11.0|^12.0",
2223
"illuminate/support": "^10.0|^11.0|^12.0",
2324
"php-webdriver/webdriver": "^1.15.2",
25+
"react/event-loop": "^1.5",
26+
"react/http": "^1.10",
2427
"symfony/console": "^6.2|^7.0",
2528
"symfony/finder": "^6.2|^7.0",
2629
"symfony/process": "^6.2|^7.0",
30+
"symfony/psr-http-message-bridge": "^7.1",
2731
"vlucas/phpdotenv": "^5.2"
2832
},
2933
"require-dev": {

src/Browser.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use Facebook\WebDriver\WebDriverPoint;
1111
use Illuminate\Support\Str;
1212
use Illuminate\Support\Traits\Macroable;
13+
use Laravel\Dusk\Http\ProxyServer;
1314

1415
class Browser
1516
{
@@ -182,6 +183,9 @@ public function visit($url)
182183
$url = static::$baseUrl.'/'.ltrim($url, '/');
183184
}
184185

186+
// Force our proxy server — this needs to be improved
187+
$url = str_replace(rtrim(static::$baseUrl, '/'), app(ProxyServer::class)->url(), $url);
188+
185189
$this->driver->navigate()->to($url);
186190

187191
// If the page variable was set, we will call the "on" method which will set a
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
namespace Laravel\Dusk\Concerns;
4+
5+
use Illuminate\Routing\UrlGenerator;
6+
use Laravel\Dusk\Http\ProxyServer;
7+
use PHPUnit\Framework\Attributes\Before;
8+
9+
trait ProvidesProxyServer
10+
{
11+
#[Before]
12+
public function setUpProvidesProxyServer(): void
13+
{
14+
$this->afterApplicationCreated(function () {
15+
$proxy = $this->app->make(ProxyServer::class)->listen();
16+
$this->app->make(UrlGenerator::class)->forceRootUrl($proxy->url());
17+
});
18+
19+
$this->beforeApplicationDestroyed(function () {
20+
$this->app->make(ProxyServer::class)->flush();
21+
$this->app->make(UrlGenerator::class)->forceRootUrl(null);
22+
});
23+
}
24+
}
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
<?php
2+
3+
namespace Laravel\Dusk\Driver;
4+
5+
use Facebook\WebDriver\Exception\Internal\LogicException;
6+
use Facebook\WebDriver\Exception\WebDriverException;
7+
use Facebook\WebDriver\Remote\HttpCommandExecutor;
8+
use Facebook\WebDriver\Remote\WebDriverCommand;
9+
use Facebook\WebDriver\Remote\WebDriverResponse;
10+
use Illuminate\Support\Arr;
11+
use Illuminate\Support\Collection;
12+
use JsonException;
13+
use Psr\Http\Message\ResponseInterface;
14+
use React\EventLoop\Loop;
15+
use React\Http\Browser as ReactHttpClient;
16+
use React\Promise\PromiseInterface;
17+
18+
class AsyncCommandExecutor extends HttpCommandExecutor
19+
{
20+
/**
21+
* Execute a web driver command asynchronously.
22+
*
23+
* @param WebDriverCommand $command
24+
* @return WebDriverResponse
25+
*/
26+
public function execute(WebDriverCommand $command): WebDriverResponse
27+
{
28+
$client = new ReactHttpClient();
29+
30+
[$url, $method, $headers, $payload] = $this->extractRequestDataFromCommand($command);
31+
32+
return $this->sendRequestAndWaitForResponse(match ($method) {
33+
'GET' => $client->get($url, $headers),
34+
'POST' => $client->post($url, $headers, $this->encodePayload($payload)),
35+
'DELETE' => $client->delete($url, $headers),
36+
});
37+
}
38+
39+
/**
40+
* Run event loop until request is fulfilled.
41+
*
42+
* @param PromiseInterface $request
43+
* @return WebDriverResponse
44+
*
45+
* @throws JsonException
46+
* @throws WebDriverException
47+
*/
48+
protected function sendRequestAndWaitForResponse(PromiseInterface $request): WebDriverResponse
49+
{
50+
$resolved = null;
51+
52+
$request->then(function ($response) use (&$resolved) {
53+
Loop::get()->futureTick(fn() => Loop::stop());
54+
$resolved = $response;
55+
});
56+
57+
while ($resolved === null) {
58+
Loop::run();
59+
}
60+
61+
return $this->mapAsyncResponseToWebDriverResponse($resolved);
62+
}
63+
64+
/**
65+
* Parse HTTP response and map to web driver response.
66+
*
67+
* @param ResponseInterface $response
68+
* @return WebDriverResponse
69+
*
70+
* @throws JsonException
71+
* @throws WebDriverException
72+
*/
73+
protected function mapAsyncResponseToWebDriverResponse(ResponseInterface $response): WebDriverResponse
74+
{
75+
$value = null;
76+
$message = null;
77+
$sessionId = null;
78+
$status = 0;
79+
80+
$results = json_decode($response->getBody(), true, 512, JSON_THROW_ON_ERROR);
81+
82+
if (is_array($results)) {
83+
$value = Arr::get($results, 'value');
84+
$message = Arr::get($results, 'message');
85+
$status = Arr::get($results, 'status', 0);
86+
87+
if (is_array($value) && array_key_exists('sessionId', $value)) {
88+
$sessionId = $value['sessionId'];
89+
} elseif (array_key_exists('sessionId', $results)) {
90+
$sessionId = $results['sessionId'];
91+
}
92+
}
93+
94+
if (is_array($value) && isset($value['error'])) {
95+
WebDriverException::throwException($value['error'], $message, $results);
96+
}
97+
98+
if ($status !== 0) {
99+
WebDriverException::throwException($status, $message, $results);
100+
}
101+
102+
return (new WebDriverResponse($sessionId))->setStatus($status)->setValue($value);
103+
}
104+
105+
/**
106+
* Ensure that payload is always a JSON object.
107+
*
108+
* @param Collection $payload
109+
* @return string
110+
*/
111+
protected function encodePayload(Collection $payload): string
112+
{
113+
// POST body must be valid JSON object, even if empty: https://www.w3.org/TR/webdriver/#processing-model
114+
if ($payload->isEmpty()) {
115+
return '{}';
116+
}
117+
118+
return $payload->toJson();
119+
}
120+
121+
/**
122+
* Extract data necessary to make HTTP request for web driver command.
123+
*
124+
* @param WebDriverCommand $command
125+
* @return array{0: string, 1: string, 2: array, 3: Collection}
126+
*
127+
* @throws LogicException
128+
*/
129+
protected function extractRequestDataFromCommand(WebDriverCommand $command): array
130+
{
131+
['url' => $path, 'method' => $method] = $this->getCommandHttpOptions($command);
132+
133+
// Keys that are prefixed with ":" are URL parameters. All others are JSON payload data.
134+
[$parameters, $payload] = collect($command->getParameters() ?? [])
135+
->put(':sessionId', (string) $command->getSessionID())
136+
->partition(fn($value, $key) => str_starts_with($key, ':'));
137+
138+
if ($payload->isNotEmpty() && $method !== 'POST') {
139+
throw LogicException::forInvalidHttpMethod($path, $method, $payload->all());
140+
}
141+
142+
$url = $this->url.$this->applyParametersToPath($parameters, $path);
143+
$method = strtoupper($method);
144+
$headers = $this->defaultHeaders($method);
145+
146+
return [$url, $method, $headers, $payload];
147+
}
148+
149+
/**
150+
* Replace prefixed placeholders with request parameters.
151+
*
152+
* @param Collection $parameters
153+
* @param string $path
154+
* @return string
155+
*/
156+
protected function applyParametersToPath(Collection $parameters, string $path): string
157+
{
158+
return str_replace($parameters->keys()->all(), $parameters->values()->all(), $path);
159+
}
160+
161+
/**
162+
* Get the default HTTP headers for a given request method.
163+
*
164+
* @param string $method
165+
* @return array
166+
*/
167+
protected function defaultHeaders(string $method): array
168+
{
169+
$headers = collect(static::DEFAULT_HTTP_HEADERS)->mapWithKeys(function ($header) {
170+
[$key, $value] = explode(':', $header, 2);
171+
return [$key => $value];
172+
});
173+
174+
if (in_array($method, ['POST', 'PUT'], true)) {
175+
$headers->put('Expect', '');
176+
}
177+
178+
return $headers->all();
179+
}
180+
}

src/Driver/AsyncWebDriver.php

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<?php
2+
3+
namespace Laravel\Dusk\Driver;
4+
5+
use Facebook\WebDriver\Remote\DesiredCapabilities;
6+
use Facebook\WebDriver\Remote\HttpCommandExecutor;
7+
use Facebook\WebDriver\Remote\RemoteWebDriver;
8+
use Facebook\WebDriver\WebDriverCapabilities;
9+
10+
class AsyncWebDriver extends RemoteWebDriver
11+
{
12+
/**
13+
* Create a new asynchronous web driver instance.
14+
*
15+
* @param string $selenium_server_url
16+
* @param DesiredCapabilities|array|null $desired_capabilities
17+
* @param int|null $connection_timeout_in_ms
18+
* @param int|null $request_timeout_in_ms
19+
* @param string|null $http_proxy
20+
* @param int|null $http_proxy_port
21+
* @param DesiredCapabilities|null $required_capabilities
22+
* @return AsyncWebDriver
23+
*/
24+
public static function create(
25+
$selenium_server_url = 'http://localhost:4444/wd/hub',
26+
$desired_capabilities = null,
27+
$connection_timeout_in_ms = null,
28+
$request_timeout_in_ms = null,
29+
$http_proxy = null,
30+
$http_proxy_port = null,
31+
DesiredCapabilities $required_capabilities = null,
32+
): AsyncWebDriver {
33+
$factory = new AsyncWebDriverFactory(
34+
$selenium_server_url, $desired_capabilities, $connection_timeout_in_ms,
35+
$request_timeout_in_ms, $http_proxy, $http_proxy_port, $required_capabilities,
36+
);
37+
38+
return $factory();
39+
}
40+
41+
/**
42+
* Public constructor to allow for instantiation via factory.
43+
*
44+
* @param AsyncCommandExecutor $commandExecutor
45+
* @param string $sessionId
46+
* @param WebDriverCapabilities $capabilities
47+
* @param bool $isW3cCompliant
48+
*/
49+
public function __construct(
50+
AsyncCommandExecutor $commandExecutor,
51+
string $sessionId,
52+
WebDriverCapabilities $capabilities,
53+
bool $isW3cCompliant = false,
54+
) {
55+
parent::__construct($commandExecutor, $sessionId, $capabilities, $isW3cCompliant);
56+
}
57+
}

0 commit comments

Comments
 (0)