diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2c34956..1cda955 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -62,6 +62,7 @@ jobs: - name: Run tests env: + BUILD_MATRIX: PHP ${{ matrix.php }} BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }} BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} run: | diff --git a/.gitignore b/.gitignore index fc2d621..788d08c 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ Desktop.ini .env.* .env .php-version +.runs .tool-versions /*.session.sql coveralls-upload.json diff --git a/src/BrowserStack.php b/src/BrowserStack.php new file mode 100644 index 0000000..58fed29 --- /dev/null +++ b/src/BrowserStack.php @@ -0,0 +1,266 @@ + $browsers + */ + public function __construct( + private Collection $browsers + ) { + // . + } + + /** + * Execute javascript command on BrowserStack. + * + * @link https://www.browserstack.com/docs/automate/selenium/js-executors + */ + public function executeCommand(string $action, ?array $arguments = null): mixed + { + $command = \array_filter([ + 'action' => $action, + 'arguments' => $arguments, + ]); + + return $this->browsers->each( + fn (Browser $browser) => $browser->driver->executeScript('browserstack_executor: '.\json_encode($command)) + ); + } + + /** + * Set session status on BrowserStack. + * + * @link https://www.browserstack.com/docs/automate/selenium/js-executors + */ + public function setSessionStatus(string $status, string $reason): void + { + $this->executeCommand('setSessionStatus', [ + 'status' => $status, + 'reason' => $reason, + ]); + } + + /** + * Retreive session details from BrowserStack. + * + * @link https://www.browserstack.com/docs/automate/selenium/js-executors + */ + public function getSessionDetails() + { + return $this->executeCommand('getSessionDetails'); + } + + /** + * Initiate a BrowserStackLocal process. + */ + public static function createLocalProcess(array $arguments = []): LocalProcess + { + return new LocalProcess(\array_merge($arguments, [ + 'key' => self::getAccessKey(), + 'local-identifier' => self::getLocalIdentifier(), + ])); + } + + /** + * Check whether the BrowserStack AccessKey is set. + */ + public static function hasAccessKey(): bool + { + return env('BROWSERSTACK_ACCESS_KEY') !== null; + } + + /** + * Get the BrowserStack AccessKey. + */ + public static function getAccessKey(): ?string + { + return env('BROWSERSTACK_ACCESS_KEY'); + } + + /** + * Get the Local Identifier. + */ + public static function getLocalIdentifier(): string + { + if (self::$localIdentifier) { + return self::$localIdentifier; + } + + if ($project = env('BROWSERSTACK_LOCAL_IDENTIFIER')) { + return $project; + } + + $run = self::getRunsNumber() ?: null; + $sha = \trim(self::getCommitSha().'-'.$run, '- '); + + return self::$localIdentifier = self::getProjectName().'_'.$sha; + } + + /** + * Get the Build Name. + */ + public static function getBuildName(): string + { + if (self::$buildName) { + return self::$buildName; + } + + $build = env('BROWSERSTACK_BUILD_NAME'); + + if ($build && (\strlen($build) > 0 && \strlen($build) <= 255)) { + return self::$buildName = $build; + } + + $numbers = ''; + $branch = env('GITHUB_HEAD_REF', \exec('git branch --show-current')); + $message = self::getRunsMessage().' '.self::getCommitSha(); + + if ($buildMatrix = env('BUILD_MATRIX')) { + $numbers .= \sprintf(', Matrix: %s', $buildMatrix); + } + + if ($runNumber = self::getRunsNumber()) { + $numbers .= \sprintf(', Run: %d', $runNumber); + } + + return self::$buildName = \sprintf('[%s] %s%s', $branch, $message, $numbers); + } + + /** + * Get the Project Name. + */ + public static function getProjectName(): string + { + if (self::$projectName) { + return self::$projectName; + } + + if ($project = env('BROWSERSTACK_PROJECT_NAME')) { + return $project; + } + + if ($project = \env('GITHUB_REPOSITORY')) { + return \explode('/', $project)[1]; + } + + return self::$projectName = \substr(\explode('/', \exec('git remote get-url origin'))[1], 0, -4); + } + + /** + * Get the Driver URL. + */ + public static function getDriverURL(): string + { + if (! self::hasAccessKey()) { + return env('DUSK_DRIVER_URL', 'http://localhost:9515'); + } + + $username = env('BROWSERSTACK_USERNAME'); + + return 'https://'.$username.':'.self::getAccessKey().'@hub.browserstack.com/wd/hub'; + } + + /** + * Check wheter the current branch is dirty. + */ + private static function isDirty(): bool + { + return (bool) \exec('[[ -n `git status --porcelain` ]] && echo 1'); + } + + /** + * Get local runs number. + */ + private static function getRunsNumber(): ?int + { + $runPath = \dirname(__DIR__).'/.runs'; + + // When it runs on github actions, use its run number instead. + if ($runNumber = \env('GITHUB_RUN_ATTEMPT')) { + return (int) $runNumber; + } + + // Check whether the current branch is dirty. + if (! self::isDirty()) { + // If there's any runs file, it must be from previous run. + if (! \file_exists($runPath)) { + // Get rid of it! + \unlink($runPath); + } + + // Don't do anything. + return null; + } + + // Please do cache. + if (self::$runNumber) { + return self::$runNumber; + } + + // Create new runs file if it doesn't exist. + if (! \file_exists($runPath)) { + \file_put_contents($runPath, '0'); + } + + // Get the run number, which is must be from previous run. + // Increment the run number and save it back to the file. + $runNumber = (int) \file_get_contents($runPath); + \file_put_contents($runPath, self::$runNumber = ++$runNumber); + + return self::$runNumber; + } + + /** + * Get run message. + */ + private static function getRunsMessage(): string + { + if (\env('GITHUB_EVENT_NAME') === 'pull_request') { + return \sprintf('PR #%s', \explode('/', \env('GITHUB_REF'))[2]); + } + + if (self::isDirty()) { + return 'uncommited changes'; + } + + return \exec('git log -1 --pretty=%s'); + } + + /** + * Get commit sha in short format. + */ + private static function getCommitSha(): string + { + if ($githubSha = \env('GITHUB_SHA')) { + return \substr($githubSha, 0, 7); + } + + return \exec('git rev-parse --short HEAD'); + } +} diff --git a/src/LocalBinary.php b/src/LocalBinary.php index b88dcb5..6034284 100644 --- a/src/LocalBinary.php +++ b/src/LocalBinary.php @@ -5,10 +5,23 @@ class LocalBinary { /** - * @var string Path to the bin directory. + * Path to the bin directory. */ private static string $directory = __DIR__.'/../bin'; + /** + * Path to the BrowserStackLocal binary. + */ + private static ?string $path = null; + + /** + * Set the path to the custom BrowserStackLocal binary. + */ + public static function use(string $path): void + { + static::$path = $path; + } + /** * Retrieve the download URL for the BrowserStackLocal binary. */ @@ -24,13 +37,17 @@ public static function getDownloadUrl(): string */ public static function getPath(): string { + if (self::$path) { + return self::$path; + } + $os = static::getPlatform(); if ($os === 'win32') { $os .= '.exe'; } - return self::$directory.'/bs-local-'.$os; + return self::getDirectory().'/bs-local-'.$os; } /** diff --git a/src/LocalProcess.php b/src/LocalProcess.php index 4ba74be..e3495eb 100644 --- a/src/LocalProcess.php +++ b/src/LocalProcess.php @@ -16,11 +16,11 @@ class LocalProcess */ protected ?Process $process = null; - public function __construct(?string $binary, array $arguments = []) + public function __construct(array $arguments = []) { - $binary = \realpath($binary ?? LocalBinary::getPath()); + $binary = LocalBinary::getPath(); - if (! $binary) { + if (! \realpath($binary)) { throw new \RuntimeException("Unable to locate the BrowserStackLocal binary: {$binary}"); } @@ -36,6 +36,9 @@ public function __construct(?string $binary, array $arguments = []) } } + /** + * Start the browserstack-local process. + */ public function start(): void { $this->process = new Process($this->commands); @@ -82,6 +85,9 @@ public function stop(): void $this->process->stop(); } + /** + * Check whether browserstack-local process is running. + */ public function isRunning(): bool { return $this->process?->isRunning() ?? false; diff --git a/src/SupportsBrowserStack.php b/src/SupportsBrowserStack.php index 1ab2e4a..d4f9e4c 100644 --- a/src/SupportsBrowserStack.php +++ b/src/SupportsBrowserStack.php @@ -2,246 +2,30 @@ namespace Creasi\DuskBrowserStack; -use Facebook\WebDriver\Remote\DesiredCapabilities; -use Laravel\Dusk\Browser; -use PHPUnit\Runner\Version; - /** - * @mixin \Laravel\Dusk\TestCase - * @mixin \PHPUnit\Framework\TestCase - * - * @property-read static $browsers Illuminate\Support\Collection + * @deprecated use `Creasi\DuskBrowserStack\WithBrowserStack` instead. */ trait SupportsBrowserStack { - /** - * @var string|null The path to the custom BrowserStackLocal binary. - */ - protected static $bslocalBinary; - - /** - * @var LocalProcess The BrowserStackLocal process instance. - */ - protected static $bslocalProcess; - - /** - * Determine if the BrowserStack Key and User is set. - */ - private static function hasBrowserStackKey(): bool - { - return isset($_SERVER['BROWSERSTACK_ACCESS_KEY']) || isset($_ENV['BROWSERSTACK_ACCESS_KEY']); - } - - /** - * Sending assertion result back to BrowserStack. - */ - protected function tearDownSupportsBrowserStack(): void - { - $this->executeBrowserStackCommand('setSessionStatus', $this->getSessionStatus()); - } - - private function withBrowserStackCapabilities(DesiredCapabilities $caps): DesiredCapabilities - { - if (! static::hasBrowserStackKey()) { - return $caps; - } - - $caps - ->setCapability('buildName', self::getBuildName()) - ->setCapability('projectName', self::getProjectName()) - ->setCapability('sessionName', self::getSessionName()) - ->setCapability('acceptInsecureCerts', true); - - if (static::$bslocalProcess->isRunning() && $localId = self::getLocalIdentifier()) { - $caps - ->setCapability('browserstack.local', true) - ->setCapability('browserstack.networkLogs', true) - ->setCapability('browserstack.localIdentifier', $localId); - } - - return $caps; - } + use WithBrowserStack; /** - * Get session name - */ - private static function getSessionName(): string - { - return str(static::class)->classBasename()->replace('Test', '')->headline(); - } - - /** - * Backward compatibility for PHPUnit 9. - */ - private function getSessionStatus(): array - { - if (\version_compare(Version::id(), '10.0.0', '<')) { - return [ - 'status' => $this->hasFailed() ? 'failed' : 'passed', - 'reason' => $this->getStatusMessage(), - ]; - } - - $status = $this->status(); - - return [ - 'status' => $status->isSuccess() ? 'passed' : 'failed', - 'reason' => $status->message(), - ]; - } - - /** - * Get commit sha in short format. + * Check whether the BrowserStack AccessKey is set. * - * @link https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables + * @deprecated use `BrowserStack::hasAccessKey()` instead. */ - private static function getCommitSha(): string + private static function hasBrowserStackKey(): bool { - if ($githubSha = \env('GITHUB_SHA')) { - return \substr($githubSha, 0, 7); - } - - return \exec('echo "$(git rev-parse --short HEAD)"'); + return BrowserStack::hasAccessKey(); } /** - * Get branch name, but if it's a pull request, use the PR number. + * Get the Driver URL. * - * @link https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables - */ - private static function getBranchName(): string - { - static $result; - - if ($result) { - return $result; - } - - $githubRef = \env('GITHUB_REF'); - - if (! $githubRef) { - return $result = \exec('echo "$(git branch --show-current)"'); - } - - $branchOrPullRequest = \explode('/', $githubRef)[2]; - - if (\env('GITHUB_EVENT_NAME') === 'pull_request') { - return $result = \sprintf('PR #%s', $branchOrPullRequest); - } - - return $result = $branchOrPullRequest; - } - - /** - * Get build name. - */ - private static function getBuildName(): string - { - static $result; - - if ($result) { - return $result; - } - - $build = env('BROWSERSTACK_BUILD_NAME'); - - if ($build && (\strlen($build) > 0 && \strlen($build) <= 255)) { - return $result = $build; - } - - $sha = self::getCommitSha(); - $branch = self::getBranchName(); - - return $result = \sprintf('[%s] %s', $sha, $branch); - } - - /** - * Get project name. + * @deprecated use `BrowserStack::getDriverURL()` instead. */ - private static function getProjectName(): string - { - static $result; - - if ($result) { - return $result; - } - - if ($project = \env('BROWSERSTACK_PROJECT_NAME', \env('GITHUB_REPOSITORY'))) { - if (\str_contains($project, '/')) { - $project = \explode('/', $project)[1]; - } - - return $result = $project; - } - - return $result = \substr(\explode('/', \exec('git remote get-url origin'))[1], 0, -4); - } - - /** - * Get local identifier. - */ - private static function getLocalIdentifier(): string - { - static $result; - - if ($result) { - return $result; - } - - $localIdentifier = env('BROWSERSTACK_LOCAL_IDENTIFIER', self::getProjectName().'_'.self::getBuildName()); - - return $result = (string) \str($localIdentifier)->replace('/', '_')->slug(); - } - private static function getDriverURL(): string { - if (static::hasBrowserStackKey()) { - return 'https://'.env('BROWSERSTACK_USERNAME').':'.env('BROWSERSTACK_ACCESS_KEY').'@hub.browserstack.com/wd/hub'; - } - - return env('DUSK_DRIVER_URL', 'http://localhost:9515'); - } - - private function executeBrowserStackCommand(string $action, array $arguments): void - { - if (! static::hasBrowserStackKey()) { - return; - } - - $browsers = collect(static::$browsers ?? []); - $command = \compact('action', 'arguments'); - - $browsers->each( - fn (Browser $browser) => $browser->driver->executeScript('browserstack_executor: '.\json_encode($command)) - ); - } - - /** - * Start the BrowserStackLocal process. - * - * @throws \RuntimeException - */ - public static function startBrowserStackLocal(array $arguments = []): void - { - static::$bslocalProcess = new LocalProcess(static::$bslocalBinary, \array_merge($arguments, [ - 'key' => env('BROWSERSTACK_ACCESS_KEY'), - 'local-identifier' => self::getLocalIdentifier(), - ])); - - static::$bslocalProcess->start(); - - static::afterClass(function () { - if (static::$bslocalProcess) { - static::$bslocalProcess->stop(); - } - }); - } - - /** - * Set the path to the custom BrowserStackLocal. - */ - public static function useBrowserStackLocal(string $path): void - { - static::$bslocalBinary = $path; + return BrowserStack::getDriverURL(); } } diff --git a/src/WithBrowserStack.php b/src/WithBrowserStack.php new file mode 100644 index 0000000..c29e716 --- /dev/null +++ b/src/WithBrowserStack.php @@ -0,0 +1,109 @@ + + */ +trait WithBrowserStack +{ + /** + * Instance of BrowserStack. + */ + private static ?BrowserStack $browserStack = null; + + /** + * The BrowserStackLocal process instance. + */ + private static ?LocalProcess $bslocalProcess; + + /** + * Sent back session status to BrowserStack after each test. + */ + protected function tearDownWithBrowserStack(): void + { + if (\version_compare(Version::id(), '10', '<')) { + $this->browserStack()->setSessionStatus( + $this->hasFailed() ? 'failed' : 'passed', + $this->getStatusMessage() + ); + + return; + } + + $status = $this->status(); + + $this->browserStack()->setSessionStatus( + $status->isSuccess() ? 'passed' : 'failed', + $status->message() + ); + } + + /** + * Get BrowserStack instance. + */ + final protected function browserStack(): BrowserStack + { + if (self::$browserStack) { + return self::$browserStack; + } + + return self::$browserStack = new BrowserStack(static::$browsers); + } + + /** + * Configure the DesiredCapabilities for the BrowserStack session. + * + * @link https://chromedriver.chromium.org/capabilities + */ + protected function withBrowserStackCapabilities(array|DesiredCapabilities $caps): DesiredCapabilities + { + $sessionName = str(static::class)->classBasename()->replace('Test', '')->headline(); + $localId = BrowserStack::getLocalIdentifier(); + + if (\is_array($caps)) { + $caps = DesiredCapabilities::chrome()->setCapability(ChromeOptions::CAPABILITY, $caps); + } + + $caps + ->setCapability('buildName', BrowserStack::getBuildName()) + ->setCapability('projectName', BrowserStack::getProjectName()) + ->setCapability('sessionName', $sessionName) + ->setCapability('acceptInsecureCerts', true); + + if (static::$bslocalProcess->isRunning() && $localId) { + $caps + ->setCapability('browserstack.local', true) + ->setCapability('browserstack.networkLogs', true) + ->setCapability('browserstack.localIdentifier', $localId); + } + + return $caps; + } + + /** + * Start the BrowserStackLocal process. + */ + public static function startBrowserStackLocal(array $arguments = []): void + { + if (! BrowserStack::hasAccessKey()) { + return; + } + + static::$bslocalProcess = BrowserStack::createLocalProcess($arguments); + + static::$bslocalProcess->start(); + + static::afterClass(function () { + if (static::$bslocalProcess) { + static::$bslocalProcess->stop(); + } + }); + } +} diff --git a/tests/BrowserStackLocalTest.php b/tests/BrowserStackLocalTest.php index 5ae42e3..be95a71 100644 --- a/tests/BrowserStackLocalTest.php +++ b/tests/BrowserStackLocalTest.php @@ -2,9 +2,8 @@ namespace Creasi\Tests; -use Creasi\DuskBrowserStack\SupportsBrowserStack; -use Facebook\WebDriver\Chrome\ChromeOptions; -use Facebook\WebDriver\Remote\DesiredCapabilities; +use Creasi\DuskBrowserStack\BrowserStack; +use Creasi\DuskBrowserStack\WithBrowserStack; use Facebook\WebDriver\Remote\RemoteWebDriver; use Laravel\Dusk\Browser; use Orchestra\Testbench\Attributes\RequiresEnv; @@ -16,17 +15,15 @@ class BrowserStackLocalTest extends TestCase { - use SupportsBrowserStack; + use WithBrowserStack; use WithWorkbench; public static function defineWebDriverOptions() { - $_ENV['DUSK_DRIVER_URL'] = self::getDriverURL(); + $_ENV['DUSK_DRIVER_URL'] = BrowserStack::getDriverURL(); $_ENV['DUSK_HEADLESS_DISABLED'] = true; - if (static::hasBrowserStackKey()) { - static::startBrowserStackLocal(); - } + static::startBrowserStackLocal(); } protected function driver(): RemoteWebDriver @@ -37,11 +34,10 @@ protected function driver(): RemoteWebDriver DuskOptions::withUI(); } - $capabilities = DesiredCapabilities::chrome() - ->setCapability(ChromeOptions::CAPABILITY, DuskOptions::getChromeOptions()); + $capabilities = DuskOptions::getChromeOptions()->toCapabilities(); return RemoteWebDriver::create( - self::getDriverURL(), + $_ENV['DUSK_DRIVER_URL'], $this->withBrowserStackCapabilities($capabilities) ); }