382 lines
12 KiB
PHP
382 lines
12 KiB
PHP
<?php
|
|
|
|
namespace App\Browser;
|
|
|
|
use App\Models\Job;
|
|
use App\Models\JobArtifact;
|
|
use App\Models\JobRun;
|
|
use App\Notification\Notifications\JobDebugNotification;
|
|
use App\Notification\Notifications\JobErrorNotification;
|
|
use App\Notification\Providers\AllNotification;
|
|
use Closure;
|
|
use Exception;
|
|
use Facebook\WebDriver\Chrome\ChromeOptions;
|
|
use Facebook\WebDriver\Remote\DesiredCapabilities;
|
|
use Facebook\WebDriver\Remote\RemoteWebDriver;
|
|
use Facebook\WebDriver\Remote\RemoteWebElement;
|
|
use Facebook\WebDriver\WebDriverBy;
|
|
use Illuminate\Bus\Queueable;
|
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
use Illuminate\Foundation\Bus\Dispatchable;
|
|
use Illuminate\Queue\InteractsWithQueue;
|
|
use Illuminate\Queue\SerializesModels;
|
|
use Laravel\Dusk\Browser;
|
|
use Laravel\Dusk\Chrome\SupportsChrome;
|
|
use Laravel\Dusk\Concerns\ProvidesBrowser;
|
|
use Log;
|
|
use PHPUnit\Framework\Attributes\BeforeClass;
|
|
use Throwable;
|
|
|
|
abstract class BrowserJob implements ShouldQueue
|
|
{
|
|
use SupportsChrome, ProvidesBrowser, Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
|
|
|
public int $jobId;
|
|
|
|
public $timeout = 300; // 5 minutes
|
|
|
|
public function __construct(int $jobId)
|
|
{
|
|
$this->jobId = $jobId;
|
|
}
|
|
|
|
/**
|
|
* Execute the callback in a browser
|
|
* @param Closure $callback function with a Browser as parameter
|
|
* @return void
|
|
*/
|
|
private function executeInBrowser(Closure $callback): ?JobRun
|
|
{
|
|
|
|
$this->prepare();
|
|
$this->setUp();
|
|
|
|
$this->browse(function (Browser $browser) use ($callback, &$log) {
|
|
try {
|
|
$browser->driver->manage()->timeouts()->implicitlyWait(20);
|
|
$log = $callback($browser);
|
|
// } catch (Exception $e) {
|
|
// $browser->screenshot("failure-{$this->jobId}");
|
|
// dump($e);
|
|
// throw $e;
|
|
}
|
|
catch (Throwable $e) {
|
|
$browser->screenshot(JobErrorScreenshot::getFileName($this->jobId));
|
|
AllNotification::send(new JobErrorNotification($this->jobId, $e->getMessage()));
|
|
dump($e);
|
|
throw $e;
|
|
} finally {
|
|
$browser->quit();
|
|
return [];
|
|
}
|
|
});
|
|
|
|
return $log;
|
|
}
|
|
|
|
/**
|
|
* Execute the job
|
|
* @return void
|
|
*/
|
|
public function execute(): ?JobRun {
|
|
return $this->executeInBrowser(function (Browser $browser): ?JobRun {
|
|
return $this->run($browser);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Execute the job test
|
|
* @return void
|
|
*/
|
|
public function executeTest(): ?JobRun {
|
|
return $this->executeInBrowser(function (Browser $browser): ?JobRun {
|
|
return $this->runTest($browser);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Steps that run in the browser
|
|
* @param \Laravel\Dusk\Browser $browser
|
|
* @return void
|
|
*/
|
|
abstract public function run(Browser $browser): ?JobRun;
|
|
|
|
abstract public function runTest(Browser $browser): ?JobRun;
|
|
|
|
/**
|
|
* Prepare for Dusk test execution.
|
|
* @unused
|
|
*/
|
|
#[BeforeClass]
|
|
public static function prepare(): void
|
|
{
|
|
if (config("dusk.driver.url") == null && !(isset($_ENV['LARAVEL_SAIL']) && $_ENV['LARAVEL_SAIL'] == '1')) {
|
|
static::startChromeDriver(['--port=9515']);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Register the base URL with Dusk.
|
|
*
|
|
* @return void
|
|
*/
|
|
protected function setUp(): void
|
|
{
|
|
Browser::$baseUrl = "https://pdftools.matthiasg.dev/";
|
|
|
|
Browser::$storeScreenshotsAt = base_path('app/Browser/screenshots');
|
|
|
|
Browser::$storeConsoleLogAt = base_path('app/Browser/console');
|
|
|
|
Browser::$storeSourceAt = base_path('app/Browser/source');
|
|
|
|
/*Browser::$userResolver = function () {
|
|
return $this->user();
|
|
}; */
|
|
}
|
|
|
|
protected function makeSimpleJobRun(bool $success, string $name, string $content): JobRun {
|
|
$artifact = new JobRun([
|
|
"job_id" => $this->jobId,
|
|
"success" => $success
|
|
]);
|
|
$artifact->save();
|
|
$artifact->artifacts()->save(new JobArtifact([
|
|
"name" => $name,
|
|
"content" => $content,
|
|
]));
|
|
return $artifact;
|
|
}
|
|
|
|
/**
|
|
* Create the RemoteWebDriver instance.
|
|
*/
|
|
protected function driver(): RemoteWebDriver
|
|
{
|
|
$options = (new ChromeOptions)->addArguments(collect([
|
|
$this->shouldStartMaximized() ? '--start-maximized' : '--window-size=1360,1020',
|
|
'--disable-search-engine-choice-screen',
|
|
'--disable-gpu',
|
|
'--no-sandbox',
|
|
'--disable-setuid-sandbox',
|
|
'--whitelisted-ips=""',
|
|
'--disable-dev-shm-usage',
|
|
'--user-data-dir=/home/seluser/profile/nigga', // seems that selenium doesn't like docker having a volume on the exact same folder ("session not created: probably user data directory is already in use")
|
|
])->all());
|
|
|
|
return RemoteWebDriver::create(
|
|
config("dusk.driver.url", 'http://localhost:9515'),
|
|
DesiredCapabilities::chrome()->setCapability(
|
|
ChromeOptions::CAPABILITY,
|
|
$options
|
|
)
|
|
->setCapability('timeouts', [
|
|
'implicit' => 20000, // 20 seconds
|
|
'pageLoad' => 300000, // 5 minutes
|
|
'script' => 30000, // 30 seconds
|
|
]),
|
|
4000,
|
|
$this->timeout * 1000
|
|
);
|
|
}
|
|
|
|
public function terminate() {
|
|
$this->browse(function (Browser $browser) {
|
|
$browser->quit();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Determine whether the Dusk command has disabled headless mode.
|
|
*/
|
|
protected function hasHeadlessDisabled(): bool
|
|
{
|
|
return config('dusk.headlessDisabled', false);
|
|
}
|
|
|
|
/**
|
|
* Determine if the browser window should start maximized.
|
|
*/
|
|
protected function shouldStartMaximized(): bool
|
|
{
|
|
return config('dusk.shouldStartMaximized', false);
|
|
}
|
|
|
|
/**
|
|
* Register an "after class" tear down callback.
|
|
*
|
|
* @param \Closure $callback
|
|
* @return void
|
|
*/
|
|
public static function afterClass(Closure $callback)
|
|
{
|
|
static::$afterClassCallbacks[] = $callback;
|
|
}
|
|
|
|
public static function name()
|
|
{
|
|
return "test";
|
|
}
|
|
|
|
public static function dataName()
|
|
{
|
|
return "dataTest";
|
|
}
|
|
|
|
/**
|
|
* Execute the job.
|
|
*/
|
|
public function handle(): void
|
|
{
|
|
if (Job::find($this->jobId)->is_active) {
|
|
$this->execute();
|
|
}
|
|
}
|
|
|
|
public function reschedule($minutes) {
|
|
$this::dispatch()->delay(now()->addMinutes($minutes));
|
|
}
|
|
|
|
|
|
// === BROWSER MACROS ===
|
|
protected function waitForAndClickText(Browser $browser, string $text, int $timeout = 30, bool $ignoreCase = true) {
|
|
$browser->waitForText($text, $timeout, $ignoreCase);
|
|
$this->findElementContainingText($browser, $text, $ignoreCase)?->click();
|
|
}
|
|
|
|
protected function waitForElementContainingTextAndGetIt(Browser $browser, string $text, int $timeout = 30, bool $ignoreCase = true): RemoteWebElement|null {
|
|
try {
|
|
$browser->waitForText($text, $timeout, $ignoreCase);
|
|
return $this->findElementContainingText($browser, $text, $ignoreCase);
|
|
} catch (Exception $e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
protected function findElementContainingText(Browser $browser, string $text, bool $ignoreCase = true): RemoteWebElement|null {
|
|
try {
|
|
if ($ignoreCase) {
|
|
return $browser->driver->findElement(WebDriverBy::xpath("//*[{$this->xpathContainsIgnoreCase($text)}]"));
|
|
} else {
|
|
return $browser->driver->findElement(WebDriverBy::xpath("//*[contains(text(), \"{$text}\")]"));
|
|
}
|
|
} catch (Exception $e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private function xpathContainsIgnoreCase(string $needle, string $haystack = "text()") {
|
|
$needle = strtolower($needle);
|
|
return "contains(translate({$haystack}, \"ABCDEFGHIJKLMNOPQRSTUVWXYZ\", \"abcdefghijklmnopqrstuvwxyz\"), \"{$needle}\")";
|
|
}
|
|
|
|
protected function waitForAndClickElementContainingText(Browser $browser, string $elementXpath, string $text, int $timeout = 30, bool $ignoreCase = true) {
|
|
$browser->waitForText($text, $timeout, $ignoreCase);
|
|
$this->findElementContainingElementWithText($browser, $elementXpath, $text, $ignoreCase)?->click();
|
|
}
|
|
|
|
protected function waitForElementContainingElementWithTextAndGetIt(Browser $browser, string $elementXpath, string $text, int $timeout = 30, bool $ignoreCase = true): RemoteWebElement|null {
|
|
try {
|
|
$browser->waitForText($text, $timeout, $ignoreCase);
|
|
sleep(2);
|
|
return $this->findElementContainingElementWithText($browser, $elementXpath, $text, $ignoreCase);
|
|
} catch (Exception $e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
protected function findElementContainingElementWithText(Browser $browser, string $elementXpath, string $text, bool $ignoreCase = true): RemoteWebElement|null {
|
|
try {
|
|
if ($ignoreCase) {
|
|
return $browser->driver->findElement(WebDriverBy::xpath("{$elementXpath}[.//*[{$this->xpathContainsIgnoreCase($text)}]]"));
|
|
} else {
|
|
return $browser->driver->findElement(WebDriverBy::xpath("{$elementXpath}[.//*[contains(text(), \"{$text}\")]]"));
|
|
}
|
|
} catch (Exception $e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse a string for use in JavaScript.
|
|
*
|
|
* @param string $string The string to parse.
|
|
* @return string The parsed string.
|
|
*/
|
|
private function parseJavaScriptString(string $string): string
|
|
{
|
|
$string = str_replace("\n", "\\n", $string);
|
|
return str_replace("'", "\\'", $string);
|
|
}
|
|
|
|
/**
|
|
* Type text into an input field using JavaScript.
|
|
*
|
|
* @param Browser $browser
|
|
* @param string $text The text to type.
|
|
* @param string $querySelector The CSS selector for the input field.
|
|
*/
|
|
public function setInputValue(Browser $browser, string $text, string $querySelector): void
|
|
{
|
|
$text = $this->parseJavaScriptString($text);
|
|
$querySelector = $this->parseJavaScriptString($querySelector);
|
|
|
|
$browser->script("
|
|
let element = document.querySelector('{$querySelector}');
|
|
if (element) {
|
|
element.focus();
|
|
element.value = '{$text}';
|
|
element.dispatchEvent(new Event('input', { bubbles: true }));
|
|
} else {
|
|
console.error('Element not found: {$querySelector}');
|
|
}
|
|
");
|
|
}
|
|
|
|
/**
|
|
* Paste text into an element using JavaScript.
|
|
* Can be useful for non input elements that need to get text. For example works
|
|
* with `contenteditable` elements.
|
|
*
|
|
* @param Browser $browser
|
|
* @param string $text The text to paste.
|
|
* @param string $querySelector The CSS selector for the input field.
|
|
*/
|
|
public function pasteText(Browser $browser, string $text, string $querySelector): void
|
|
{
|
|
try {
|
|
$text = $this->parseJavaScriptString($text);
|
|
$querySelector = $this->parseJavaScriptString($querySelector);
|
|
|
|
$browser->script("
|
|
var el = document.querySelector('{$querySelector}'), text = '{$text}';
|
|
el.focus();
|
|
const dataTransfer = new DataTransfer();
|
|
dataTransfer.setData('text', text);
|
|
const event = new ClipboardEvent('paste', {
|
|
clipboardData: dataTransfer,
|
|
bubbles: true
|
|
});
|
|
el.dispatchEvent(event)
|
|
");
|
|
} catch (Exception $e) {
|
|
$errorMessage = "Failed to paste text into element: {$querySelector} - " . $e->getMessage() . "\n With text: {$text}";
|
|
Log::error($errorMessage);
|
|
$browser->screenshot(JobDebugScreenshot::getFileName($this->jobId));
|
|
AllNotification::send(new JobDebugNotification($this->jobId, $errorMessage));
|
|
}
|
|
}
|
|
|
|
public function clickElementWithJavaScript(Browser $browser, string $xPathSelector): void
|
|
{
|
|
$browser->script("
|
|
var element = document.evaluate('{$xPathSelector}', document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
|
|
if (element) {
|
|
element.click();
|
|
} else {
|
|
console.error('Element not found: {$xPathSelector}');
|
|
}
|
|
");
|
|
}
|
|
}
|