274 lines
8.3 KiB
PHP
Executable File
274 lines
8.3 KiB
PHP
Executable File
<?php
|
|
|
|
namespace Tests;
|
|
|
|
use Facebook\WebDriver\Chrome\ChromeOptions;
|
|
use Facebook\WebDriver\Remote\DesiredCapabilities;
|
|
use Facebook\WebDriver\Remote\RemoteWebDriver;
|
|
use Laravel\Dusk\TestCase as BaseTestCase;
|
|
use PHPUnit\Framework\Attributes\BeforeClass;
|
|
|
|
abstract class DuskTestCase extends BaseTestCase
|
|
{
|
|
private const LOOPBACK = '127.0.0.1';
|
|
|
|
use CreatesApplication;
|
|
|
|
/**
|
|
* Keep the local Laravel server process alive for the Dusk run.
|
|
*
|
|
* @var resource|null
|
|
*/
|
|
private static $laravelServerProcess = null;
|
|
|
|
/**
|
|
* Prepare for Dusk test execution.
|
|
*/
|
|
#[BeforeClass]
|
|
public static function prepare(): void
|
|
{
|
|
static::configureDuskBaseUrl();
|
|
static::ensureLaravelServerIsReachable();
|
|
|
|
if (! static::runningInSail()) {
|
|
static::ensureChromeDriverBinaryAlias();
|
|
static::startChromeDriver(['--port=9515']);
|
|
static::waitForTcpPort(self::LOOPBACK, 9515, 8.0, 'ChromeDriver');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create the RemoteWebDriver instance.
|
|
*/
|
|
protected function driver(): RemoteWebDriver
|
|
{
|
|
$options = (new ChromeOptions)->addArguments([
|
|
'--disable-gpu',
|
|
'--headless=new',
|
|
'--window-size=1920,1080',
|
|
'--no-sandbox',
|
|
'--disable-dev-shm-usage',
|
|
'--disable-search-engine-choice-screen',
|
|
'--ignore-certificate-errors',
|
|
'--allow-insecure-localhost',
|
|
'--disable-features=Translate,BackForwardCache,AcceptCHFrame',
|
|
]);
|
|
|
|
return RemoteWebDriver::create(
|
|
$_ENV['DUSK_DRIVER_URL'] ?? 'http://127.0.0.1:9515',
|
|
DesiredCapabilities::chrome()->setCapability(
|
|
ChromeOptions::CAPABILITY,
|
|
$options,
|
|
),
|
|
);
|
|
}
|
|
|
|
private static function configureDuskBaseUrl(): void
|
|
{
|
|
// Do not trust an already-listening local server. In local Dusk runs it
|
|
// may be Octane, Valet, or a stale `artisan serve` process using a
|
|
// different database. Pick a free port for this PHPUnit run and start a
|
|
// matching Laravel server below, unless explicitly told to use an
|
|
// external server.
|
|
$useExternalServer = filter_var(
|
|
$_ENV['DUSK_USE_EXTERNAL_SERVER'] ?? getenv('DUSK_USE_EXTERNAL_SERVER') ?: false,
|
|
FILTER_VALIDATE_BOOL,
|
|
);
|
|
|
|
if ($useExternalServer) {
|
|
$url = rtrim((string) ($_ENV['DUSK_APP_URL'] ?? getenv('DUSK_APP_URL') ?: 'http://127.0.0.1:8000'), '/');
|
|
} else {
|
|
$url = 'http://127.0.0.1:' . static::availablePort(8011, 8099);
|
|
}
|
|
|
|
$_ENV['DUSK_APP_URL'] = $url;
|
|
$_SERVER['DUSK_APP_URL'] = $url;
|
|
putenv('DUSK_APP_URL=' . $url);
|
|
|
|
$_ENV['APP_URL'] = $url;
|
|
$_SERVER['APP_URL'] = $url;
|
|
putenv('APP_URL=' . $url);
|
|
|
|
if (property_exists(BaseTestCase::class, 'baseUrl')) {
|
|
static::$baseUrl = $url;
|
|
}
|
|
}
|
|
|
|
private static function ensureLaravelServerIsReachable(): void
|
|
{
|
|
$url = rtrim((string) ($_ENV['APP_URL'] ?? 'http://127.0.0.1:8000'), '/');
|
|
$parts = parse_url($url);
|
|
|
|
$scheme = $parts['scheme'] ?? 'http';
|
|
$host = $parts['host'] ?? self::LOOPBACK;
|
|
$port = (int) ($parts['port'] ?? ($scheme === 'https' ? 443 : 80));
|
|
|
|
$useExternalServer = filter_var(
|
|
$_ENV['DUSK_USE_EXTERNAL_SERVER'] ?? getenv('DUSK_USE_EXTERNAL_SERVER') ?: false,
|
|
FILTER_VALIDATE_BOOL,
|
|
);
|
|
|
|
if ($useExternalServer && static::canConnect($host, $port)) {
|
|
return;
|
|
}
|
|
|
|
static::startLaravelServer($host, $port);
|
|
static::waitForTcpPort($host, $port, 15.0, 'Laravel Dusk application server');
|
|
}
|
|
|
|
private static function startLaravelServer(string $host, int $port): void
|
|
{
|
|
$root = dirname(__DIR__);
|
|
$logPath = $root . '/storage/logs/dusk-server.log';
|
|
|
|
if (! is_dir(dirname($logPath))) {
|
|
@mkdir(dirname($logPath), 0775, true);
|
|
}
|
|
|
|
$command = sprintf(
|
|
'%s %s serve --host=%s --port=%d',
|
|
escapeshellarg(PHP_BINARY),
|
|
escapeshellarg($root . '/artisan'),
|
|
escapeshellarg($host),
|
|
$port,
|
|
);
|
|
|
|
$descriptors = [
|
|
0 => ['pipe', 'r'],
|
|
1 => ['file', $logPath, 'a'],
|
|
2 => ['file', $logPath, 'a'],
|
|
];
|
|
|
|
$env = static::scalarEnvironment([
|
|
...getenv(),
|
|
...$_SERVER,
|
|
...$_ENV,
|
|
'APP_ENV' => $_ENV['APP_ENV'] ?? 'dusk',
|
|
'APP_URL' => $_ENV['APP_URL'] ?? "http://{$host}:{$port}",
|
|
'DUSK_APP_URL' => $_ENV['DUSK_APP_URL'] ?? "http://{$host}:{$port}",
|
|
'CACHE_STORE' => $_ENV['CACHE_STORE'] ?? 'array',
|
|
'QUEUE_CONNECTION' => $_ENV['QUEUE_CONNECTION'] ?? 'sync',
|
|
'MAIL_MAILER' => $_ENV['MAIL_MAILER'] ?? 'array',
|
|
]);
|
|
|
|
self::$laravelServerProcess = proc_open($command, $descriptors, $pipes, $root, $env);
|
|
|
|
if (isset($pipes[0]) && is_resource($pipes[0])) {
|
|
fclose($pipes[0]);
|
|
}
|
|
|
|
if (! is_resource(self::$laravelServerProcess)) {
|
|
throw new \RuntimeException('Unable to start Laravel Dusk application server. Check storage/logs/dusk-server.log.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<mixed> $environment
|
|
*
|
|
* @return array<string, string>
|
|
*/
|
|
private static function scalarEnvironment(array $environment): array
|
|
{
|
|
$filtered = [];
|
|
|
|
foreach ($environment as $key => $value) {
|
|
if (! is_string($key) || $key === '') {
|
|
continue;
|
|
}
|
|
|
|
if (is_bool($value)) {
|
|
$filtered[$key] = $value ? 'true' : 'false';
|
|
|
|
continue;
|
|
}
|
|
|
|
if (is_scalar($value) || $value === null) {
|
|
$filtered[$key] = (string) $value;
|
|
}
|
|
}
|
|
|
|
return $filtered;
|
|
}
|
|
|
|
private static function availablePort(int $from, int $to): int
|
|
{
|
|
for ($port = $from; $port <= $to; $port++) {
|
|
if (! static::canConnect(self::LOOPBACK, $port)) {
|
|
return $port;
|
|
}
|
|
}
|
|
|
|
throw new \RuntimeException("No free TCP port available between {$from} and {$to} for Dusk.");
|
|
}
|
|
|
|
private static function canConnect(string $host, int $port): bool
|
|
{
|
|
$connection = @fsockopen($host, $port, $errno, $errstr, 0.25);
|
|
|
|
if (! is_resource($connection)) {
|
|
return false;
|
|
}
|
|
|
|
fclose($connection);
|
|
|
|
return true;
|
|
}
|
|
|
|
private static function waitForTcpPort(string $host, int $port, float $seconds, string $service): void
|
|
{
|
|
$deadline = microtime(true) + $seconds;
|
|
|
|
do {
|
|
if (static::canConnect($host, $port)) {
|
|
return;
|
|
}
|
|
|
|
usleep(100_000);
|
|
} while (microtime(true) < $deadline);
|
|
|
|
$log = dirname(__DIR__) . '/storage/logs/dusk-server.log';
|
|
$hint = is_file($log) ? " Check {$log}." : '';
|
|
|
|
throw new \RuntimeException("{$service} is not reachable at {$host}:{$port}.{$hint}");
|
|
}
|
|
|
|
/**
|
|
* Dusk may install a differently named macOS Apple Silicon binary.
|
|
*
|
|
* This method intentionally does not use base_path(), app(), or Artisan because
|
|
* PHPUnit calls BeforeClass before Laravel has booted the application container.
|
|
*/
|
|
private static function ensureChromeDriverBinaryAlias(): void
|
|
{
|
|
$binDirectory = dirname(__DIR__) . '/vendor/laravel/dusk/bin';
|
|
$expected = $binDirectory . '/chromedriver-mac-arm';
|
|
|
|
if (is_file($expected)) {
|
|
@chmod($expected, 0755);
|
|
|
|
return;
|
|
}
|
|
|
|
if (! is_dir($binDirectory)) {
|
|
@mkdir($binDirectory, 0755, true);
|
|
}
|
|
|
|
$candidates = array_values(array_filter(
|
|
glob($binDirectory . '/chromedriver*') ?: [],
|
|
static function (string $path) use ($expected): bool {
|
|
return is_file($path)
|
|
&& $path !== $expected
|
|
&& ! str_ends_with($path, '.zip')
|
|
&& ! str_ends_with($path, '.txt');
|
|
},
|
|
));
|
|
|
|
if ($candidates === []) {
|
|
return;
|
|
}
|
|
|
|
copy($candidates[0], $expected);
|
|
@chmod($expected, 0755);
|
|
}
|
|
}
|