<?php declare(strict_types=1);

namespace Lmc\Steward\Console\Style;

use Symfony\Component\Console\Helper\Helper;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\OutputStyle;
use Symfony\Component\Console\Style\SymfonyStyle;

class StewardStyle extends OutputStyle
{
    /** @var SymfonyStyle */
    protected $symfonyStyle;

    public function __construct(InputInterface $input, OutputInterface $output)
    {
        parent::__construct($output);

        $this->symfonyStyle = new SymfonyStyle($input, $output);
    }

    public static function getTimestampPrefix(): string
    {
        return '[' . date('Y-m-d H:i:s') . ']';
    }

    /**
     * Output progress message status
     */
    public function runStatus(string $message): void
    {
        $this->writeln($this->getTimestampPrefix() . ' ' . $message);
    }

    /**
     * Output success message in progress status
     */
    public function runStatusSuccess(string $message): void
    {
        $this->runStatus('<fg=green>' . $message . '</>');
    }

    /**
     * Output error message in progress status
     */
    public function runStatusError(string $message): void
    {
        $this->runStatus('<fg=red>' . $message . '</>');
    }

    public function title($message): void
    {
        throw new \BadMethodCallException('Method not implemented');
    }

    public function section($message): void
    {
        $this->newLine();
        $this->writeln([
            sprintf('<comment>%s</>', $message),
            sprintf(
                '<comment>%s</>',
                str_repeat('-', Helper::strlenWithoutDecoration($this->getFormatter(), $message))
            ),
        ]);
    }

    /**
     * Print output from process
     */
    public function output(string $output, string $identifier): void
    {
        if (empty($output)) {
            return;
        }

        $lines = explode("\n", $output);

        foreach ($lines as $line) {
            // color lines containing "[WARN]" or "[DEBUG]"
            if (mb_strpos($line, '[WARN]') !== false) {
                $line = '<fg=black;bg=yellow>' . $line . '</fg=black;bg=yellow>';
            } elseif (mb_strpos($line, '[DEBUG]') !== false) {
                $line = '<comment>' . $line . '</comment>';
            }

            $this->write($identifier . '> ');
            $this->writeln($line);
        }
    }

    /**
     * Print error output from process
     */
    public function errorOutput(string $output, string $identifier): void
    {
        $output = rtrim($output);

        if (empty($output)) {
            return;
        }

        $lines = explode("\n", $output);

        foreach ($lines as $line) {
            $this->write('<error>' . $identifier . ' ERR> ');
            $this->writeln($line . '</>');
        }
    }

    public function listing(array $elements): void
    {
        throw new \BadMethodCallException('Method not implemented');
    }

    public function text($message): void
    {
        $this->writeln($message);
    }

    public function success($message): void
    {
        $this->symfonyStyle->block($message, 'OK', 'fg=black;bg=green', ' ', true);
    }

    public function error($message): void
    {
        $this->symfonyStyle->block($message, 'ERROR', 'fg=white;bg=red', ' ', true);
    }

    public function warning($message): void
    {
        throw new \BadMethodCallException('Method not implemented');
    }

    public function note($message): void
    {
        $this->symfonyStyle->block($message, 'NOTE', 'fg=yellow', ' ');
    }

    public function caution($message): void
    {
        throw new \BadMethodCallException('Method not implemented');
    }

    public function table(array $headers, array $rows): void
    {
        throw new \BadMethodCallException('Method not implemented');
    }

    public function ask($question, $default = null, $validator = null)
    {
        return $this->symfonyStyle->ask($question, $default, $validator);
    }

    public function askHidden($question, $validator = null): void
    {
        throw new \BadMethodCallException('Method not implemented');
    }

    public function confirm($question, $default = true): void
    {
        throw new \BadMethodCallException('Method not implemented');
    }

    public function choice($question, array $choices, $default = null): void
    {
        throw new \BadMethodCallException('Method not implemented');
    }

    public function progressStart($max = 0): void
    {
        throw new \BadMethodCallException('Method not implemented');
    }

    public function progressAdvance($step = 1): void
    {
        throw new \BadMethodCallException('Method not implemented');
    }

    public function progressFinish(): void
    {
        throw new \BadMethodCallException('Method not implemented');
    }
}


<?php declare(strict_types=1);

namespace Lmc\Steward\Console\Command;

use Facebook\WebDriver\Remote\WebDriverBrowserType;
use Lmc\Steward\Console\CommandEvents;
use Lmc\Steward\Console\Configuration\ConfigOptions;
use Lmc\Steward\Console\Event\BasicConsoleEvent;
use Lmc\Steward\Console\Event\ExtendedConsoleEvent;
use Lmc\Steward\Process\ExecutionLoop;
use Lmc\Steward\Process\MaxTotalDelayStrategy;
use Lmc\Steward\Process\ProcessSetCreator;
use Lmc\Steward\Publisher\XmlPublisher;
use Lmc\Steward\Selenium\SeleniumServerAdapter;
use OndraM\CiDetector\CiDetector;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Finder\Finder;

/**
 * Run tests command is used to start Steward test planner and execute tests one by one,
 * optionally with defined delay and relations.
 */
class RunCommand extends Command
{
    /** @var SeleniumServerAdapter */
    protected $seleniumAdapter;
    /** @var ProcessSetCreator */
    protected $processSetCreator;
    /** @var array Lowercase name => WebDriver identifier */
    protected $supportedBrowsers = [
        'firefox' => WebDriverBrowserType::FIREFOX,
        'chrome' => WebDriverBrowserType::CHROME,
        'microsoftedge' => WebDriverBrowserType::MICROSOFT_EDGE,
        'internet explorer' => WebDriverBrowserType::IE,
        'safari' => WebDriverBrowserType::SAFARI,
        'phantomjs' => WebDriverBrowserType::PHANTOMJS,
    ];

    public const ARGUMENT_ENVIRONMENT = 'environment';
    public const ARGUMENT_BROWSER = 'browser';
    public const OPTION_SERVER_URL = 'server-url';
    public const OPTION_CAPABILITY = 'capability';
    public const OPTION_TESTS_DIR = 'tests-dir';
    public const OPTION_LOGS_DIR = 'logs-dir';
    public const OPTION_PATTERN = 'pattern';
    public const OPTION_GROUP = 'group';
    public const OPTION_EXCLUDE_GROUP = 'exclude-group';
    public const OPTION_FILTER = 'filter';
    public const OPTION_NO_EXIT = 'no-exit';
    public const OPTION_IGNORE_DELAYS = 'ignore-delays';
    public const OPTION_PARALLEL_LIMIT = 'parallel-limit';

    /**
     * @internal
     */
    public function setSeleniumAdapter(SeleniumServerAdapter $seleniumAdapter): void
    {
        $this->seleniumAdapter = $seleniumAdapter;
    }

    /**
     * @internal
     */
    public function setProcessSetCreator(ProcessSetCreator $processSetCreator): void
    {
        $this->processSetCreator = $processSetCreator;
    }

    /**
     * Configure command
     */
    protected function configure(): void
    {
        $this->setName('run')
            ->setDescription('Run tests planner and execute tests')
            ->addArgument(
                self::ARGUMENT_ENVIRONMENT,
                InputArgument::REQUIRED,
                'Environment name (must be specified to avoid unintentional run against production)'
            )
            ->addArgument(
                self::ARGUMENT_BROWSER,
                InputArgument::REQUIRED,
                'Browser in which tests should be run'
            )
            ->addOption(
                self::OPTION_SERVER_URL,
                null,
                InputOption::VALUE_REQUIRED,
                'Selenium server (hub) URL ',
                'http://localhost:4444/wd/hub'
            )
            ->addOption(
                self::OPTION_CAPABILITY,
                null,
                InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED,
                'Extra DesiredCapabilities to be passed to WebDriver, use format capabilityName:value'
            )
            ->addOption(
                self::OPTION_TESTS_DIR,
                null,
                InputOption::VALUE_REQUIRED,
                'Path to directory with tests',
                STEWARD_BASE_DIR . DIRECTORY_SEPARATOR . 'tests'
            )
            ->addOption(
                self::OPTION_LOGS_DIR,
                null,
                InputOption::VALUE_REQUIRED,
                'Path to directory with logs',
                STEWARD_BASE_DIR . DIRECTORY_SEPARATOR . 'logs'
            )
            ->addOption(
                self::OPTION_PATTERN,
                null,
                InputOption::VALUE_REQUIRED,
                'Pattern for test files to be run',
                '*Test.php'
            )
            ->addOption(
                self::OPTION_GROUP,
                null,
                InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
                'Only run testcases with specified @group of this name'
            )
            ->addOption(
                self::OPTION_EXCLUDE_GROUP,
                null,
                InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
                'Exclude testcases with specified @group from being run'
            )
            ->addOption(
                self::OPTION_FILTER,
                null,
                InputOption::VALUE_REQUIRED,
                'Run only testcases/tests with name matching this filter'
            )
            ->addOption(
                self::OPTION_NO_EXIT,
                null,
                InputOption::VALUE_NONE,
                'Always exit with code 0 <comment>(by default any failed test causes the command to return 1)</comment>'
            )
            ->addOption(
                self::OPTION_IGNORE_DELAYS,
                'i',
                InputOption::VALUE_NONE,
                'Ignore delays defined between testcases'
            )
            ->addOption(
                self::OPTION_PARALLEL_LIMIT,
                'l',
                InputOption::VALUE_REQUIRED,
                'Number of maximum testcases being executed in a parallel',
                50
            );

        $this->addUsage('staging firefox');
        $this->addUsage('--group=foo --group=bar --exclude-group=baz -vvv development phantomjs');

        $this->getDispatcher()->dispatch(CommandEvents::CONFIGURE, new BasicConsoleEvent($this));
    }

    /**
     * Initialize, check arguments and options values etc.
     */
    protected function initialize(InputInterface $input, OutputInterface $output): void
    {
        parent::initialize($input, $output);

        $output->writeln(
            sprintf(
                '<info>Steward</info> <comment>%s</comment> is running the tests...%s',
                $this->getApplication()->getVersion(),
                (!(new CiDetector())->isCiDetected() ? ' Just for you <fg=red><3</fg=red>!' : '')
            )
        );

        // If browser name or env is empty, ends initialization and let the Console/Command fail on input validation
        if (empty($input->getArgument(self::ARGUMENT_BROWSER))
            || empty($input->getArgument(self::ARGUMENT_ENVIRONMENT))
        ) {
            return;
        }

        // Browser name is case insensitive, normalize it to lower case
        $browserNormalized = mb_strtolower($input->getArgument(self::ARGUMENT_BROWSER));

        // Check if browser is supported
        if (!isset($this->supportedBrowsers[$browserNormalized])) {
            throw new \RuntimeException(
                sprintf(
                    'Browser "%s" is not supported (use one of: %s)',
                    $browserNormalized,
                    implode(', ', array_keys($this->supportedBrowsers))
                )
            );
        }

        // Set WebDriver browser identifier back to the argument value
        $input->setArgument(self::ARGUMENT_BROWSER, $this->supportedBrowsers[$browserNormalized]);

        if ($output->isVerbose()) {
            $output->writeln(sprintf('Browser: %s', $input->getArgument(self::ARGUMENT_BROWSER)));
            $output->writeln(sprintf('Environment: %s', $input->getArgument(self::ARGUMENT_ENVIRONMENT)));
        }

        // Initialize Selenium server adapter and normalize server URL
        $seleniumAdapter = $this->getSeleniumAdapter($input->getOption(self::OPTION_SERVER_URL));
        $input->setOption(self::OPTION_SERVER_URL, $seleniumAdapter->getServerUrl());

        // Make sure parallel-limit is greater than 0
        $parallelLimit = (int) $input->getOption(self::OPTION_PARALLEL_LIMIT);
        if ($parallelLimit === 0) {
            throw new \RuntimeException('Parallel limit must be a whole number greater than 0');
        }
        $input->setOption(self::OPTION_PARALLEL_LIMIT, $parallelLimit);

        $this->getDispatcher()->dispatch(
            CommandEvents::RUN_TESTS_INIT,
            new ExtendedConsoleEvent($this, $input, $output)
        );

        if ($output->isVeryVerbose()) {
            $output->writeln(
                sprintf('Path to logs: %s', $this->config[ConfigOptions::LOGS_DIR])
            );
            $output->writeln(
                sprintf('Ignore delays: %s', ($input->getOption(self::OPTION_IGNORE_DELAYS)) ? 'yes' : 'no')
            );
            $output->writeln(
                sprintf('Parallel limit: %d', $input->getOption(self::OPTION_PARALLEL_LIMIT))
            );
        }
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        if (!$this->testSeleniumConnection()) {
            return 1;
        }

        // Find all files holding test-cases
        if ($this->io->isVeryVerbose()) {
            $this->io->writeln('Searching for testcases:');
            $this->io->writeln(sprintf(' - in directory "%s"', $this->config[ConfigOptions::TESTS_DIR]));
            $this->io->writeln(sprintf(' - by pattern "%s"', $input->getOption(self::OPTION_PATTERN)));
        }

        $files = (new Finder())
            ->files()
            ->in($this->config[ConfigOptions::TESTS_DIR])
            ->name($input->getOption(self::OPTION_PATTERN));

        if (!count($files)) {
            $this->io->error(
                sprintf(
                    'No testcases found, exiting.%s',
                    !$this->io->isVeryVerbose() ? ' (use -vv or -vvv option for more information)' : ''
                )
            );

            return 1;
        }

        $processSetCreator = $this->getProcessSetCreator($input, $this->io);
        $processSet = $processSetCreator->createFromFiles(
            $files,
            $input->getOption(self::OPTION_GROUP),
            $input->getOption(self::OPTION_EXCLUDE_GROUP),
            $input->getOption(self::OPTION_FILTER),
            $input->getOption(self::OPTION_IGNORE_DELAYS)
        );

        if (!count($processSet)) {
            $this->io->error('No testcases matched given groups, exiting.');

            return 1;
        }

        $maxParallelLimit = $input->getOption(self::OPTION_PARALLEL_LIMIT);

        $executionLoop = new ExecutionLoop($processSet, $this->io, new MaxTotalDelayStrategy(), $maxParallelLimit);

        $allTestsPassed = $executionLoop->start();

        if ($input->getOption(self::OPTION_NO_EXIT)) {
            return 0;
        }

        return $allTestsPassed ? 0 : 1;
    }

    /**
     * @codeCoverageIgnore
     */
    protected function getSeleniumAdapter(string $seleniumServerUrl): SeleniumServerAdapter
    {
        if ($this->seleniumAdapter === null) {
            $this->seleniumAdapter = new SeleniumServerAdapter($seleniumServerUrl);
        }

        return $this->seleniumAdapter;
    }

    /**
     * @codeCoverageIgnore
     */
    protected function getProcessSetCreator(InputInterface $input, OutputInterface $output): ProcessSetCreator
    {
        if ($this->processSetCreator === null) {
            $xmlPublisher = new XmlPublisher();
            $xmlPublisher->setFileDir($this->config[ConfigOptions::LOGS_DIR]);
            $xmlPublisher->clean();

            $this->processSetCreator = new ProcessSetCreator($this, $input, $output, $xmlPublisher, $this->config);
        }

        return $this->processSetCreator;
    }

    /**
     * Try connection to Selenium server
     */
    protected function testSeleniumConnection(): bool
    {
        $seleniumAdapter = $this->seleniumAdapter;
        $this->io->write(
            sprintf('Selenium server (hub) url: %s, trying connection...', $seleniumAdapter->getServerUrl()),
            false,
            OutputInterface::VERBOSITY_VERY_VERBOSE
        );

        if (!$seleniumAdapter->isAccessible()) {
            $this->io->writeln(
                sprintf(
                    '<error>%s ("%s")</error>',
                    $this->io->isVeryVerbose() ? 'connection error' : 'Error connecting to Selenium server',
                    $seleniumAdapter->getLastError()
                )
            );

            $this->io->error(
                sprintf(
                    'Make sure your Selenium server is really accessible on url "%s" '
                    . 'or change it using --server-url option',
                    $seleniumAdapter->getServerUrl()
                )
            );

            return false;
        }

        if (!$seleniumAdapter->isSeleniumServer()) {
            $this->io->writeln(
                sprintf(
                    '<error>%s (%s)</error>',
                    $this->io->isVeryVerbose() ? 'unexpected response' : 'Unexpected response from Selenium server',
                    $seleniumAdapter->getLastError()
                )
            );
            $this->io->error(
                sprintf(
                    'Looks like url "%s" is occupied by something else than Selenium server. '
                    . 'Make sure Selenium server is really accessible on this url '
                    . 'or change it using --server-url option',
                    $seleniumAdapter->getServerUrl()
                )
            );

            return false;
        }

        if ($this->io->isVeryVerbose()) {
            $cloudService = $seleniumAdapter->getCloudService();
            $this->io->writeln(
                'OK' . ($cloudService ? ' (' . $cloudService . ' cloud service detected)' : ''),
                OutputInterface::VERBOSITY_VERY_VERBOSE
            );
        }

        return true;
    }
}


<?php declare(strict_types=1);

namespace Lmc\Steward\Console\EventListener;

use Lmc\Steward\Console\CommandEvents;
use Lmc\Steward\Console\Event\BasicConsoleEvent;
use Lmc\Steward\Console\Event\ExtendedConsoleEvent;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Input\StringInput;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

/**
 * Invoke clean command before run command starts (unless no-clean option is given)
 */
class CleanLogsListener implements EventSubscriberInterface
{
    public const OPTION_NO_CLEAN = 'no-clean';

    public static function getSubscribedEvents(): array
    {
        return [
            CommandEvents::CONFIGURE => 'onCommandConfigure',
            CommandEvents::PRE_INITIALIZE => 'onCommandPreInitialize',
        ];
    }

    /**
     * Add option to `run` command configuration.
     */
    public function onCommandConfigure(BasicConsoleEvent $event): void
    {
        if ($event->getCommand()->getName() !== 'run') {
            return;
        }

        $event->getCommand()->addOption(
            self::OPTION_NO_CLEAN,
            null,
            InputOption::VALUE_NONE,
            'Do not clean content of logs directory on startup'
        );
    }

    public function onCommandPreInitialize(ExtendedConsoleEvent $event): void
    {
        if (!$event->getCommand()->getDefinition()->hasOption(self::OPTION_NO_CLEAN)) {
            return;
        }

        if ($event->getInput()->getOption(self::OPTION_NO_CLEAN)) {
            return;
        }

        $cleanCommand = $event->getCommand()->getApplication()->find('clean');
        $cleanCommand->run(new StringInput(''), $event->getOutput());
    }
}


<?php declare(strict_types=1);

namespace Lmc\Steward\Selenium;

use Assert\Assert;

/**
 * Download Selenium standalone server
 */
class Downloader
{
    /** @var string Selenium storage URL */
    public static $storageUrl = 'https://selenium-release.storage.googleapis.com';
    /** @var string Target directory where should be the file saved */
    private $targetDir;
    /** @var string Version to download */
    private $version;

    /**
     * @param string $targetDir Target directory where should be the file saved
     */
    public function __construct(string $targetDir)
    {
        $this->targetDir = rtrim($targetDir, '/');
    }

    /**
     * Get latest released version of Selenium server. If not found, null is returned.
     */
    public static function getLatestVersion(): ?string
    {
        $availableVersions = self::getAvailableVersions();

        if (!$availableVersions) {
            return null;
        }

        return end($availableVersions);
    }

    public static function getAvailableVersions(): array
    {
        $data = @file_get_contents(self::$storageUrl);
        if (!$data) {
            return [];
        }

        libxml_use_internal_errors(true); // disable errors from being thrown
        $xml = simplexml_load_string($data);
        if (!$xml) {
            return [];
        }

        $releases = $xml->xpath('//*[text()[contains(.,"selenium-server-standalone")]]');
        $availableVersions = [];
        foreach ($releases as $release) {
            $parsedVersion = preg_replace('/.*standalone-(.+)\.jar/', '$1', $release);
            if ((string) $release === $parsedVersion) { // regexp did not match
                continue;
            }

            $availableVersions[] = $parsedVersion;
        }

        self::sortVersions($availableVersions);

        return $availableVersions;
    }

    private static function sortVersions(array &$versions): void
    {
        // Sort naturally, but versions like 3.0.0 must be after 3.0.0-beta, so we must take care of them explicitly
        usort($versions, function (string $a, string $b): int {
            $aParts = explode('-', $a);
            $bParts = explode('-', $b);

            // First part is the same (3.0.0), but one string does have second part (-beta) while the other one does not
            if ($aParts[0] === $bParts[0] && (count($aParts) !== count($bParts))) {
                // The one with less parts should be ordered after the longer one
                return count($bParts) <=> count($aParts);
            }

            return strnatcmp(mb_strtolower($a), mb_strtolower($b));
        });
    }

    /**
     * @param string $version Version to download
     */
    public function setVersion(string $version): void
    {
        $this->version = $version;
    }

    /**
     * Get version that should be downloaded; if not set, attempt to retrieve latest released version
     */
    public function getVersion(): ?string
    {
        if (!$this->version) {
            $this->version = self::getLatestVersion();
        }

        return $this->version;
    }

    /**
     * Check if file of given version was already downloaded to given directory
     */
    public function isAlreadyDownloaded(): bool
    {
        return file_exists($this->getFilePath());
    }

    /**
     * Get target path of the file
     */
    public function getFilePath(): string
    {
        return $this->targetDir . '/' . $this->getFileName();
    }

    /**
     * Get complete URL of the file to download
     */
    public function getFileUrl(): string
    {
        $version = $this->getVersion();
        Assert::that($version, 'Invalid version (expected format is X.Y.Z)')
            ->notEmpty()
            ->regex('/^\d+\.\d+\.[\da-z\-]+$/i');

        $versionParts = explode('.', $version);

        $devVersion = '';
        if (preg_match('/(\d+)-([[:alnum:]]+)/', $versionParts[2], $matches)) {
            $devVersion = $matches[2];
        }

        $fileUrl = self::$storageUrl . '/' . $versionParts[0] . '.' . $versionParts[1]
            . (!empty($devVersion) ? '-' . $devVersion : '')
            . '/' . $this->getFileName();

        return $fileUrl;
    }

    /**
     * Execute the download
     *
     * @throws \RuntimeException Thrown when file cannot be downloaded
     * @return int Downloaded size in bytes.
     */
    public function download(): int
    {
        $targetPath = $this->getFilePath();

        if (!is_dir(dirname($targetPath))) {
            mkdir(dirname($targetPath), 0777, true);
        }

        $fileUrl = $this->getFileUrl();

        $fp = @fopen($fileUrl, 'rb');
        $responseHeaders = get_headers($fileUrl);
        if (mb_strpos($responseHeaders[0], '200 OK') === false) {
            throw new \RuntimeException(sprintf('Error downloading file "%s" (%s)', $fileUrl, $responseHeaders[0]));
        }

        $downloadedSize = file_put_contents($targetPath, $fp);
        // @codeCoverageIgnoreStart
        if (!$downloadedSize) {
            throw new \RuntimeException(sprintf('Error saving file to path "%s"', $targetPath));
        }
        // @codeCoverageIgnoreEnd

        return $downloadedSize;
    }

    /**
     * Get name of the jar file
     */
    protected function getFileName(): string
    {
        return 'selenium-server-standalone-' . $this->getVersion() . '.jar';
    }
}


<?php declare(strict_types=1);

namespace Lmc\Steward\Component;

use Facebook\WebDriver\Exception\NoSuchElementException;
use Facebook\WebDriver\Exception\UnsupportedOperationException;
use Facebook\WebDriver\WebDriverBy;
use Facebook\WebDriver\WebDriverElement;
use Facebook\WebDriver\WebDriverExpectedCondition;
use Facebook\WebDriver\WebDriverSelectInterface;
use Lmc\Steward\Test\AbstractTestCase;

/**
 * Manipulate the Select2 UI jQuery based component.
 * Currently compatible with Select2 v3.5.
 *
 * @see https://select2.github.io/
 */
class Select2 extends AbstractComponent implements WebDriverSelectInterface
{
    protected const DROPDOWN_SELECTOR = '#select2-drop';
    protected const DROPDOWN_OPTIONS_SELECTOR = '.select2-results > li';
    protected const SIMPLESELECT_SELECTED_OPTION_SELECTOR = '.select2-chosen';
    protected const MULTISELECT_SELECTED_OPTIONS_SELECTOR = '.select2-choices > li.select2-search-choice';

    /** @var bool */
    protected $multiple;
    /** @var WebDriverElement */
    protected $element;
    /** @var string */
    protected $select2Selector;

    /**
     * @param WebDriverElement $element Original HTML element to which is the Select2 attached
     */
    public function __construct(AbstractTestCase $tc, WebDriverElement $element)
    {
        parent::__construct($tc);

        $this->element = $element;
        $this->select2Selector = '#s2id_' . $this->element->getAttribute('id');
        $select2Element = $this->waitForCss($this->select2Selector);
        $this->multiple = $this->detectIfSelect2IsMultiple($select2Element);
    }

    public function isMultiple(): bool
    {
        return $this->multiple;
    }

    public function getOptions(): array
    {
        $this->openDropdownOptions();

        $dropdown = $this->wd->findElement(WebDriverBy::cssSelector(self::DROPDOWN_SELECTOR));

        return $dropdown->findElements((WebDriverBy::cssSelector(self::DROPDOWN_OPTIONS_SELECTOR)));
    }

    /**
     * @return WebDriverElement[] All selected options belonging to this select tag.
     */
    public function getAllSelectedOptions(): array
    {
        if (!$this->isMultiple()) {
            return [$this->getFirstSelectedOption()];
        }

        return $this->findMultipleByCss($this->select2Selector . ' ' . self::MULTISELECT_SELECTED_OPTIONS_SELECTOR);
    }

    /**
     * @throws NoSuchElementException
     *
     * @return WebDriverElement The first selected option in multi-select (or the currently selected option
     * in a normal select)
     */
    public function getFirstSelectedOption(): WebDriverElement
    {
        if (!$this->isMultiple()) {
            return $this->findByCss($this->select2Selector . ' ' . self::SIMPLESELECT_SELECTED_OPTION_SELECTOR);
        }

        $selected = $this->findMultipleByCss(
            $this->select2Selector . ' ' . self::MULTISELECT_SELECTED_OPTIONS_SELECTOR
        );

        if (count($selected) === 0) {
            throw new NoSuchElementException('No options are selected');
        }

        return reset($selected);
    }

    /**
     * @codeCoverageIgnore
     * @param int $index
     * @throws UnsupportedOperationException
     */
    public function selectByIndex($index): void
    {
        throw new UnsupportedOperationException('Method not is not implemented');
    }

    /**
     * @codeCoverageIgnore
     * @param string $value
     * @throws UnsupportedOperationException
     */
    public function selectByValue($value): void
    {
        throw new UnsupportedOperationException('Method not is not implemented');
    }

    /**
     * @codeCoverageIgnore
     * @param string $text
     * @throws UnsupportedOperationException
     */
    public function selectByVisibleText($text): void
    {
        throw new UnsupportedOperationException('Method not is not implemented');
    }

    /**
     * Select first option which text partially matches the argument.
     *
     * @param string $text
     */
    public function selectByVisiblePartialText($text): void
    {
        $this->openDropdownOptions();

        $this->log('Sending keys to select2: %s', $text);

        $inputSelector = WebDriverBy::cssSelector(
            $this->isMultiple() ? $this->select2Selector . ' input' : '#select2-drop input'
        );

        // Insert searched term into Select2 generated input
        $this->tc->wd
            ->findElement($inputSelector)
            ->sendKeys($text);

        // Wait until result are rendered (or maybe loaded with ajax)
        $firstResult = $this->tc->wd->wait()->until(
            WebDriverExpectedCondition::presenceOfElementLocated(
                WebDriverBy::cssSelector('.select2-drop .select2-result.select2-highlighted')
            )
        );

        $this->log('Dropdown detected, selecting the first result: %s', $firstResult->getText());

        // Select first item in results
        $firstResult->click();
    }

    /**
     * @codeCoverageIgnore
     * @throws UnsupportedOperationException
     */
    public function deselectAll(): void
    {
        throw new UnsupportedOperationException('Method not is not implemented');
    }

    /**
     * @codeCoverageIgnore
     * @param int $index
     * @throws UnsupportedOperationException
     */
    public function deselectByIndex($index): void
    {
        throw new UnsupportedOperationException('Method not is not implemented');
    }

    /**
     * @codeCoverageIgnore
     * @param string $value
     * @throws UnsupportedOperationException
     */
    public function deselectByValue($value): void
    {
        throw new UnsupportedOperationException('Method not is not implemented');
    }

    /**
     * @codeCoverageIgnore
     * @param string $text
     * @throws UnsupportedOperationException
     */
    public function deselectByVisibleText($text): void
    {
        throw new UnsupportedOperationException('Method not is not implemented');
    }

    /**
     * @codeCoverageIgnore
     * @param string $text
     * @throws UnsupportedOperationException
     */
    public function deselectByVisiblePartialText($text): void
    {
        throw new UnsupportedOperationException('Method not is not implemented');
    }

    public function openDropdownOptions(): void
    {
        // Wait for select2 to appear
        $select2link = $this->tc->wd->wait()->until(
            WebDriverExpectedCondition::presenceOfElementLocated(
                WebDriverBy::cssSelector($this->select2Selector . ' ' . ($this->isMultiple() ? 'input' : 'a'))
            )
        );

        // Click on element to open dropdown - to copy users behavior
        $select2link->click();
    }

    protected function detectIfSelect2IsMultiple(WebDriverElement $select2Element): bool
    {
        $select2Classes = $select2Element->getAttribute('class');

        return (mb_strpos($select2Classes, 'select2-container-multi') !== false);
    }
}


<?php declare(strict_types=1);

namespace Lmc\Steward\Console\Command;

use Lmc\Steward\Console\CommandEvents;
use Lmc\Steward\Console\Event\BasicConsoleEvent;
use Lmc\Steward\Selenium\Downloader;
use OndraM\CiDetector\CiDetector;
use Symfony\Component\Console\Exception\RuntimeException;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

/**
 * Install (download) Selenium standalone server.
 */
class InstallCommand extends Command
{
    /** @var Downloader */
    protected $downloader;

    /**
     * Target directory to store the selenium server (relatively to STEWARD_BASE_DIR)
     *
     * @var string
     */
    protected $targetDir = '/vendor/bin';

    /**
     * @internal
     */
    public function setDownloader(Downloader $downloader): void
    {
        $this->downloader = $downloader;
    }

    /**
     * Configure command
     */
    protected function configure(): void
    {
        $this->setName('install')
            ->setDescription('Download latest Selenium standalone server jar file')
            ->addArgument(
                'version',
                InputArgument::OPTIONAL,
                'Specific Selenium version to install'
            );

        $this->getDispatcher()->dispatch(CommandEvents::CONFIGURE, new BasicConsoleEvent($this));
    }

    /**
     * In interactive or very verbose (-vv) mode provide more output, otherwise only output full path to selenium
     * server jar file (so it could be parsed and run).
     */
    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $verboseOutput = false;
        if ($input->isInteractive() || $output->isVerbose()) {
            $verboseOutput = true;
        }

        $version = $input->getArgument('version'); // exact version could be specified as argument

        if (!$version) {
            $version = $this->askForVersion($input->isInteractive());
        }

        if ($verboseOutput) {
            $this->io->note(
                sprintf(
                    'Downloading Selenium standalone server version %s...%s',
                    $version,
                    (!(new CiDetector())->isCiDetected() ? ' Just for you <3!' : '')
                )
            );
        }

        $downloader = $this->getDownloader();
        $downloader->setVersion($version);
        $targetPath = realpath($downloader->getFilePath());

        if ($this->io->isVerbose()) {
            $this->io->note(sprintf('Download URL: %s', $downloader->getFileUrl()));
        }

        if ($downloader->isAlreadyDownloaded()) {
            if ($verboseOutput) {
                $this->io->note(
                    sprintf('File "%s" already exists - won\'t be downloaded again.', basename($targetPath))
                );

                $this->io->note('Path to file: ' . $targetPath);
                $this->printLinkToWiki();
            } else {
                $this->io->writeln($targetPath); // In non-verbose mode only output path to the file
            }

            return 0;
        }

        if ($verboseOutput) {
            $this->io->note('Downloading may take a while - its ~20 MB...');
        }

        $downloadedSize = $downloader->download();
        $downloadedFilePath = realpath($downloader->getFilePath());

        if ($verboseOutput) {
            $this->io->success(
                sprintf('Downloaded %d MB, file saved successfully.', round($downloadedSize / 1024 / 1024, 1))
            );
            $this->io->note('Path to file: ' . $downloadedFilePath);
            $this->printLinkToWiki();
        } else {
            $this->io->writeln($downloadedFilePath); // In non-verbose mode only output path to the file
        }

        return 0;
    }

    /**
     * @codeCoverageIgnore
     */
    protected function getDownloader(): Downloader
    {
        if ($this->downloader === null) {
            $this->downloader = new Downloader(STEWARD_BASE_DIR . $this->targetDir);
        }

        return $this->downloader;
    }

    private function askForVersion(bool $isInteractiveInput): string
    {
        $latestVersion = Downloader::getLatestVersion();

        $questionText = 'Enter Selenium server version to install';

        if ($latestVersion !== null) {
            return $this->io->ask($questionText, $latestVersion);
        }

        // When latest version cannot be detected, the version must always be provided
        if (!$isInteractiveInput) { // we have nowhere to get the version number in non-interactive mode
            throw new RuntimeException('Auto-detection of latest Selenium version failed - version must be provided');
        }

        // in interactive mode version must specified
        return $this->io->ask($questionText, null, function ($answer) {
            if (empty($answer)) {
                throw new \RuntimeException('Please provide version to download (latest version auto-detect failed)');
            }

            return $answer;
        });
    }

    private function printLinkToWiki(): void
    {
        $this->io->note(
            'What now? Learn how to start the Selenium server: '
            . 'https://github.com/lmc-eu/steward/wiki/Selenium-server-&-browser-drivers'
        );
    }
}


<?php declare(strict_types=1);

namespace Lmc\Steward\Console\Command;

use Lmc\Steward\Console\CommandEvents;
use Lmc\Steward\Console\Event\BasicConsoleEvent;
use Lmc\Steward\Publisher\XmlPublisher;
use Lmc\Steward\Timeline\TimelineDataBuilder;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\Filesystem\Filesystem;

/**
 * Generate visual timeline of the latest test run (according to the results.xml file) to a standalone HTML file.
 */
class GenerateTimelineCommand extends Command
{
    public const OPTION_RESULTS_FILE = 'results-file';
    public const OPTION_OUTPUT_FILE = 'output-file';
    public const DEFAULT_OUTPUT_FILENAME = 'timeline.html';

    /** @var Filesystem */
    private $filesystem;

    public function __construct(EventDispatcher $dispatcher, string $name = null)
    {
        $this->filesystem = new Filesystem();

        parent::__construct($dispatcher, $name);
    }

    /**
     * @internal
     */
    public function setFilesystem(Filesystem $filesystem): void
    {
        $this->filesystem = $filesystem;
    }

    /**
     * Configure command
     */
    protected function configure(): void
    {
        $this->setName('generate-timeline')
            ->setDescription('Generates HTML file with timeline visualisation of test run')
            ->addOption(
                self::OPTION_RESULTS_FILE,
                null,
                InputOption::VALUE_REQUIRED,
                'Path to test results xml file',
                STEWARD_BASE_DIR . DIRECTORY_SEPARATOR . 'logs' . DIRECTORY_SEPARATOR . XmlPublisher::FILE_NAME
            )
            ->addOption(
                self::OPTION_OUTPUT_FILE,
                null,
                InputOption::VALUE_REQUIRED,
                'Path to output html file',
                STEWARD_BASE_DIR . DIRECTORY_SEPARATOR . 'logs' . DIRECTORY_SEPARATOR . self::DEFAULT_OUTPUT_FILENAME
            );

        $this->getDispatcher()->dispatch(CommandEvents::CONFIGURE, new BasicConsoleEvent($this));
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $inputResultsFilePath = $this->getInputResultsFilePathFromOption($input);
        $outputFilePath = $input->getOption(self::OPTION_OUTPUT_FILE);

        $xml = simplexml_load_file($inputResultsFilePath);
        $timelineDataBuilder = new TimelineDataBuilder($xml);

        $outputHtml = $this->assembleOutputHtml(
            $timelineDataBuilder->buildTimelineGroups(),
            $timelineDataBuilder->buildTimelineItems()
        );

        $this->filesystem->dumpFile($outputFilePath, $outputHtml);

        $this->io->success(
            sprintf('Timeline generated to file "%s"', $outputFilePath)
        );

        return 0;
    }

    private function assembleOutputHtml(array $timelineGroups, array $timelineItems): string
    {
        $htmlTemplate = $this->getHtmlTemplate();

        $output = strtr(
            $htmlTemplate,
            [
                '{{stewardVersion}}' => $this->getApplication()->getVersion(),
                '{{dateGenerated}}' => (new \DateTime())->format('Y-m-d H:i:s'),
                '{{timelineGroups}}' => json_encode($timelineGroups),
                '{{timelineItems}}' => json_encode($timelineItems),
            ]
        );

        return $output;
    }

    private function getHtmlTemplate(): string
    {
        return file_get_contents(__DIR__ . '/../../Resources/timeline-template.html');
    }

    protected function getInputResultsFilePathFromOption(InputInterface $input): string
    {
        $inputResultsFilePath = $input->getOption(self::OPTION_RESULTS_FILE);
        if (!is_readable($inputResultsFilePath)) {
            throw new \RuntimeException(
                sprintf(
                    'Cannot read results file "%s", make sure it is accessible '
                    . '(or use --%s option if it is stored elsewhere)',
                    $inputResultsFilePath,
                    self::OPTION_RESULTS_FILE
                )
            );
        }

        return $inputResultsFilePath;
    }
}


<?php declare(strict_types=1);

/**
 * Allows sharing data between test-cases and phases of tests
 *
 * Two possible uses
 * 1. loadWithName() and saveWithName() - you must define a name for the legacy which will be used as a filename
 * to store the data - beware - the name must be unique through all test-cases
 *
 * example:
 * class FooFirstTest
 * {
 *      public function test()
 *      {
 *          $legacy->saveWithName("some data to be remembered", 'my_test_case_legacy');
 *      }
 * }
 *
 * class FooSecondTest
 * {
 *      public function test()
 *      {
 *          $data = $legacy->loadWithName('my_test_case_legacy');
 *      }
 * }
 *
 *
 * 2. load() and save() - the name of the legacy (file) is generated from the name of the test case class and the name
 * of the test running - the class must have PhaseN in the name where N is a digit - this because different phases
 * of the test-case will differ in the digit but the rest of the name will be the same
 * - and so different phases of the same test-case can access the same legacy
 * You can choose whether the legacy should be shared between tests in a test case (class) or accessible only
 * by the same test function.
 *
 * example:
 * class FooPhase1Test
 * {
 *      public function test()
 *      {
 *          $legacy->save("some data to be remembered");
 *      }
 * }
 *
 * class FooPhase2Test
 * {
 *      public function test()
 *      {
 *          $data = $legacy->load();
 *      }
 * }
 */

namespace Lmc\Steward\Component;

use Lmc\Steward\ConfigProvider;
use Lmc\Steward\Test\AbstractTestCase;
use Lmc\Steward\Utils\Strings;

/**
 * Legacy component allows you to share data between test-cases and phases of tests.
 */
class Legacy extends AbstractComponent
{
    public const LEGACY_TYPE_CASE = 'CASE';
    public const LEGACY_TYPE_TEST = 'TEST';

    /** @var string */
    protected $testClassName;

    /** @var string */
    protected $extension = '.legacy';

    /** @var string */
    protected $fileDir;

    public function __construct(AbstractTestCase $tc)
    {
        parent::__construct($tc);

        $logsDir = ConfigProvider::getInstance()->logsDir;
        if ($logsDir) { // if the directory is not defined, the setFileDir() must be called explicitly later
            $this->setFileDir($logsDir);
        }

        $this->testClassName = get_class($tc);

        $this->log('New Legacy instantiated in class "%s"', $this->testClassName);
    }

    /**
     * Set directory where results file should be stored. Usable eg. when LOGS_DIR constant was not set.
     */
    public function setFileDir(string $dir): void
    {
        $this->fileDir = $dir;
    }

    /**
     * Generates a filename (without path and extension) for the legacy based on the name of the test-case
     *
     * @param string $type LEGACY_TYPE_CASE (shared by all tests in test case)
     *      or LEGACY_TYPE_TEST (shared only by the same test function)
     * @throws LegacyException
     */
    protected function getLegacyName(string $type): string
    {
        $name = $this->testClassName;

        if (!preg_match('/Phase\d/', $name)) {
            throw new LegacyException(
                "Cannot generate Legacy name from class without 'Phase' followed by number in name " . $name
            );
        }

        $name = preg_replace('/Phase\d/', '', $name); // remove 'PhaseX' from the name
        $name = Strings::toFilename($name);

        if ($type == self::LEGACY_TYPE_TEST) {
            $name .= '#' . Strings::toFilename($this->tc->getName(false));
        }

        return $name;
    }

    /**
     * Gets a path to file with legacy data
     */
    protected function getLegacyFullPath(string $filename): string
    {
        return $this->fileDir . '/' . $filename . $this->extension;
    }

    /**
     * Store legacy of test under a custom name
     *
     * @param string $legacyName filename to store the data if null getLegacyFilename is called to generate filename
     *      from the test class name
     * @throws LegacyException
     */
    public function saveWithName($data, string $legacyName): void
    {
        $filename = $this->getLegacyFullPath($legacyName);
        $this->log('Saving data as Legacy "%s" to file "%s"', $legacyName, $filename);
        $this->debug('Legacy data: %s', $this->getPrintableValue($data));

        if (@file_put_contents($filename, serialize($data)) === false) {
            throw new LegacyException('Cannot save Legacy to file ' . $filename);
        }
    }

    /**
     * Store legacy of test getLegacyFilename is called to generate filename from the test class name
     *
     * @param string $type LEGACY_TYPE_CASE (shared by all tests in test case)
     *      or LEGACY_TYPE_TEST (shared only by the same test function)
     * @throws LegacyException
     */
    public function save($data, string $type = self::LEGACY_TYPE_CASE): void
    {
        $this->saveWithName($data, $this->getLegacyName($type));
    }

    /**
     * Reads legacy of test getLegacyFilename is called to generate filename from the test class name.
     * Raises exception if it is not found.
     *
     * @param string $type LEGACY_TYPE_CASE (shared by all tests in test case)
     *      or LEGACY_TYPE_TEST (shared only by the same test function)
     * @throws LegacyException
     */
    public function load(string $type = self::LEGACY_TYPE_CASE)
    {
        return $this->loadWithName($this->getLegacyName($type));
    }

    /**
     * Reads legacy specified by custom name.
     * Raises exception if it is not found.
     *
     * @param string $legacyName filename to store the data from the test class name
     * @throws LegacyException
     */
    public function loadWithName(string $legacyName)
    {
        $filename = $this->getLegacyFullPath($legacyName);

        $this->log('Reading Legacy "%s" from file "%s"', $legacyName, $filename);

        $data = @file_get_contents($filename);
        if ($data === false) {
            throw new LegacyException('Cannot read Legacy file ' . $filename);
        }

        $legacy = unserialize($data);
        if ($legacy === false) {
            throw new LegacyException('Cannot parse Legacy from file ' . $filename);
        }

        $this->debug('Legacy data: %s', $this->getPrintableValue($legacy));

        return $legacy;
    }

    /**
     * Converts legacy value to string that can be printed (e.g. in log)
     * calls __toString on the object if it's defined otherwise print_r()
     */
    private function getPrintableValue($object): string
    {
        if (is_object($object) && method_exists($object, '__toString')) {
            return (string) $object;
        }

        return print_r($object, true);
    }
}


<?php declare(strict_types=1);

namespace Lmc\Steward\Selenium;

/**
 * Selenium server API adapter, used mainly to check connection (communication with Selenium server handles the
 * WebDriverCommandExecutor, not this adapter).
 */
class SeleniumServerAdapter
{
    public const CLOUD_SERVICE_SAUCELABS = 'saucelabs';
    public const CLOUD_SERVICE_BROWSERSTACK = 'browserstack';
    public const CLOUD_SERVICE_TESTINGBOT = 'testingbot';

    protected const STATUS_ENDPOINT = '/status';
    protected const TESTSESSION_ENDPOINT = '/grid/api/testsession';
    protected const HUB_ENDPOINT = '/wd/hub';
    protected const DEFAULT_PORT = 4444;
    protected const DEFAULT_PORT_CLOUD_SERVICE = 80;
    protected const DEFAULT_PORT_CLOUD_SERVICE_HTTPS = 443;

    /** @var array */
    protected $serverUrlParts;
    /** @var string */
    protected $lastError = '';
    /** @var string */
    protected $cloudService;

    public function __construct(string $serverUrl)
    {
        $this->serverUrlParts = $this->parseServerUrl($serverUrl);
    }

    /**
     * Get description of last error
     */
    public function getLastError(): string
    {
        return $this->lastError;
    }

    public function getServerUrlParts(): array
    {
        return $this->serverUrlParts;
    }

    public function getServerUrl(): string
    {
        $parts = $this->serverUrlParts;

        $serverUrl = $parts['scheme'] . '://';
        $serverUrl .= $parts['user'] ?? '';
        $serverUrl .= isset($parts['pass']) ? ':' . $parts['pass'] : '';
        $serverUrl .= (isset($parts['user']) || isset($parts['pass'])) ? '@' : '';
        $serverUrl .= $parts['host'] . ':' . $parts['port'];
        $serverUrl .= isset($parts['path']) ? rtrim($parts['path'], '/') : '';
        $serverUrl .= isset($parts['query']) ? '?' . $parts['query'] : '';

        return $serverUrl;
    }

    /**
     * Test if server URL is accessible and we can connected there
     */
    public function isAccessible(): bool
    {
        // Check connection to server is possible
        $seleniumConnection = @fsockopen(
            $this->serverUrlParts['host'],
            $this->serverUrlParts['port'],
            $connectionErrorNo,
            $connectionError,
            5
        );
        if (!is_resource($seleniumConnection)) {
            $this->lastError = $connectionError ?? 'unknown connection error';

            return false;
        }
        fclose($seleniumConnection);

        return true;
    }

    /**
     * Test if remote server is really a Selenium server and is ready to accept connection
     */
    public function isSeleniumServer(): bool
    {
        // Check server properly responds to http requests
        $context = stream_context_create(['http' => ['ignore_errors' => true, 'timeout' => 5]]);
        $responseData = @file_get_contents($this->getServerUrl() . self::STATUS_ENDPOINT, false, $context);

        if (!$responseData) {
            $this->lastError = 'error reading server response';

            return false;
        }

        $decodedData = json_decode($responseData);

        if (!$decodedData) {
            $this->lastError = 'error parsing server JSON response (' . json_last_error_msg() . ')';

            return false;
        }

        $this->cloudService = $this->detectCloudServiceByStatus($decodedData);

        return true;
    }

    /**
     * Get name of the cloud service we are connected to.
     *
     * @return string Cloud service identifier; empty string if no cloud service detected
     */
    public function getCloudService(): string
    {
        // If cloud service value is not yet initialized, attempt to connect to the server first
        if ($this->cloudService === null) {
            if (!$this->isSeleniumServer()) {
                throw new \RuntimeException(sprintf('Unable to connect to remote server: %s', $this->getLastError()));
            }
        }

        return $this->cloudService;
    }

    /**
     * Get URL of the concrete executor (node) of given session.
     * Note this is specific non-standard feature of Selenium server (and available only in grid mode), so empty string
     * will be returned if the server does not provide this functionality.
     */
    public function getSessionExecutor(string $sessionId): string
    {
        $context = stream_context_create(['http' => ['ignore_errors' => true, 'timeout' => 1]]);

        // Selenium server URL ends with /wd/hub. But the endpoint to get the session info is available on URL
        // which starts from the same root path as the /wd/hub - thus we must remove this path.
        $endpointUrl = $this->removeHubEndpointPathIfPresent($this->getServerUrl()) . self::TESTSESSION_ENDPOINT;

        $responseData = @file_get_contents($endpointUrl . '?session=' . $sessionId, false, $context);

        if (!$responseData) {
            return '';
        }

        $responseJson = json_decode($responseData);
        if (!$responseJson || empty($responseJson->proxyId)) {
            return '';
        }

        return $responseJson->proxyId;
    }

    /**
     * @return array URL parts. Scheme, host and port are always non-empty.
     */
    protected function parseServerUrl(string $seleniumServerUrl): array
    {
        $urlParts = parse_url($seleniumServerUrl);

        if (!is_array($urlParts) || empty($urlParts['scheme']) || empty($urlParts['host'])) {
            throw new \RuntimeException(sprintf('Provided Selenium server URL "%s" is invalid', $seleniumServerUrl));
        }

        if (empty($urlParts['port'])) {
            $urlParts['port'] = $this->guessPort($urlParts['host'], $urlParts['scheme']);
        }

        return $urlParts;
    }

    /**
     * Guess port for given service
     */
    protected function guessPort(string $host, string $scheme): int
    {
        if ($scheme === 'https') {
            return self::DEFAULT_PORT_CLOUD_SERVICE_HTTPS;
        }

        foreach (['saucelabs.com', 'browserstack.com', 'testingbot.com'] as $knownCloudHost) {
            if (mb_strpos($host, $knownCloudHost) !== false) {
                return self::DEFAULT_PORT_CLOUD_SERVICE;
            }
        }

        return self::DEFAULT_PORT;
    }

    protected function removeHubEndpointPathIfPresent(string $path): string
    {
        $path = preg_replace(
            '/^(.*)(' . preg_quote(self::HUB_ENDPOINT, '/') . '\/?)$/',
            '$1',
            $path
        );

        return $path;
    }

    /**
     * Detect cloud service using server status response
     *
     * @param object $responseData
     */
    private function detectCloudServiceByStatus($responseData): string
    {
        if (isset($responseData->value, $responseData->value->build, $responseData->value->build->version)) {
            if ($responseData->value->build->version === 'Sauce Labs') {
                return self::CLOUD_SERVICE_SAUCELABS;
            } elseif ($responseData->value->build->version === 'TestingBot') {
                return self::CLOUD_SERVICE_TESTINGBOT;
            } elseif (!isset($responseData->class) && !isset($responseData->value->ready)) {
                return self::CLOUD_SERVICE_BROWSERSTACK;
            }
        }

        return '';
    }
}


<?php declare(strict_types=1);

namespace Lmc\Steward\Process;

use Lmc\Steward\Console\Command\RunCommand;
use Lmc\Steward\Console\CommandEvents;
use Lmc\Steward\Console\Configuration\ConfigOptions;
use Lmc\Steward\Console\Event\RunTestsProcessEvent;
use Lmc\Steward\Publisher\AbstractPublisher;
use Lmc\Steward\Utils\Strings;
use Nette\Reflection\AnnotationsParser;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Finder\SplFileInfo;
use Symfony\Component\Process\Process;

/**
 * Class to encapsulate creation of ProcessSet from files
 */
class ProcessSetCreator
{
    /** @var RunCommand */
    protected $command;
    /** @var InputInterface */
    protected $input;
    /** @var OutputInterface */
    protected $output;
    /** @var ProcessSet */
    protected $processSet;
    /** @var AbstractPublisher */
    protected $publisher;
    /** @var array */
    protected $config;

    public function __construct(
        RunCommand $command,
        InputInterface $input,
        OutputInterface $output,
        AbstractPublisher $publisher,
        array $config
    ) {
        $this->command = $command;
        $this->input = $input;
        $this->output = $output;
        $this->publisher = $publisher;
        $this->config = $config;
    }

    /**
     * Create ProcessSet from given files, optionally filtering by given $groups and $excludeGroups
     *
     * @param array $groups Groups to be run
     * @param array $excludeGroups Groups to be excluded
     * @param string $filter Filter test cases by given name
     * @param bool $ignoreDelays Should test delays be ignored?
     */
    public function createFromFiles(
        Finder $files,
        array $groups,
        array $excludeGroups,
        string $filter = null,
        bool $ignoreDelays = false
    ): ProcessSet {
        $files->sortByName();
        $processSet = $this->getProcessSet();

        if ($this->output->isVeryVerbose()) {
            if (!empty($groups) || !empty($excludeGroups) || !empty($filter)) {
                $this->output->writeln('Filtering testcases:');
            }
            if (!empty($groups)) {
                $this->output->writeln(sprintf(' - by group(s): %s', implode(', ', $groups)));
            }
            if (!empty($excludeGroups)) {
                $this->output->writeln(sprintf(' - excluding group(s): %s', implode(', ', $excludeGroups)));
            }
            if (!empty($filter)) {
                $this->output->writeln(sprintf(' - by testcase/test name: %s', $filter));
            }
        }

        $testCasesNum = 0;
        /** @var SplFileInfo $file */
        foreach ($files as $file) {
            $fileName = $file->getRealPath();
            $className = $this->getClassNameFromFile($fileName);
            $annotations = $this->getClassAnnotations($className, $fileName);

            if ($excludingGroups = $this->getExcludingGroups($excludeGroups, $annotations)) {
                $this->output->writeln(
                    sprintf('Excluding testcase file %s with group %s', $fileName, implode(', ', $excludingGroups)),
                    OutputInterface::VERBOSITY_DEBUG
                );
                continue;
            }

            // Filter out test-cases without any matching group
            if (!empty($groups)) {
                if (!array_key_exists('group', $annotations)
                    || !count($matchingGroups = array_intersect($groups, $annotations['group']))
                ) {
                    continue;
                }

                $this->output->writeln(
                    sprintf(
                        'Found testcase file #%d in group %s: %s',
                        ++$testCasesNum,
                        implode(', ', $matchingGroups),
                        $fileName
                    ),
                    OutputInterface::VERBOSITY_DEBUG
                );
            } else {
                $this->output->writeln(
                    sprintf('Found testcase file #%d: %s', ++$testCasesNum, $fileName),
                    OutputInterface::VERBOSITY_DEBUG
                );
            }

            $phpunitArgs = [
                '--log-junit=' . $this->config[ConfigOptions::LOGS_DIR] . '/'
                . Strings::toFilename($className)
                . '.xml',
                '--configuration=' . realpath(__DIR__ . '/../phpunit.xml'),
            ];

            if (!empty($filter)) {
                $phpunitArgs[] = '--filter=' . $filter;
            }

            // If ANSI output is enabled, turn on colors in PHPUnit
            if ($this->output->isDecorated()) {
                $phpunitArgs[] = '--colors=always';
            }

            $processWrapper = new ProcessWrapper(
                $this->buildProcess($fileName, $phpunitArgs),
                $className,
                $this->publisher
            );

            if (!$ignoreDelays) {
                $this->setupProcessDelays($processWrapper, $annotations);
            }

            $processSet->add($processWrapper);
        }

        return $processSet;
    }

    /**
     * Build Process instance for each testcase file
     */
    protected function buildProcess(string $fileName, array $phpunitArgs = []): Process
    {
        $capabilities = (new KeyValueCapabilityOptionsParser())
            ->parse($this->input->getOption(RunCommand::OPTION_CAPABILITY));

        $env = [
            'BROWSER_NAME' => $this->input->getArgument(RunCommand::ARGUMENT_BROWSER),
            'ENV' => mb_strtolower($this->input->getArgument(RunCommand::ARGUMENT_ENVIRONMENT)),
            'CAPABILITY' => json_encode($capabilities),
            'CAPABILITIES_RESOLVER' => $this->config[ConfigOptions::CAPABILITIES_RESOLVER],
            'SERVER_URL' => $this->input->getOption(RunCommand::OPTION_SERVER_URL),
            'LOGS_DIR' => $this->config[ConfigOptions::LOGS_DIR],
            'DEBUG' => $this->output->isDebug() ? '1' : '0',
        ];

        $dispatcher = $this->command->getDispatcher();
        $dispatcher->dispatch(
            CommandEvents::RUN_TESTS_PROCESS,
            $processEvent = new RunTestsProcessEvent(
                $this->command,
                $this->input,
                $this->output,
                $env,
                $phpunitArgs
            )
        );

        $phpunitExecutable = realpath(__DIR__ . '/../../bin/phpunit-steward');

        $commandLine = array_merge([PHP_BINARY, $phpunitExecutable], $processEvent->getArgs(), [$fileName]);

        return new Process($commandLine, STEWARD_BASE_DIR, $processEvent->getEnvironmentVars(), null, 3600);
    }

    protected function getProcessSet(): ProcessSet
    {
        if ($this->processSet === null) {
            $this->processSet = new ProcessSet();
            $this->processSet->setPublisher($this->publisher);
        }

        return $this->processSet;
    }

    /**
     * Get array of groups that cause this class to be excluded.
     *
     * @return array Empty array is returned if class should not be excluded.
     */
    private function getExcludingGroups(array $excludeGroups, array $annotations): array
    {
        $excludingGroups = [];

        if (!empty($excludeGroups) && array_key_exists('group', $annotations)) {
            if (!empty(array_intersect($excludeGroups, $annotations['group']))) {
                $excludingGroups = array_intersect($excludeGroups, $annotations['group']);
            }
        }

        return $excludingGroups;
    }

    private function getClassNameFromFile(string $fileName): string
    {
        // Parse classes from the testcase file
        $classes = AnnotationsParser::parsePhp(\file_get_contents($fileName));

        if (empty($classes)) {
            throw new \RuntimeException(sprintf('No class found in file "%s"', $fileName));
        }

        if (count($classes) > 1) {
            throw new \RuntimeException(
                sprintf(
                    'File "%s" contains definition of %d classes. However, each class must be defined in its own'
                    . ' separate file.',
                    $fileName,
                    count($classes)
                )
            );
        }

        return key($classes);
    }

    private function setupProcessDelays(ProcessWrapper $processWrapper, array $annotations): void
    {
        $delayAfter = !empty($annotations['delayAfter']) ? current($annotations['delayAfter']) : '';
        $delayMinutes = !empty($annotations['delayMinutes']) ? current($annotations['delayMinutes']) : null;

        if ($delayAfter) {
            $processWrapper->setDelay($delayAfter, $delayMinutes);
        } elseif ($delayMinutes !== null && empty($delayAfter)) {
            throw new \InvalidArgumentException(
                sprintf(
                    'Testcase "%s" has defined delay %d minutes, '
                    . 'but doesn\'t have defined the testcase to run after',
                    $processWrapper->getClassName(),
                    $delayMinutes
                )
            );
        }
    }

    /**
     * Get annotations for the first class in testcase (one file = one class)
     */
    private function getClassAnnotations(string $className, string $fileName): array
    {
        try {
            return AnnotationsParser::getAll(new \ReflectionClass($className));
        } catch (\ReflectionException $e) {
            throw new \RuntimeException(
                sprintf(
                    'Error loading class "%s" from file "%s". Make sure the class name and namespace matches '
                    . 'the file path.',
                    $className,
                    $fileName
                )
            );
        }
    }
}


<?php declare(strict_types=1);

namespace Lmc\Steward\Listener;

use Facebook\WebDriver\Exception\WebDriverException;
use Facebook\WebDriver\Remote\RemoteWebDriver;
use Lmc\Steward\ConfigProvider;
use Lmc\Steward\Test\AbstractTestCase;
use Lmc\Steward\Utils\Strings;
use PHPUnit\Framework\AssertionFailedError;
use PHPUnit\Framework\Test;
use PHPUnit\Framework\TestListener;
use PHPUnit\Framework\TestListenerDefaultImplementation;

/**
 * Listener to take snapshots of the page (screenshot and html snapshot) on each error or failure.
 */
class SnapshotListener implements TestListener
{
    use TestListenerDefaultImplementation;

    public function addError(Test $test, \Throwable $e, float $time): void
    {
        if ($test instanceof AbstractTestCase) {
            $this->takeSnapshot($test);
        }
    }

    public function addFailure(Test $test, AssertionFailedError $e, float $time): void
    {
        if ($test instanceof AbstractTestCase) {
            $this->takeSnapshot($test);
        }
    }

    /**
     * Take screenshot and HTML snapshot of the page and save it.
     */
    protected function takeSnapshot(AbstractTestCase $test): void
    {
        if (!$test->wd instanceof RemoteWebDriver) {
            $test->appendTestLog('[WARN] WebDriver instance not found, cannot take snapshot.');

            return;
        }

        $savePath = ConfigProvider::getInstance()->logsDir . DIRECTORY_SEPARATOR;
        $testIdentifier = $this->assembleTestIdentifier($test);

        ob_start();
        $outputBufferClosed = false;

        try {
            $currentUrl = $test->wd->getCurrentURL();
            // Save PNG screenshot
            $screenshotPath = $savePath . $testIdentifier . '.png';
            $test->wd->takeScreenshot($screenshotPath);
            // Save HTML snapshot of page
            $htmlPath = $savePath . $testIdentifier . '.html';
            file_put_contents($htmlPath, $test->wd->getPageSource());

            $bufferedOutput = ob_get_clean();
            $outputBufferClosed = true;
            $test->appendFormattedTestLog($bufferedOutput);

            $test->appendTestLog('');
            $test->appendTestLog('[WARN] Test failed on page "%s", taking page snapshots:', $currentUrl);
            $test->appendTestLog('Screenshot: "%s"', $this->getSnapshotUrl($screenshotPath));
            $test->appendTestLog('HTML snapshot: "%s"', $this->getSnapshotUrl($htmlPath));
            $test->appendTestLog('');
        } catch (WebDriverException $e) {
            $test->appendTestLog('[WARN] Error taking page snapshot, perhaps browser is not accessible?');

            return;
        } finally {
            if (!$outputBufferClosed) {
                $test->appendFormattedTestLog(ob_get_clean());
            }
        }
    }

    /**
     * Get url based on relative path of specific snapshot.
     * In our implementation we prepend artifact's URL to given relative path to make it clickable in Jenkins output.
     */
    protected function getSnapshotUrl(string $path): string
    {
        if (getenv('JENKINS_URL') && getenv('BUILD_URL') && getenv('WORKSPACE')) {
            $realPath = realpath($path);
            if ($realPath) {
                // from absolute path, remove workspace
                $path = str_replace(getenv('WORKSPACE'), '', $realPath);
                // prepend url to artifact
                $path = getenv('BUILD_URL') . 'artifact' . DIRECTORY_SEPARATOR . $path;
            }
        }

        return $path;
    }

    private function assembleTestIdentifier(AbstractTestCase $testCase): string
    {
        return sprintf(
            '%s-%s-%s',
            Strings::toFilename(get_class($testCase)),
            Strings::toFilename($testCase->getName()),
            date('Y-m-d-H-i-s')
        );
    }
}


<?php declare(strict_types=1);

namespace Lmc\Steward\Console\EventListener;

use Lmc\Steward\Console\CommandEvents;
use Lmc\Steward\Console\Event\BasicConsoleEvent;
use Lmc\Steward\Console\Event\ExtendedConsoleEvent;
use Lmc\Steward\Console\Event\RunTestsProcessEvent;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

/**
 * Adds option to use Xdebug remote debugger on run testcases (so you can add breakpoints, step the tests etc.).
 *
 * @see https://github.com/lmc-eu/steward/wiki/Debugging-Selenium-tests-with-Steward
 */
class XdebugListener implements EventSubscriberInterface
{
    public const OPTION_XDEBUG = 'xdebug';
    public const DEFAULT_VALUE = 'phpstorm';
    public const DOCS_URL = 'https://github.com/lmc-eu/steward/wiki/Debugging-Selenium-tests-with-Steward';

    /** @var string */
    protected $xdebugIdeKey;

    public static function getSubscribedEvents(): array
    {
        return [
            CommandEvents::CONFIGURE => 'onCommandConfigure',
            CommandEvents::RUN_TESTS_INIT => 'onCommandRunTestsInit',
            CommandEvents::RUN_TESTS_PROCESS => 'onCommandRunTestsProcess',
        ];
    }

    /**
     * Add option to `run` command configuration.
     */
    public function onCommandConfigure(BasicConsoleEvent $event): void
    {
        if ($event->getCommand()->getName() !== 'run') {
            return;
        }

        $event->getCommand()->addOption(
            self::OPTION_XDEBUG,
            null,
            InputOption::VALUE_OPTIONAL,
            'Start Xdebug debugger on tests. Pass custom IDE key if needed for your IDE settings.',
            ''
        );
    }

    /**
     * Get input option on command initialization
     */
    public function onCommandRunTestsInit(ExtendedConsoleEvent $event): void
    {
        $input = $event->getInput();
        $output = $event->getOutput();

        $this->xdebugIdeKey = $this->getIdeKeyFromInputOption($input);

        if ($this->xdebugIdeKey === null) {
            return;
        }

        if (!extension_loaded('xdebug')) {
            throw new \RuntimeException(
                sprintf(
                    'Extension Xdebug is not loaded or installed. See %s for help and more information.',
                    self::DOCS_URL
                )
            );
        }

        if (!ini_get('xdebug.remote_enable')) {
            throw new \RuntimeException(
                sprintf(
                    'The xdebug.remote_enable directive must be set to true to enable remote debugging. '
                    . 'See %s for help and more information.',
                    self::DOCS_URL
                )
            );
        }

        $output->writeln(
            sprintf('Xdebug remote debugging initialized with IDE key: %s', $this->xdebugIdeKey),
            OutputInterface::VERBOSITY_DEBUG
        );
    }

    /**
     * If the $xdebugIdeKey variable is set, pass it to the process as XDEBUG_CONFIG environment variable
     */
    public function onCommandRunTestsProcess(RunTestsProcessEvent $event): void
    {
        if ($this->xdebugIdeKey) {
            $env = $event->getEnvironmentVars();
            $env['XDEBUG_CONFIG'] = 'idekey=' . $this->xdebugIdeKey;
            $event->setEnvironmentVars($env);
        }
    }

    /**
     * If --xdebug option was not passed at all, return null to not activate the feature.
     * If the option was used without a value, use the default value of idekey.
     * If the option was passed with custom (not empty) value, use this value.
     */
    protected function getIdeKeyFromInputOption(InputInterface $input): ?string
    {
        $optionValue = $input->getOption(self::OPTION_XDEBUG);

        if ($optionValue === null) { // no custom value was passed => use default
            return self::DEFAULT_VALUE;
        }

        if ($optionValue === '') { // empty value was passed => do not enable the feature
            return null;
        }

        return $optionValue;
    }
}


<?php declare(strict_types=1);

namespace Lmc\Steward\Console\EventListener;

use Nette\Reflection\AnnotationsParser;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Finder\SplFileInfo;

/**
 * Search and instantiate event-listeners for the commands
 */
class ListenerInstantiator
{
    /** @var string Searched pattern for listeners, used to exclude all other paths and speed up the search */
    protected $searchPathPattern = 'src/Console/EventListener';

    /**
     * Instantiate listeners in given directory and register them to given dispatcher
     */
    public function instantiate(EventDispatcher $dispatcher, string $dirToSearchForListeners): void
    {
        $listeners = $this->searchListeners($dirToSearchForListeners);

        foreach ($listeners as $listener) {
            $r = new \ReflectionClass($listener);
            if ($r->implementsInterface('Symfony\\Component\\EventDispatcher\\EventSubscriberInterface')
                && !$r->isAbstract()
            ) {
                /** @var EventSubscriberInterface $listenerInstance */
                $listenerInstance = $r->newInstanceWithoutConstructor();
                $dispatcher->addSubscriber($listenerInstance);
            }
        }
    }

    /**
     * @return array Array of listener class names
     */
    protected function searchListeners(string $dir): array
    {
        /** @var Finder $files */
        $files = (new Finder())
            ->files()
            ->in($dir)
            ->path($this->searchPathPattern)
            ->name('*Listener.php');

        $listeners = [];
        /** @var SplFileInfo $file */
        foreach ($files as $file) {
            $listeners[] = key(AnnotationsParser::parsePhp(\file_get_contents($file->getRealPath())));
        }

        return $listeners;
    }

    /**
     * Set searched pattern path for event listeners.
     *
     * @internal Should be only overridden in testing.
     */
    public function setSearchPathPattern(string $searchPathPattern): void
    {
        $this->searchPathPattern = $searchPathPattern;
    }
}


<?php declare(strict_types=1);

namespace Lmc\Steward\Console\Command;

use Lmc\Steward\Console\CommandEvents;
use Lmc\Steward\Console\Configuration\ConfigOptions;
use Lmc\Steward\Console\Event\BasicConsoleEvent;
use Lmc\Steward\Publisher\XmlPublisher;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Finder\SplFileInfo;

/**
 * Clean previous log files in the logs directory
 */
class CleanCommand extends Command
{
    /** @var Filesystem */
    private $filesystem;

    public function __construct(EventDispatcher $dispatcher, string $name = null)
    {
        $this->filesystem = new Filesystem();

        parent::__construct($dispatcher, $name);
    }

    /**
     * @internal
     */
    public function setFilesystem(Filesystem $filesystem): void
    {
        $this->filesystem = $filesystem;
    }

    /**
     * Configure command
     */
    protected function configure(): void
    {
        $this->setName('clean')
            ->setDescription('Clean logs directory')
            ->addOption(
                RunCommand::OPTION_LOGS_DIR,
                null,
                InputOption::VALUE_REQUIRED,
                'Path to directory with logs',
                STEWARD_BASE_DIR . DIRECTORY_SEPARATOR . 'logs'
            );

        $this->getDispatcher()->dispatch(CommandEvents::CONFIGURE, new BasicConsoleEvent($this));
    }

    protected function resolveConfiguration(InputInterface $input): array
    {
        if ($this->isDefaultLogsDirUsed($input)) {
            $this->createLogsDirectoryIfNotExists(
                $input->getOption(RunCommand::OPTION_LOGS_DIR)
            );
        }

        return parent::resolveConfiguration($input);
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $logsDir = $this->config[ConfigOptions::LOGS_DIR];

        $this->cleanDirectory($logsDir);

        return 0;
    }

    protected function isDefaultLogsDirUsed(InputInterface $input): bool
    {
        $logsDir = $input->getOption(RunCommand::OPTION_LOGS_DIR);
        $defaultLogsDir = $this->getDefinition()->getOption(RunCommand::OPTION_LOGS_DIR)->getDefault();

        return $logsDir === $defaultLogsDir;
    }

    private function createLogsDirectoryIfNotExists(string $logsDir): void
    {
        if (!$this->filesystem->exists($logsDir)) {
            $this->filesystem->mkdir($logsDir);
        }
    }

    private function cleanDirectory(string $logsDir): void
    {
        /** @var Finder $finder */
        $finder = (new Finder())
            ->files()
            ->in($logsDir)
            ->depth('== 0')
            ->name('*.html')
            ->name('*.png')
            ->name('*.xml')
            ->notName(XmlPublisher::FILE_NAME);

        /** @var SplFileInfo $file */
        foreach ($finder as $file) {
            $this->filesystem->remove($file->getRealPath());
        }
    }
}


<?php declare(strict_types=1);

namespace Lmc\Steward\Publisher;

use Facebook\WebDriver\Remote\RemoteWebDriver;
use Lmc\Steward\ConfigProvider;
use Lmc\Steward\Selenium\SeleniumServerAdapter;
use Lmc\Steward\Test\AbstractTestCase;
use PHPUnit\Framework\Test;

class XmlPublisher extends AbstractPublisher
{
    /** @var string Default name of results file. */
    public const FILE_NAME = 'results.xml';
    /** @var string */
    protected $fileDir;
    /** @var string */
    protected $fileName = self::FILE_NAME;
    /** @var resource|null */
    protected $fileHandle;

    /**
     * Set directory where results file should be stored. Usable when config object is not available (when
     * not called from PHPUnit testcase but from Command). If the file dir is not set, the value from Config object
     * is used.
     */
    public function setFileDir(string $dir): void
    {
        $this->fileDir = $dir;
    }

    /**
     * Change file name from the default. Mostly usable for testing.
     */
    public function setFileName(string $fileName): void
    {
        $this->fileName = $fileName;
    }

    /**
     * Get full path to results file.
     */
    public function getFilePath(): string
    {
        if (!$this->fileDir) {
            $this->fileDir = ConfigProvider::getInstance()->logsDir;
        }

        return $this->fileDir . '/' . $this->fileName;
    }

    /**
     * Clean the file with all previous results (if exists).
     */
    public function clean(): void
    {
        if (file_exists($this->getFilePath())) {
            unlink($this->getFilePath());
        }
    }

    public function publishResults(
        string $testCaseName,
        string $status,
        string $result = null,
        \DateTimeInterface $testCaseStartDate = null,
        \DateTimeInterface $testCaseEndDate = null
    ): void {
        $xml = $this->readAndLock();

        $testCaseNode = $this->getTestCaseNode($xml, $testCaseName);
        $testCaseNode['status'] = $status;
        if (!empty($result)) {
            $testCaseNode['result'] = $result;
        }
        if ($testCaseStartDate) {
            $testCaseNode['start'] = $testCaseStartDate->format(\DateTime::ISO8601);
        }
        if ($testCaseEndDate) {
            $testCaseNode['end'] = $testCaseEndDate->format(\DateTime::ISO8601);
        }

        $this->writeAndUnlock($xml);
    }

    public function publishResult(
        string $testCaseName,
        string $testName,
        Test $testInstance,
        string $status,
        string $result = null,
        string $message = null
    ): void {
        if (!in_array($status, self::TEST_STATUSES, true)) {
            throw new \InvalidArgumentException(
                sprintf('Tests status must be one of "%s", but "%s" given', implode(', ', self::TEST_STATUSES), $status)
            );
        }
        if ($result !== null && !in_array($result, self::TEST_RESULTS, true)) {
            throw new \InvalidArgumentException(
                sprintf(
                    'Tests result must be null or one of "%s", but "%s" given',
                    implode(', ', self::TEST_RESULTS),
                    $result
                )
            );
        }

        $xml = $this->readAndLock();

        $testCaseNode = $this->getTestCaseNode($xml, $testCaseName);
        $testNode = $this->getTestNode($testCaseNode, $testCaseName, $testName);

        $testNode['status'] = $status;

        if ($status === self::TEST_STATUS_STARTED) {
            $testNode['start'] = (new \DateTimeImmutable())->format(\DateTime::ISO8601);

            $executor = $this->getTestExecutor($testInstance);
            if ($executor) {
                $testNode['executor'] = $executor;
            }
        }
        if ($status === self::TEST_STATUS_DONE) {
            $testNode['end'] = (new \DateTimeImmutable())->format(\DateTime::ISO8601);
        }

        if ($result !== null) {
            $testNode['result'] = $result;
        }

        $this->writeAndUnlock($xml);
    }

    /**
     * Get element for test case of given name. If id does not exist yet, it is created.
     */
    protected function getTestCaseNode(\SimpleXMLElement $xml, string $testCaseName): \SimpleXMLElement
    {
        $testcaseNode = $xml->xpath(sprintf('//testcase[@name=%s]', $this->quoteXpathAttribute($testCaseName)));

        if (!$testcaseNode) {
            $testcaseNode = $xml->addChild('testcase');
            $testcaseNode->addAttribute('name', $testCaseName);
        } else {
            $testcaseNode = reset($testcaseNode);
        }

        return $testcaseNode;
    }

    /**
     * Get element for test of given name. If id does not exist yet, it is created.
     */
    protected function getTestNode(\SimpleXMLElement $xml, string $testCaseName, string $testName): \SimpleXMLElement
    {
        $testNode = $xml->xpath(
            sprintf(
                '//testcase[@name=%s]/test[@name=%s]',
                $this->quoteXpathAttribute($testCaseName),
                $this->quoteXpathAttribute($testName)
            )
        );

        if (!$testNode) {
            $testNode = $xml->addChild('test');
            $testNode->addAttribute('name', $testName);
        } else {
            $testNode = reset($testNode);
        }

        return $testNode;
    }

    protected function readAndLock(): \SimpleXMLElement
    {
        $file = $this->getFilePath();
        $fileDir = dirname($file);

        if ($this->fileHandle) {
            throw new \RuntimeException(
                sprintf('File "%s" was already opened by this XmlPublisher and closed', $file)
            );
        }

        if (!is_writable($fileDir)) {
            throw new \RuntimeException(sprintf('Directory "%s" does not exist or is not writeable.', $fileDir));
        }

        // open (or create) the file and acquire exclusive lock (or wait until it is acquired)
        $this->fileHandle = fopen($file, 'c+');
        if (!flock($this->fileHandle, LOCK_EX)) {
            throw new \RuntimeException(sprintf('Cannot obtain lock for file "%s"', $file));
        }

        if (fstat($this->fileHandle)['size'] === 0) { // new or empty file => create empty element and add stylesheet
            $xml = new \SimpleXMLElement(
                '<?xml version="1.0" encoding="utf-8" ?>'
                . '<?xml-stylesheet type="text/xsl" href="#stylesheet"?>'
                . '<!DOCTYPE testcases [<!ATTLIST xsl:stylesheet id ID #REQUIRED>]>'
                . '<testcases>'
                . $this->getStylesheet()
                . '</testcases>'
            );
        } else { // file already exists, load current xml
            $fileContents = fread($this->fileHandle, fstat($this->fileHandle)['size']);
            $xml = simplexml_load_string($fileContents);
        }

        return $xml;
    }

    protected function writeAndUnlock(\SimpleXMLElement $xml): void
    {
        if (!$this->fileHandle) {
            throw new \RuntimeException(
                sprintf(
                    'File "%s" was not opened by this XmlPublisher yet (or it was already closed)',
                    $this->getFilePath()
                )
            );
        }

        // remove all file contents
        ftruncate($this->fileHandle, 0);
        rewind($this->fileHandle);

        // write new contents (formatted)
        $dom = new \DOMDocument('1.0');
        $dom->preserveWhiteSpace = false;
        $dom->formatOutput = true;
        $dom->loadXML($xml->asXML());
        fwrite($this->fileHandle, $dom->saveXML());

        // unlock and close the file, remove reference
        flock($this->fileHandle, LOCK_UN);
        fclose($this->fileHandle);
        $this->fileHandle = null;
    }

    private function getStylesheet(): string
    {
        $xslPath = __DIR__ . '/../Resources/results.xsl';
        $xsl = file_get_contents($xslPath);

        return $xsl;
    }

    /**
     * Encapsulate given attribute value into valid xpath expression.
     *
     * @param string $input Value of an xpath attribute selector
     */
    protected function quoteXpathAttribute(string $input): string
    {
        if (mb_strpos($input, '\'') === false) { // Selector does not contain single quotes
            return "'$input'"; // Encapsulate with double quotes
        }

        if (mb_strpos($input, '"') === false) { // Selector contain single quotes but not double quotes
            return "\"$input\""; // Encapsulate with single quotes
        }

        // When both single and double quotes are contained, escape each part individually and concat() all parts
        return "concat('" . strtr($input, ['\'' => '\', "\'", \'']) . "')";
    }

    protected function getTestExecutor(Test $test): string
    {
        if (!$test instanceof AbstractTestCase || !$test->wd instanceof RemoteWebDriver) {
            return '';
        }

        $serverUrl = ConfigProvider::getInstance()->serverUrl;

        $executor = (new SeleniumServerAdapter($serverUrl))
            ->getSessionExecutor($test->wd->getSessionID());

        return $executor;
    }
}


<?php declare(strict_types=1);

namespace Lmc\Steward\Process;

use Fhaculty\Graph\Vertex;
use Graphp\Algorithms\ShortestPath\Dijkstra;
use Graphp\Algorithms\Tree\OutTree;

class MaxTotalDelayStrategy implements OptimizeOrderInterface
{
    /**
     * Optimize order of tests in given tree based on their defined delay.
     * The aim is to run as first processes having the longest delay of their sub-dependencies.
     *
     * @return array Array of [string key (= testclass fully qualified name) => int value (= test order)]
     */
    public function optimize(OutTree $tree): array
    {
        // get root node of the tree
        $root = $tree->getVertexRoot();

        // get all root descendants vertices (without the root vertex itself)
        $children = $tree->getVerticesDescendant($root);

        // for each vertex (process) get maximum total weight of its subtree (longest distance)
        $subTreeMaxDistances = [];
        /** @var Vertex $childVertex */
        foreach ($children as $childVertex) {
            $alg = new Dijkstra($childVertex);
            // get map with distances to all linked reachable vertexes
            $distanceMap = $alg->getDistanceMap();
            // save the longest distance
            $subTreeMaxDistances[$childVertex->getId()] = $distanceMap ? max($distanceMap) : 0;
        }

        return $subTreeMaxDistances;
    }
}


<?php declare(strict_types=1);

namespace Lmc\Steward\Process;

use Symfony\Component\Console\Exception\RuntimeException;

/**
 * Parse colon delimited key:value capabilities passed as an CLI option
 */
class KeyValueCapabilityOptionsParser
{
    private const DELIMITER = ':';

    public function parse(array $capabilities): array
    {
        $outputCapabilities = [];

        foreach ($capabilities as $capability) {
            $parts = explode(self::DELIMITER, $capability);
            if (!isset($parts[0], $parts[1])) {
                throw new RuntimeException(
                    sprintf(
                        'Capability must be given in format "capabilityName:value" but "%s" was given',
                        $capability
                    )
                );
            }

            $outputCapabilities[$parts[0]] = $this->castToGuessedDataType($parts[1]);
        }

        return $outputCapabilities;
    }

    /**
     * Guess most appropriate data type acceptable by JSON
     */
    private function castToGuessedDataType(string $value)
    {
        $stringValueWithoutQuotes = $this->removeEncapsulatingQuotes($value);
        if ($stringValueWithoutQuotes !== null) {
            return $stringValueWithoutQuotes;
        }

        $intValue = filter_var($value, FILTER_VALIDATE_INT, []);
        if ($intValue !== false) {
            return $intValue;
        }

        $floatValue = filter_var($value, FILTER_VALIDATE_FLOAT, []);
        if ($floatValue !== false) {
            return $floatValue;
        }

        $boolValue = filter_var($value, FILTER_VALIDATE_BOOLEAN, ['flags' => FILTER_NULL_ON_FAILURE]);
        if ($boolValue !== null) {
            return $boolValue;
        }

        return $value;
    }

    private function removeEncapsulatingQuotes(string $value): ?string
    {
        $withoutDoubleQuotes = preg_replace('/^"(.+)"$/', '$1', $value);
        if ($withoutDoubleQuotes !== $value) {
            return $withoutDoubleQuotes;
        }

        $withoutSingleQuotes = preg_replace("/^'(.+)'$/", '$1', $value);
        if ($withoutSingleQuotes !== $value) {
            return $withoutSingleQuotes;
        }

        return null;
    }
}


<?php declare(strict_types=1);

namespace Lmc\Steward\Process;

use Assert\Assert;
use Assert\Assertion;
use Lmc\Steward\Publisher\AbstractPublisher;
use PHPUnit\TextUI\TestRunner;
use Symfony\Component\Process\Exception\ProcessTimedOutException;
use Symfony\Component\Process\Process;

/**
 * Wrapper for PHPUnit processes adding some metadata and custom logic
 */
class ProcessWrapper
{
    /** Process prepared to be run */
    public const PROCESS_STATUS_PREPARED = 'prepared';
    /** Finished process */
    public const PROCESS_STATUS_DONE = 'done';
    /** Process in queue - waiting to be prepared */
    public const PROCESS_STATUS_QUEUED = 'queued';

    /** Process failed - some tests have failed or are broken */
    public const PROCESS_RESULT_FAILED = 'failed';
    /** Process fatally failed (PHP fatal error occurred - eg. no WebDriver available) */
    public const PROCESS_RESULT_FATAL = 'fatal';
    /** Process passed successful (with all its tests passing) */
    public const PROCESS_RESULT_PASSED = 'passed';

    /** @var array List of possible process statuses */
    public const PROCESS_STATUSES = [
        self::PROCESS_STATUS_PREPARED,
        self::PROCESS_STATUS_QUEUED,
        self::PROCESS_STATUS_DONE,
    ];
    /** @var array List of possible process results */
    public const PROCESS_RESULTS = [
        self::PROCESS_RESULT_PASSED,
        self::PROCESS_RESULT_FAILED,
        self::PROCESS_RESULT_FATAL,
    ];

    /** @var AbstractPublisher|null */
    private $publisher;
    /** @var Process */
    private $process;
    /** @var string */
    private $className;
    /** @var string|null */
    private $delayAfter;
    /** @var float|null */
    private $delayMinutes;
    /** @var string */
    private $status;
    /** @var string */
    private $result;
    /** @var int */
    private $finishedTime;

    public function __construct(Process $process, string $className, AbstractPublisher $publisher = null)
    {
        $this->process = $process;
        $this->className = $className;
        $this->publisher = $publisher;
        $this->setStatus(self::PROCESS_STATUS_QUEUED);
    }

    public function getClassName(): string
    {
        return $this->className;
    }

    /**
     * @param string $afterClass Fully qualified class name after which this test should be run.
     * @param mixed $minutes Delay execution for that much $minutes after $afterClass test.
     */
    public function setDelay(string $afterClass, $minutes): void
    {
        Assertion::notNull(
            $minutes,
            sprintf('Testcase "%s" should run after "%s", but no delay was defined', $this->getClassName(), $afterClass)
        );

        $assertionError = sprintf(
            'Delay defined in testcase "%s" must be greater than or equal 0, but "%s" was given',
            $this->getClassName(),
            $minutes
        );
        Assert::that($minutes)->numeric($assertionError)->greaterOrEqualThan(0, $assertionError);

        $this->delayAfter = $afterClass;
        $this->delayMinutes = (float) $minutes;
    }

    public function isDelayed(): bool
    {
        return $this->delayAfter !== null;
    }

    public function getDelayAfter(): ?string
    {
        return $this->delayAfter;
    }

    public function getDelayMinutes(): ?float
    {
        return $this->delayMinutes;
    }

    public function getProcess(): Process
    {
        return $this->process;
    }

    public function getStatus(): string
    {
        return $this->status;
    }

    public function setStatus(string $status): void
    {
        Assertion::choice($status, self::PROCESS_STATUSES);

        $this->status = $status;

        if ($status === self::PROCESS_STATUS_DONE) {
            $this->result = $this->resolveResult();
            $this->finishedTime = time();
        }

        if ($this->publisher) {
            $this->publisher->publishResults($this->getClassName(), $status, $this->result);
        }
    }

    public function getResult(): ?string
    {
        return $this->result;
    }

    public function getFinishedTime(): ?int
    {
        return $this->finishedTime;
    }

    /**
     * Check if process is not running longer then specified timeout, return error message if so.
     *
     * @return string Non-empty error message if process timeout exceeded
     */
    public function checkProcessTimeout(): ?string
    {
        try {
            $this->getProcess()->checkTimeout();
        } catch (ProcessTimedOutException $e) {
            $this->setStatus(self::PROCESS_STATUS_DONE);

            return sprintf(
                'Process for class "%s" exceeded the timeout of %d seconds and was killed.',
                $this->getClassName(),
                $e->getExceededTimeout()
            );
        }

        return '';
    }

    /**
     * Resolve result of finished process
     */
    private function resolveResult(): string
    {
        $exitCode = $this->getProcess()->getExitCode();

        // If the process was not even started, mark its result as failed
        if ($exitCode === null) {
            return self::PROCESS_RESULT_FAILED;
        }

        switch ($exitCode) {
            case TestRunner::SUCCESS_EXIT: // all tests passed
                $result = self::PROCESS_RESULT_PASSED;
                // for passed process save just the status and result; end time was saved by TestStatusListener
                break;
            case 15: // Process killed because of timeout, or
            case 9: // Process terminated because of timeout
                $result = self::PROCESS_RESULT_FATAL;
                break;
            case 255: // PHP fatal error
                $result = self::PROCESS_RESULT_FATAL;
                break;
            case TestRunner::EXCEPTION_EXIT: // exception thrown from phpunit
            case TestRunner::FAILURE_EXIT: // some test failed
            default:
                $result = self::PROCESS_RESULT_FAILED;
                break;
        }

        return $result;
    }
}