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
13 changes: 13 additions & 0 deletions src/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Pest\Browser;

use Pest\Browser\Contracts\HttpServer;
use Pest\Browser\Enums\BrowserType;
use Pest\Browser\Enums\ColorScheme;
use Pest\Browser\Playwright\Playwright;
Expand Down Expand Up @@ -114,4 +115,16 @@ public function diff(): self

return $this;
}

/**
* Sets the browsers http server class.
*
* @param class-string<HttpServer> $class
*/
public function httpServer(string $class): self
{
ServerManager::setHttpServerClass($class);

return $this;
}
}
323 changes: 323 additions & 0 deletions src/Drivers/SymfonyHttpServer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,323 @@
<?php

declare(strict_types=1);

namespace Pest\Browser\Drivers;

use Amp\ByteStream\ReadableResourceStream;
use Amp\Http\Server\DefaultErrorHandler;
use Amp\Http\Server\HttpServer as AmpHttpServer;
use Amp\Http\Server\HttpServerStatus;
use Amp\Http\Server\Request as AmpRequest;
use Amp\Http\Server\RequestHandler\ClosureRequestHandler;
use Amp\Http\Server\Response;
use Amp\Http\Server\SocketHttpServer;
use Exception;
use Pest\Browser\Contracts\HttpServer;
use Pest\Browser\Exceptions\ServerNotFoundException;
use Pest\Browser\Execution;
use Pest\Browser\GlobalState;
use Psr\Log\NullLogger;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\HttpKernel\TerminableInterface;
use Symfony\Component\Mime\MimeTypes;
use Throwable;

/**
* @internal
*
* @codeCoverageIgnore
*/
final class SymfonyHttpServer implements HttpServer
{
/**
* The underlying socket server instance, if any.
*/
private ?AmpHttpServer $socket = null;

/**
* The original asset URL, if set.
*/
private ?string $originalAssetUrl = null;

/**
* The last throwable that occurred during the server's execution.
*/
private ?Throwable $lastThrowable = null;

/**
* Creates a new Symfony http server instance.
*/
public function __construct(
public readonly string $host,
public readonly int $port,
) {
//
}

/**
* Destroy the server instance and stop listening for incoming connections.
*/
public function __destruct()
{
// @codeCoverageIgnoreStart
// $this->stop();
}

/**
* Rewrite the given URL to match the server's host and port.
*/
public function rewrite(string $url): string
{
if (! str_starts_with($url, 'http://') && ! str_starts_with($url, 'https://')) {
$url = mb_ltrim($url, '/');

$url = '/'.$url;
}

$parts = parse_url($url);
$path = $parts['path'] ?? '/';
$query = $parts['query'] ?? '';
$fragment = $parts['fragment'] ?? '';

return $this->url().$path.($query !== '' ? '?'.$query : '').($fragment !== '' ? '#'.$fragment : '');
}

/**
* Start the server and listen for incoming connections.
*/
public function start(): void
{
if ($this->socket instanceof AmpHttpServer) {
return;
}

$this->socket = $server = SocketHttpServer::createForDirectAccess(new NullLogger());

$server->expose("{$this->host}:{$this->port}");
$server->start(
new ClosureRequestHandler($this->handleRequest(...)),
new DefaultErrorHandler(),
);
}

/**
* Stop the server and close all connections.
*/
public function stop(): void
{
// @codeCoverageIgnoreStart
if ($this->socket instanceof AmpHttpServer) {
$this->flush();

if ($this->socket instanceof AmpHttpServer) {
if (in_array($this->socket->getStatus(), [HttpServerStatus::Starting, HttpServerStatus::Started], true)) {
$this->socket->stop();
}

$this->socket = null;
}
}
}

/**
* Flush pending requests and close all connections.
*/
public function flush(): void
{
if (! $this->socket instanceof AmpHttpServer) {
return;
}

Execution::instance()->tick();

$this->lastThrowable = null;
}

/**
* Bootstrap the server and set the application URL.
*/
public function bootstrap(): void
{
$this->start();

if (is_string($_ENV['DEFAULT_URI'] ?? null)) {
$this->setOriginalAssetUrl($_ENV['DEFAULT_URI']);
}
}

/**
* Get the last throwable that occurred during the server's execution.
*/
public function lastThrowable(): ?Throwable
{
return $this->lastThrowable;
}

/**
* Throws the last throwable if it should be thrown.
*
* @throws Throwable
*/
public function throwLastThrowableIfNeeded(): void
{
if (! $this->lastThrowable instanceof Throwable) {
return;
}

throw $this->lastThrowable;
}

/**
* Get the public path for the given path.
*/
private function url(): string
{
if (! $this->socket instanceof AmpHttpServer) {
throw new ServerNotFoundException('The HTTP server is not running.');
}

return sprintf('http://%s:%d', $this->host, $this->port);
}

/**
* Sets the original asset URL.
*/
private function setOriginalAssetUrl(string $url): void
{
$this->originalAssetUrl = mb_rtrim($url, '/');
}

/**
* Handle the incoming request and return a response.
*/
private function handleRequest(AmpRequest $request): Response
{
GlobalState::flush();

if (Execution::instance()->isWaiting() === false) {
Execution::instance()->tick();
}

$publicPath = getcwd().DIRECTORY_SEPARATOR.(is_string($_ENV['PUBLIC_PATH'] ?? null) ? $_ENV['PUBLIC_PATH'] : 'public');
$filepath = $publicPath.$request->getUri()->getPath();
if (file_exists($filepath) && ! is_dir($filepath)) {
return $this->asset($filepath);
}

$kernelClass = is_string($_ENV['KERNEL_CLASS'] ?? null) ? $_ENV['KERNEL_CLASS'] : 'App\Kernel';
if (class_exists($kernelClass) === false) {
$this->lastThrowable = new Exception('You must define the test kernel class environment variable: KERNEL_CLASS.');

throw $this->lastThrowable;
}
/** @var KernelInterface&TerminableInterface $kernel */
$kernel = new $kernelClass($_ENV['APP_ENV'], (bool) $_ENV['APP_DEBUG']);

$contentType = $request->getHeader('content-type') ?? '';
$method = mb_strtoupper($request->getMethod());
$rawBody = (string) $request->getBody();
$parameters = [];
if ($method !== 'GET' && str_starts_with(mb_strtolower($contentType), 'application/x-www-form-urlencoded')) {
parse_str($rawBody, $parameters);
}

$symfonyRequest = Request::create(
(string) $request->getUri(),
$method,
$parameters,
$request->getCookies(),
[], // @TODO files...
[], // @TODO server variables...
$rawBody
);

$symfonyRequest->headers->add($request->getHeaders());

try {
$response = $kernel->handle($symfonyRequest);
} catch (Throwable $e) {
$this->lastThrowable = $e;

throw $e;
}

$kernel->terminate($symfonyRequest, $response);

if (property_exists($response, 'exception') && $response->exception !== null) {
assert($response->exception instanceof Throwable);

$this->lastThrowable = $response->exception;
}

$content = $response->getContent();

if ($content === false) {
try {
ob_start();
$response->sendContent();
} finally {
// @phpstan-ignore-next-line
$content = mb_trim(ob_get_clean());
}
}

return new Response(
$response->getStatusCode(),
$response->headers->all(), // @phpstan-ignore-line
$content,
);
}

/**
* Return an asset response.
*/
private function asset(string $filepath): Response
{
$file = fopen($filepath, 'r');

if ($file === false) {
return new Response(404);
}

$mimeTypes = new MimeTypes();
$contentType = $mimeTypes->getMimeTypes(pathinfo($filepath, PATHINFO_EXTENSION));

$contentType = $contentType[0] ?? 'application/octet-stream';

if (str_ends_with($filepath, '.js') || str_ends_with($filepath, '.css')) {
$temporaryStream = fopen('php://temp', 'r+');
assert($temporaryStream !== false, 'Failed to open temporary stream.');

// @phpstan-ignore-next-line
$temporaryContent = fread($file, (int) filesize($filepath));

assert($temporaryContent !== false, 'Failed to open temporary stream.');

$content = $this->rewriteAssetUrl($temporaryContent);

fwrite($temporaryStream, $content);

rewind($temporaryStream);

$file = $temporaryStream;
}

return new Response(200, [
'Content-Type' => $contentType,
], new ReadableResourceStream($file));
}

/**
* Rewrite the asset URL in the given content.
*/
private function rewriteAssetUrl(string $content): string
{
if ($this->originalAssetUrl === null) {
return $content;
}

return str_replace($this->originalAssetUrl, $this->url(), $content);
}
}
24 changes: 22 additions & 2 deletions src/ServerManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ final class ServerManager
*/
private ?HttpServer $http = null;

/**
* The HTTP server class.
*
* @param class-string<HttpServer> $class
*/
private static ?string $httpServerClass = null;

/**
* Gets the singleton instance of the server manager.
*/
Expand All @@ -49,6 +56,16 @@ public static function instance(): self
return self::$instance ??= new self();
}

/**
* Sets the browsers http server class.
*
* @param class-string<HttpServer> $class
*/
public static function setHttpServerClass(string $class): void
{
self::$httpServerClass = $class;
}

/**
* Returns the Playwright server process instance.
*/
Expand Down Expand Up @@ -81,8 +98,11 @@ public function playwright(): PlaywrightServer
*/
public function http(): HttpServer
{
return $this->http ??= match (function_exists('app_path')) {
true => new LaravelHttpServer(
$httpServer = self::$httpServerClass !== null ? new self::$httpServerClass(self::DEFAULT_HOST, Port::find()) : null;

return $this->http ??= match (true) {
$httpServer instanceof HttpServer => $httpServer,
function_exists('app_path') => new LaravelHttpServer(
self::DEFAULT_HOST,
Port::find(),
),
Expand Down