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)); } } }