diff --git a/README.md b/README.md index 0799320..cafa7e4 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,29 @@ Resolution priority: fluent `onVersion()` → `$psVersion` property → `PRESTAF `onVersion()` throws `InvalidArgumentException` on malformed input (expected format: `1.7`, `1.7.8`, `1.7.8.11`, `9`, `9.0`, `9.0.1`, etc.). +## Run only some suite folders + +`prestaflow run ` runs every suite under ``. To run only some of +its sub-folders, list them, comma-separated, relative to ``: + +```bash +./vendor/bin/prestaflow run tests --suites=BackOffice,FrontOffice/Checkout +PRESTAFLOW_SUITES=BackOffice,FrontOffice/Checkout ./vendor/bin/prestaflow run tests +``` + +- Each name is a sub-folder of ``, scanned recursively; nested paths such + as `FrontOffice/Checkout` are allowed. The run takes the union of the folders. +- `--suites` wins over `PRESTAFLOW_SUITES`. The GitHub Action sets + `PRESTAFLOW_SUITES` from its `suites` input. +- A name that matches no folder fails the run (non-zero exit code), listing the + missing names and the available sub-folders. A typo never becomes a green + job that ran zero tests. +- A filtered run that ends up with no suite at all (an empty folder, or + nothing left after `--group` / `--draft`) fails too, for the same reason. +- Absolute paths and `..` are refused: the filter can only narrow ``. +- `--group` and `--draft` still apply, on the suites of the selected folders. +- Unset or empty: every suite under `` runs, as before. + ## Run a suite against a throwaway shop `docker-compose.yml` boots a disposable PrestaShop from the official diff --git a/bin/prestaflow b/bin/prestaflow index 3fff636..ec99468 100755 --- a/bin/prestaflow +++ b/bin/prestaflow @@ -28,48 +28,6 @@ if (!$autoloadLoaded) { exit(1); } -use PrestaFlow\Library\Command\ExecuteSuite; -use Symfony\Component\Console\Application; -use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\ConsoleEvents; -use Symfony\Component\Console\Event\ConsoleErrorEvent; -use Symfony\Component\Console\Style\SymfonyStyle; -use Symfony\Component\EventDispatcher\EventDispatcher; - -$dispatcher = new EventDispatcher(); - -$dispatcher->addListener(ConsoleEvents::ERROR, function (ConsoleErrorEvent $event): void { - - $input = $event->getInput(); - $output = $event->getOutput(); - - $io = new SymfonyStyle($input, $output); - - $command = $event->getCommand(); - - $error = $event->getError(); - - $io->newLine(); - - $io->writeln(sprintf('ERROR %s', $error->getMessage())); - $io->writeln(sprintf('TRACE %s', $error->getFile() . ':' . $error->getLine())); - - foreach ($error->getTrace() as $trace) { - $io->writeln(sprintf('TRACE %s', ($trace['file'] ?? '[internal]') . ':' . ($trace['line'] ?? '?'))); - } - - // gets the current exit code (the exception code) - $exitCode = $event->getExitCode(); - - $event->setExitCode(Command::SUCCESS); - - // changes the exception to another one - $event->setError(new \LogicException('Caught exception', $exitCode, $event->getError())); -}); - -$application = new Application(); - -$application->add(new ExecuteSuite()); -$application->setDispatcher($dispatcher); - -$application->run(); +// Toute la configuration vit dans PrestaFlow\Library\Console\Application (testée) : +// une exception non rattrapée y donne un code de sortie non nul. +(new PrestaFlow\Library\Console\Application())->run(); diff --git a/composer.json b/composer.json index 71bc1a1..cea7697 100644 --- a/composer.json +++ b/composer.json @@ -3,6 +3,9 @@ "description": "PrestaFlow is an open-source set of prebuilt tests components, ready-to-use examples made for the PrestaShop E-commerce software.", "type": "library", "license": "MIT", + "bin": [ + "bin/prestaflow" + ], "autoload": { "psr-4": { "PrestaFlow\\Library\\": "src/", @@ -32,9 +35,9 @@ "chrome-php/chrome": "^1.10", "sapientpro/image-comparator": "^1.0", "inceddy/image-compare": "^1.0", - "symfony/console": "^6.0", + "symfony/console": "^6.0 || ^7.0", "vlucas/phpdotenv": "^5.6", - "symfony/event-dispatcher": "^6.0", + "symfony/event-dispatcher": "^6.0 || ^7.0", "smalot/pdfparser": "^2.11" }, "config": { diff --git a/composer.lock b/composer.lock index b8c701d..b46b0a6 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "32eb25d35312d85760b8197bdf21cdd9", + "content-hash": "d58f5272208350707fcab29adbde6c2b", "packages": [ { "name": "brick/math", diff --git a/src/Command/ExecuteSuite.php b/src/Command/ExecuteSuite.php index b9afe06..2c9a439 100644 --- a/src/Command/ExecuteSuite.php +++ b/src/Command/ExecuteSuite.php @@ -55,6 +55,7 @@ protected function configure(): void ->addOption('visual-report', null, InputOption::VALUE_OPTIONAL, 'Écrit un rapport de régression visuelle (HTML)', false) ->addOption('visual-report-tz', null, InputOption::VALUE_REQUIRED, 'Fuseau horaire du stamp du rapport visuel (ex. Europe/Brussels). Défaut : env PRESTAFLOW_TZ ou UTC.') ->addOption('draft', 'd', InputOption::VALUE_NEGATABLE, 'Draft mode') + ->addOption('suites', null, InputOption::VALUE_REQUIRED, 'Comma-separated sub-folders of the suites path to run (e.g. BackOffice,FrontOffice/Checkout). Overrides env PRESTAFLOW_SUITES.') ->addArgument('folder', InputArgument::OPTIONAL, 'The folder name', 'tests') ->addOption( 'group', @@ -117,6 +118,7 @@ protected function outputTitle() public function execute(InputInterface $input, OutputInterface $output): int { + $this->beginRun(); $this->handleDir(dirname($this->file)); $this->cli = true; $this->output = $output; @@ -138,7 +140,11 @@ public function execute(InputInterface $input, OutputInterface $output): int $junitPath = ($junitOption === false) ? null : ($junitOption ?: 'prestaflow/junit.xml'); try { - $testSuites = $this->resolveSuitePaths((string) $input->getArgument('folder')); + // --suites wins over PRESTAFLOW_SUITES (set by the GitHub Action). + $suitesFilter = $this->parseSuitesFilter( + $input->getOption('suites') ?? Env::get('PRESTAFLOW_SUITES') + ); + $testSuites = $this->resolveSuitePaths((string) $input->getArgument('folder'), $suitesFilter); } catch (Error $e) { $this->sections['progressIndicator']->finish('Finished'); $this->sections['progressBar']->clear(); @@ -149,6 +155,7 @@ public function execute(InputInterface $input, OutputInterface $output): int $this->sections['progressIndicator']->finish('Finished'); $this->sections['progressBar']->clear(); + $this->failOnEmptyFilteredRun($suitesFilter, (string) $input->getArgument('folder')); $this->success('Tests folder is empty', newLine: true); return Command::SUCCESS; }; @@ -255,6 +262,7 @@ public function execute(InputInterface $input, OutputInterface $output): int } if (!$nbSuites) { + $this->failOnEmptyFilteredRun($suitesFilter, (string) $input->getArgument('folder')); $this->success('Tests folder is empty', newLine: true); return Command::SUCCESS; }; @@ -295,14 +303,36 @@ public function execute(InputInterface $input, OutputInterface $output): int $this->success('Rapport visuel écrit dans ' . $visualPath, newLine: true, force: true); } + $this->releaseBrowser(); + + return $summary->hasFailures() ? Command::FAILURE : Command::SUCCESS; + } + + /** + * Give this run a browser of its own. + * + * The run ends by closing its browser (releaseBrowser()), so it must never + * pick up one another PrestaFlow process is driving, nor let that process + * find and close its own. Scoping the socket file to this run does both. + */ + public function beginRun(): void + { + \PrestaFlow\Library\Tests\TestsSuite::scopeBrowserFilesTo( + 'run-' . getmypid() . '-' . bin2hex(random_bytes(4)) + ); + } + + /** + * Close the browser this run launched and forget its socket file. + */ + public function releaseBrowser(): void + { try { \PrestaFlow\Library\Tests\TestsSuite::getBrowser(force: false)?->close(); } catch (\Throwable $e) { } @unlink(\PrestaFlow\Library\Tests\TestsSuite::getFilePath('.browser')); @unlink(\PrestaFlow\Library\Tests\TestsSuite::getFilePath('.browser-options')); - - return $summary->hasFailures() ? Command::FAILURE : Command::SUCCESS; } protected function handleDir($path) @@ -443,14 +473,24 @@ public function candidatePaths(string $argument): array * * @throws Error when the argument matches neither a directory nor a suite file */ - public function resolveSuitePaths(string $argument): array + public function resolveSuitePaths(string $argument, array $subFolders = []): array { foreach ($this->candidatePaths($argument) as $path) { if (is_dir($path)) { - return $this->getTestsSuites($path); + return $subFolders === [] + ? $this->getTestsSuites($path) + : $this->getTestsSuitesIn($path, $subFolders); } if (is_file($path)) { + if ($subFolders !== []) { + throw new Error(sprintf( + '[%s] is a single suite file: a suites filter (%s) needs a folder', + $path, + implode(', ', $subFolders) + )); + } + if (!str_ends_with($path, '.php')) { throw new Error(sprintf('[%s] is not a PHP suite file', $path)); } @@ -462,6 +502,97 @@ public function resolveSuitePaths(string $argument): array throw new Error(sprintf('The suites path [%s] doesn\'t seem to exist', $argument)); } + /** + * Parse a suites filter (--suites or PRESTAFLOW_SUITES): comma-separated + * sub-folders of the suites path, nested ones allowed (FrontOffice/Checkout). + * Names are trimmed, empty ones dropped, duplicates removed. + * + * @return array an empty array means "no filter" + * + * @throws Error on an absolute path or a `..` segment: the filter may only + * narrow the suites path, never leave it + */ + public function parseSuitesFilter(?string $raw): array + { + $names = []; + foreach (explode(',', (string) $raw) as $name) { + $name = trim(str_replace('\\', '/', $name)); + if ($name === '') { + continue; + } + + if (str_starts_with($name, '/') || preg_match('#^[A-Za-z]:#', $name)) { + throw new Error(sprintf('Suites filter [%s]: absolute paths are not allowed, name a sub-folder of the suites path', $name)); + } + + $segments = array_values(array_filter(explode('/', $name), fn ($segment) => $segment !== '' && $segment !== '.')); + if (in_array('..', $segments, true)) { + throw new Error(sprintf('Suites filter [%s]: ".." is not allowed, name a sub-folder of the suites path', $name)); + } + if ($segments === []) { + continue; + } + + $names[] = implode('/', $segments); + } + + return array_values(array_unique($names)); + } + + /** + * A run narrowed by --suites / PRESTAFLOW_SUITES that ends up with no suite + * is a mistake in the filter, not an empty project: fail it rather than + * report a green run of zero tests. Unfiltered, an empty folder still passes. + * + * @throws Error when $suitesFilter is not empty + */ + protected function failOnEmptyFilteredRun(array $suitesFilter, string $root): void + { + if ($suitesFilter !== []) { + throw new Error(sprintf( + 'Suites filter [%s] selected no suite under [%s]', + implode(', ', $suitesFilter), + $root + )); + } + } + + /** + * Suites of the given sub-folders of $root (the union, without duplicates). + * + * @return array + * + * @throws Error when a name matches no folder, so that a filter typo never + * turns into a green run of zero tests + */ + public function getTestsSuitesIn(string $root, array $subFolders): array + { + $missing = array_values(array_filter($subFolders, fn ($name) => !is_dir($root . '/' . $name))); + if ($missing !== []) { + $available = array_values(array_filter( + scandir($root) ?: [], + fn ($entry) => $entry !== '.' && $entry !== '..' && is_dir($root . '/' . $entry) + )); + sort($available); + + throw new Error(sprintf( + 'Suites filter: no folder [%s] under [%s]. Available sub-folders: %s', + implode(', ', $missing), + $root, + $available === [] ? '(none)' : implode(', ', $available) + )); + } + + $testSuites = []; + foreach ($subFolders as $name) { + foreach ($this->getTestsSuites($root . '/' . $name) as $suitePath) { + $testSuites[] = $suitePath; + } + } + + return array_values(array_unique($testSuites)); + } + public function getTestsSuites($folderPath) { $testSuites = []; diff --git a/src/Console/Application.php b/src/Console/Application.php new file mode 100644 index 0000000..5c70e3f --- /dev/null +++ b/src/Console/Application.php @@ -0,0 +1,40 @@ +add(new ExecuteSuite()); + + // Most failures inside a run are \Error (resolveSuitePaths() throws one): + // without this they would escape run() as a PHP fatal error. + $this->setCatchErrors(true); + } + + protected function doRenderThrowable(\Throwable $e, OutputInterface $output): void + { + $output->writeln(sprintf('ERROR %s', $e->getMessage())); + $output->writeln(sprintf('TRACE %s', $e->getFile() . ':' . $e->getLine())); + + foreach ($e->getTrace() as $trace) { + $output->writeln(sprintf('TRACE %s', ($trace['file'] ?? '[internal]') . ':' . ($trace['line'] ?? '?'))); + } + } +} diff --git a/src/Pages/CommonPage.php b/src/Pages/CommonPage.php index 4c6905c..6c3d9c3 100644 --- a/src/Pages/CommonPage.php +++ b/src/Pages/CommonPage.php @@ -5,6 +5,7 @@ use Exception; use HeadlessChromium\Exception\ElementNotFoundException; use HeadlessChromium\Exception\OperationTimedOut; +use HeadlessChromium\Exception\TargetDestroyed; use HeadlessChromium\Page as DomPage; use PrestaFlow\Library\Exceptions\TimeoutException; use PrestaFlow\Library\Expects\Expect; @@ -125,8 +126,23 @@ public function __call($name, $arguments) $page = $this->getPage(); if (!is_null($page) && method_exists($page, $name)) { - call_user_func_array([$page, $name], $arguments); + return call_user_func_array([$page, $name], $arguments); } + + // No browser page yet: keep the historical no-op for real chrome-php + // methods, but never for a name nobody defines. + if (is_null($page) && method_exists(DomPage::class, $name)) { + return null; + } + + // A typo or a method that lives elsewhere (e.g. waitForNavigation(), which + // belongs to PageNavigation, not Page) used to be swallowed silently, so + // the step "passed" without doing anything. + throw new \BadMethodCallException(sprintf( + 'Call to undefined method %s::%s() (not defined on the page object nor on the chrome-php page).', + static::class, + $name + )); } public function setGlobals($globals) @@ -413,6 +429,65 @@ public function scrollToAnchor(string $selector, int $settleMs = 400): void } } + private function applyVisualMasks(array $masks): void + { + $masks = array_values(array_filter(array_map('trim', $masks))); + if ($masks === []) { + return; + } + // Une règle CSS par sélecteur : un sélecteur invalide ne doit pas faire + // échouer le bloc entier et désactiver le masquage des autres. + $css = json_encode( + implode("\n", array_map(fn ($m) => $m.' { visibility: hidden !important; }', $masks)), + JSON_THROW_ON_ERROR + ); + $this->getPage()->evaluate( + "(function(){var s=document.createElement('style');s.id='pf-visual-masks';s.textContent={$css};document.head.appendChild(s);})()" + )->getReturnValue(); + } + + private function removeVisualMasks(array $masks): void + { + if (array_filter(array_map('trim', $masks)) === []) { + return; + } + try { + $this->getPage()->evaluate( + "(function(){var s=document.getElementById('pf-visual-masks');if(s){s.remove();}})()" + )->getReturnValue(); + } catch (\Throwable $e) { + // best-effort : ne doit jamais masquer l'exception d'origine de la capture + } + } + + /** Scrolle juste sous l'élément (ex. header) : le viewport commence au contenu utile. */ + public function scrollBelow(string $selector, int $settleMs = 400): void + { + try { + $sel = json_encode($selector, JSON_THROW_ON_ERROR); + $this->getPage()->evaluate( + "(function(){window.scrollTo(0, 0);var e=document.querySelector({$sel});if(!e){return;}var r=e.getBoundingClientRect();window.scrollTo(0, r.bottom + window.scrollY);})()" + )->getReturnValue(); + usleep($settleMs * 1000); + } catch (\Throwable $e) { + // best-effort : l'écart de capture tranchera si le scroll a échoué + } + } + + /** Attend le chargement complet + polices + images (y compris lazy sous le pli), sans lever si le délai expire. */ + public function waitForStable(int $timeout = 10000): void + { + try { + $this->waitForJsCondition( + "document.readyState === 'complete' && (!document.fonts || document.fonts.status === 'loaded') " + . "&& Array.from(document.images).every(function(i){return i.complete || (i.loading === 'lazy' && i.getBoundingClientRect().top > window.innerHeight);})", + $timeout + ); + } catch (\Throwable $e) { + // best-effort : ne doit jamais lever, même en cas d'échec du polling + } + } + /** * Best-effort : dimensions du viewport courant via le navigateur. `null` * pour un des deux (ou les deux) si l'info n'est pas disponible (page @@ -458,7 +533,7 @@ private function getViewportSize(): array * VisualTag::resolve()). Un tag libre est utilisé tel quel — il doit * matcher `^[a-z0-9._-]+$`, sinon exception explicite. */ - public function visualCheckpoint(string $name, ?string $selector = null, float $threshold = 0.98, bool $fullPage = true, string $tag = 'auto'): void + public function visualCheckpoint(string $name, ?string $selector = null, float $threshold = 0.98, bool $fullPage = true, string $tag = 'auto', array $masks = []): void { $rawMajorVersion = $this->getMajorVersion(); $majorVersion = (is_string($rawMajorVersion) || is_int($rawMajorVersion)) @@ -482,21 +557,26 @@ public function visualCheckpoint(string $name, ?string $selector = null, float $ $page = $this->getPage(); - if ($selector !== null) { - $node = $page->dom()->querySelector($selector); - if ($node === null) { - throw new \RuntimeException("visualCheckpoint : sélecteur introuvable « {$selector} »"); + $this->applyVisualMasks($masks); + try { + if ($selector !== null) { + $node = $page->dom()->querySelector($selector); + if ($node === null) { + throw new \RuntimeException("visualCheckpoint : sélecteur introuvable « {$selector} »"); + } + $page->screenshotElement($node)->saveToFile($actualPath); + } elseif ($fullPage) { + $page->screenshot([ + 'captureBeyondViewport' => true, + 'clip' => $page->getFullPageClip(), + 'format' => 'png', + ])->saveToFile($actualPath); + } else { + // Viewport seul : hauteur fixe (fenêtre), indépendante du total de la page. + $page->screenshot(['format' => 'png'])->saveToFile($actualPath); } - $page->screenshotElement($node)->saveToFile($actualPath); - } elseif ($fullPage) { - $page->screenshot([ - 'captureBeyondViewport' => true, - 'clip' => $page->getFullPageClip(), - 'format' => 'png', - ])->saveToFile($actualPath); - } else { - // Viewport seul : hauteur fixe (fenêtre), indépendante du total de la page. - $page->screenshot(['format' => 'png'])->saveToFile($actualPath); + } finally { + $this->removeVisualMasks($masks); } if (!is_file($refPath)) { @@ -558,9 +638,27 @@ public function goToUrl(string $url) $this->getPage()->navigate($url)->waitForNavigation(DomPage::DOM_CONTENT_LOADED); } - public function waitForPageLoaded() + /** + * Wait until the current document has been parsed (DOMContentLoaded, i.e. + * readyState "interactive" or "complete") and has a . + * + * This polls the page state; it does not observe a navigation. Right after + * an action that triggers one, the previous document may still report + * itself as ready, so prefer waiting on navigate()->waitForNavigation() or + * on an element of the next page when you have one. + * + * @throws TimeoutException when the page is still loading after $timeout ms + */ + public function waitForPageLoaded(int $timeout = 30000): void { - $this->waitForNavigation(DomPage::DOM_CONTENT_LOADED, 10000); + $loaded = $this->waitForJsCondition( + "document.readyState !== 'loading' && !!document.body", + $timeout + ); + + if (!$loaded) { + throw new TimeoutException(sprintf('Page did not finish loading within %d ms.', $timeout)); + } } public function getTextContent($selector, $index = 1, $waitForSelector = true, $timeout = 3000) @@ -824,6 +922,11 @@ public function elementIsVisible($selector, $timeout = 1000) if (get_class($elem->dom()) instanceof \HeadlessChromium\Dom) { return true; } + } catch (TargetDestroyed $e) { + // The browser or the page went away (closed by another process, + // crashed). That is not "the element is missing": answering false + // would turn a lost browser into a failed assertion on the shop. + throw $e; } catch (ElementNotFoundException | OperationTimedOut | Exception $e) { return false; } diff --git a/src/Pages/FrontOfficePage.php b/src/Pages/FrontOfficePage.php index 0f3bf12..8faf34b 100644 --- a/src/Pages/FrontOfficePage.php +++ b/src/Pages/FrontOfficePage.php @@ -75,12 +75,12 @@ public function goToPage($page = null, $params = null) if ($hasDebugMoved) { Expect::setWarning('debug-mode'); $this->click('a'); - $this->waitForNavigation(); + $this->waitForPageLoaded(); } } catch (OperationTimedOut | Exception $e) { Expect::setWarning('debug-mode'); $this->click('a'); - $this->waitForNavigation(); + $this->waitForPageLoaded(); } } diff --git a/src/Pages/v9/FrontOffice/Product/Page.php b/src/Pages/v9/FrontOffice/Product/Page.php index d409ff1..362b911 100644 --- a/src/Pages/v9/FrontOffice/Product/Page.php +++ b/src/Pages/v9/FrontOffice/Product/Page.php @@ -33,7 +33,7 @@ public function goToProduct(int $productId = 0) { $this->goToPage('product', $productId); - $this->waitForNavigation(); + $this->waitForPageLoaded(); } /** diff --git a/src/Tests/TestsSuite.php b/src/Tests/TestsSuite.php index 4e5af19..4768b0b 100644 --- a/src/Tests/TestsSuite.php +++ b/src/Tests/TestsSuite.php @@ -154,6 +154,84 @@ private static function buildVisualBlock(int $startIndex): array protected static $browserInstance = null; protected static ?string $browserInstanceSocket = null; + /** + * Owner of the keepAlive browser files, see scopeBrowserFilesTo(). + */ + protected static ?string $browserFilesScope = null; + + /** Overrides posés par l'app (matrice visuelle) avant la création du navigateur. */ + protected static ?array $browserOptionOverrides = null; + + /** + * Ne s'applique qu'à la PROCHAINE création de navigateur (dans getBrowser(), + * quand aucun socket vivant n'est réutilisé) : un navigateur déjà lancé garde + * sa taille/UA d'origine tant qu'on ne l'a pas fermé. Pour appliquer un nouvel + * override à un navigateur déjà en cours, appeler resetBrowser() juste après. + */ + public static function useBrowserOptions(?int $width, ?int $height, ?string $userAgent): void + { + self::$browserOptionOverrides = ($width === null && $height === null && $userAgent === null) + ? null + : ['width' => $width, 'height' => $height, 'userAgent' => $userAgent]; + } + + /** @return array{windowSize: array{0:int,1:int}, userAgent: string} */ + public static function browserOptions(): array + { + $o = self::$browserOptionOverrides ?? []; + + $width = (int) (($o['width'] ?? null) ?: Env::get('PRESTAFLOW_WINDOW_SIZE_WIDTH')); + if ($width <= 0) { + $width = 1920; + } + + $height = (int) (($o['height'] ?? null) ?: Env::get('PRESTAFLOW_WINDOW_SIZE_HEIGHT')); + if ($height <= 0) { + $height = 1080; + } + + return [ + 'windowSize' => [$width, $height], + 'userAgent' => (string) (($o['userAgent'] ?? null) ?: Env::get('PRESTAFLOW_USER_AGENT', 'PrestaFlow')), + ]; + } + + /** + * Ferme le navigateur partagé et oublie son socket : le prochain getBrowser() + * en relancera un neuf (nécessaire quand la taille de fenêtre change). + * + * Si ce process n'a pas de navigateur en cache (self::$browserInstance) mais + * qu'un fichier socket existe, on tente de s'y connecter pour le fermer quand + * même : sinon un navigateur keepAlive lancé par un process précédent (ou + * après un crash) resterait orphelin en arrière-plan. + * + * Hypothèse : un seul process possède le navigateur partagé à la fois (le + * fichier socket est global, pas process-local). Des runs concurrents sur la + * même machine/storage peuvent donc se fermer mutuellement leur navigateur. + */ + public static function resetBrowser(): void + { + $socketFile = self::getFilePath('.browser'); + + try { + if (self::$browserInstance !== null) { + self::$browserInstance->close(); + } elseif (file_exists($socketFile)) { + $socket = trim((string) file_get_contents($socketFile)); + if ($socket !== '') { + BrowserFactory::connectToBrowser($socket)->close(); + } + } + } catch (\Throwable) { + // navigateur déjà mort / injoignable : rien à faire + } + + self::$browserInstance = null; + self::$browserInstanceSocket = null; + @unlink($socketFile); + @unlink(self::getFilePath('.browser-options')); + } + protected $draft = false; protected $groups = 'all'; @@ -316,8 +394,30 @@ public function getGroups() : string|array return $this->groups; } + /** + * Scope the keepAlive browser files (socket, options) to one run. + * + * Without a scope they sit at one path per machine, which every PrestaFlow + * process shares: a run would reconnect to the Chrome another run is + * driving, and ExecuteSuite, which closes that browser and deletes the file + * when it ends, would tear it down under the other run ("The page was + * closed and is not available anymore"). A run scopes the files to itself + * so it only ever finds, and releases, the browser it launched. + * + * Null restores the shared path, for callers outside ExecuteSuite that + * rely on reconnecting to an existing browser. + */ + public static function scopeBrowserFilesTo(?string $scope): void + { + self::$browserFilesScope = ($scope === null || $scope === '') ? null : $scope; + } + public static function getFilePath($filename = '.browser') { + if (self::$browserFilesScope !== null) { + $filename = self::$browserFilesScope.'-'.$filename; + } + if (function_exists('storage_path')) { $dir = storage_path().'/datas'; if (!is_dir($dir)) { @@ -446,15 +546,16 @@ public static function getBrowser(bool $headless = true, bool $force = true) return null; } - // Dimensions de la fenêtre : PRESTAFLOW_WINDOW_SIZE_WIDTH/HEIGHT en - // env (utile pour émuler mobile/tablet/desktop). Défaut FHD 1920×1080. - $winWidth = (int) (Env::get('PRESTAFLOW_WINDOW_SIZE_WIDTH') ?: 1920); - $winHeight = (int) (Env::get('PRESTAFLOW_WINDOW_SIZE_HEIGHT') ?: 1080); + // Dimensions de la fenêtre et user agent : overrides posés par + // useBrowserOptions() sinon PRESTAFLOW_WINDOW_SIZE_WIDTH/HEIGHT / + // PRESTAFLOW_USER_AGENT en env (utile pour émuler mobile/tablet/desktop). + // Défaut FHD 1920×1080. + $opts = self::browserOptions(); $options = [ - 'userAgent' => Env::get('PRESTAFLOW_USER_AGENT', 'PrestaFlow'), + 'userAgent' => $opts['userAgent'], 'keepAlive' => true, - 'windowSize' => [$winWidth, $winHeight], + 'windowSize' => $opts['windowSize'], 'headless' => (bool) $headless, 'ignoreCertificateErrors' => true, ]; @@ -635,7 +736,7 @@ protected function presetBasicAuth(): void */ protected function presetExtraHeadersFromEnv(): void { - $raw = $_ENV['PRESTAFLOW_EXTRA_HEADERS'] ?? null; + $raw = Env::get('PRESTAFLOW_EXTRA_HEADERS'); if ($raw === null || $raw === '') { return; } @@ -942,8 +1043,8 @@ public function loadGlobals() 'VERBOSE' => (bool) Env::get('PRESTAFLOW_VERBOSE', true), 'BROWSER' => [ 'HEADLESS' => (bool) Env::get('PRESTAFLOW_HEADLESS', true), - 'WINDOW_SIZE_HEIGHT' => Env::get('PRESTAFLOW_WINDOW_SIZE_HEIGHT', 1920), - 'WINDOW_SIZE_WIDTH' => Env::get('PRESTAFLOW_WINDOW_SIZE_WIDTH', 1000), + 'WINDOW_SIZE_HEIGHT' => Env::get('PRESTAFLOW_WINDOW_SIZE_HEIGHT', 1080), + 'WINDOW_SIZE_WIDTH' => Env::get('PRESTAFLOW_WINDOW_SIZE_WIDTH', 1920), 'USER_AGENT' => Env::get('PRESTAFLOW_USER_AGENT', 'PrestaFlow'), ], ]; diff --git a/src/Tests/VisualTestsSuite.php b/src/Tests/VisualTestsSuite.php new file mode 100644 index 0000000..f466908 --- /dev/null +++ b/src/Tests/VisualTestsSuite.php @@ -0,0 +1,183 @@ +deviceFromEnv() ?? ($this->devices[0] ?? 'desktop')); + } + + parent::__construct(loadGlobals: $loadGlobals, getBrowser: $getBrowser); + + // loadGlobals() pose toujours LOCALE (défaut 'en') : sans PRESTAFLOW_LOCALE + // explicite, la locale par défaut d'une suite visuelle est sa 1re déclarée. + if ($loadGlobals && $this->locales !== [] && (string) Env::get('PRESTAFLOW_LOCALE', '') === '') { + $this->setGlobals(['LOCALE' => $this->locales[0]]); + } + } + + /** + * Applique le preset du device si les options navigateur ne sont pas déjà + * pilotées par l'app. Retourne true si le navigateur partagé a été réinitialisé + * (options différentes → un navigateur keepAlive réutilisé garderait sinon + * l'ancienne taille / UA). + */ + protected static function applyDevicePreset(string $device): bool + { + $overrides = self::$browserOptionOverrides; + if ($overrides !== null && $overrides !== self::$appliedPreset) { + return false; // options posées par l'app : intouchables + } + + $preset = VisualDevices::get($device); + $wanted = ['windowSize' => [$preset['width'], $preset['height']], 'userAgent' => $preset['userAgent']]; + if (TestsSuite::browserOptions() === $wanted) { + return false; + } + + self::useBrowserOptions($preset['width'], $preset['height'], $preset['userAgent']); + self::$appliedPreset = self::$browserOptionOverrides; + TestsSuite::resetBrowser(); + + return true; + } + + public function devices(): array { return $this->devices; } + public function locales(): array { return $this->locales; } + public function checkpoints(): array { return array_map([self::class, 'normalize'], $this->checkpoints); } + + public static function normalize(array $cp): array + { + $cp = array_merge([ + 'path' => '', 'paths' => [], 'zone' => 'viewport', 'selector' => null, + 'waitFor' => null, 'scrollBelow' => null, 'threshold' => 0.98, + 'excludeDevices' => [], 'masks' => [], + ], $cp); + $cp['threshold'] = max(0.5, min(1.0, (float) $cp['threshold'])); + + return $cp; + } + + public function currentDevice(): string + { + try { + $globals = $this->getGlobals(); + } catch (\Throwable) { + $globals = []; + } + + return $globals['DEVICE'] ?? $this->deviceFromEnv() ?? ($this->devices[0] ?? 'desktop'); + } + + public function currentLocale(): string + { + return (string) ($this->getGlobals()['LOCALE'] ?? ($this->locales[0] ?? 'fr')); + } + + public function resolvePath(array $cp, string $locale): ?string + { + $override = $cp['paths'][$locale] ?? null; + if ($override === false) { + return null; + } + + return is_string($override) && $override !== '' ? $override : (string) ($cp['path'] ?? ''); + } + + public function resolveUrl(string $path, string $locale): string + { + $globals = $this->getGlobals(); + $base = rtrim((string) ($globals['FO']['URL'] ?? ''), '/').'/'; + $prefix = !empty($globals['PREFIX_LOCALE']) ? $locale.'/' : ''; + + return $base.$prefix.ltrim($path, '/'); + } + + public function init() + { + parent::init(); + $this->importVisualPage(); + $page = $this->pages['frontOfficePage'] ?? null; + $device = $this->currentDevice(); + $locale = $this->currentLocale(); + + if (!in_array($device, $this->devices, true)) { + throw new \InvalidArgumentException(sprintf('%s : device « %s » non déclaré (%s)', static::class, $device, implode(', ', $this->devices))); + } + if ($this->locales !== [] && !in_array($locale, $this->locales, true)) { + throw new \InvalidArgumentException(sprintf('%s : locale « %s » non déclarée (%s)', static::class, $locale, implode(', ', $this->locales))); + } + + $this->describe($this->title ?: substr(strrchr('\\'.static::class, '\\'), 1)); + + foreach ($this->checkpoints() as $cp) { + $title = 'capture visuelle : '.$cp['name']; + $path = $this->resolvePath($cp, $locale); + + if ($path === null || in_array($device, $cp['excludeDevices'], true)) { + $this->skip($title, function () {}); + continue; + } + + $url = $this->resolveUrl($path, $locale); + $this->it($title, function () use ($page, $cp, $url) { + $page->goToUrl($url); + $page->waitForStable(); + if ($cp['waitFor']) { + $page->waitVisible($cp['waitFor']); + } + if ($cp['scrollBelow'] && $cp['zone'] === 'viewport') { + $page->scrollBelow($cp['scrollBelow']); + } + $page->visualCheckpoint( + $cp['name'], + $cp['zone'] === 'element' ? $cp['selector'] : null, + (float) $cp['threshold'], + $cp['zone'] === 'full', + 'auto', + $cp['masks'], + ); + }); + } + + return $this; + } + + /** Surchargé en test unitaire (pas de navigateur). */ + protected function importVisualPage(): void + { + $this->importPage('FrontOffice'); + } + + private function deviceFromEnv(): ?string + { + $env = Env::get('PRESTAFLOW_DEVICE'); + + return is_string($env) && $env !== '' ? $env : null; + } +} diff --git a/src/Visual/VisualDevices.php b/src/Visual/VisualDevices.php new file mode 100644 index 0000000..4eef7b7 --- /dev/null +++ b/src/Visual/VisualDevices.php @@ -0,0 +1,37 @@ + [ + 'width' => 1920, 'height' => 1080, + 'userAgent' => 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 PrestaFlow', + ], + 'tablet' => [ + 'width' => 768, 'height' => 1024, + 'userAgent' => 'Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1 PrestaFlow', + ], + 'mobile' => [ + 'width' => 390, 'height' => 844, + 'userAgent' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1 PrestaFlow', + ], + ]; + + /** @return list */ + public static function names(): array + { + return array_keys(self::PRESETS); + } + + /** @return array{width:int,height:int,userAgent:string} */ + public static function get(string $device): array + { + if (!isset(self::PRESETS[$device])) { + throw new \InvalidArgumentException("Unknown visual device « {$device} »"); + } + + return self::PRESETS[$device]; + } +} diff --git a/tests/Unit/Command/BinaryInstallTest.php b/tests/Unit/Command/BinaryInstallTest.php new file mode 100644 index 0000000..125ca3d --- /dev/null +++ b/tests/Unit/Command/BinaryInstallTest.php @@ -0,0 +1,94 @@ +libRoot = dirname(__DIR__, 3); + $this->project = sys_get_temp_dir() . '/prestaflow-install-' . bin2hex(random_bytes(6)); + + // Installed layout: /vendor/prestaflow/php-library/bin/prestaflow. + mkdir($this->project . '/vendor/prestaflow/php-library/bin', 0777, true); + mkdir($this->project . '/vendor/bin', 0777, true); + copy($this->libRoot . '/bin/prestaflow', $this->project . '/vendor/prestaflow/php-library/bin/prestaflow'); + + // The project's autoloader: leaves a marker, then delegates to the real + // one so the library classes resolve. + file_put_contents($this->project . '/vendor/autoload.php', sprintf( + "libRoot . '/vendor/autoload.php', true) + )); + } + + protected function tearDown(): void + { + exec('rm -rf ' . escapeshellarg($this->project)); + } + + public function testComposerJsonExposesTheBinary(): void + { + $composer = json_decode(file_get_contents($this->libRoot . '/composer.json'), true); + + $this->assertSame(['bin/prestaflow'], $composer['bin'] ?? null); + } + + public function testInstalledBinaryLoadsTheProjectAutoloader(): void + { + [$exitCode, $output] = $this->runPhp($this->project . '/vendor/prestaflow/php-library/bin/prestaflow'); + + $this->assertSame(0, $exitCode, $output); + $this->assertStringContainsString('PROJECT_AUTOLOAD', $output); + $this->assertStringContainsString('run', $output); + } + + /** + * Composer's vendor/bin proxy sets $_composer_autoload_path, then includes + * the real script. + */ + public function testComposerBinProxyLoadsTheProjectAutoloader(): void + { + $proxy = $this->project . '/vendor/bin/prestaflow'; + file_put_contents($proxy, "runPhp($proxy); + + $this->assertSame(0, $exitCode, $output); + $this->assertStringContainsString('PROJECT_AUTOLOAD', $output); + $this->assertStringContainsString('run', $output); + } + + /** + * @return array{0: int, 1: string} + */ + private function runPhp(string $script): array + { + // Run from an unrelated directory: the getcwd() fallback must not be + // what makes these pass. + $process = proc_open( + [PHP_BINARY, $script, 'list', '--raw'], + [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes, + sys_get_temp_dir() + ); + $output = stream_get_contents($pipes[1]) . stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + + return [proc_close($process), $output]; + } +} diff --git a/tests/Unit/Command/BrowserOwnershipTest.php b/tests/Unit/Command/BrowserOwnershipTest.php new file mode 100644 index 0000000..eb7aa3f --- /dev/null +++ b/tests/Unit/Command/BrowserOwnershipTest.php @@ -0,0 +1,90 @@ +files as $file) { + @unlink($file); + } + TestsSuite::scopeBrowserFilesTo(null); + } + + public function testARunThatEndsLeavesTheBrowserOfAnotherRunAlone(): void + { + $first = new ExecuteSuite(); + $first->beginRun(); + $firstSocketFile = TestsSuite::getFilePath('.browser'); + $this->files[] = $firstSocketFile; + file_put_contents($firstSocketFile, 'ws://127.0.0.1:1/devtools/browser/first-run'); + + // A second run starts and finishes while the first is still driving + // its browser (in real life: another process, same temp dir). + $second = new ExecuteSuite(); + $second->beginRun(); + $this->files[] = TestsSuite::getFilePath('.browser'); + $second->releaseBrowser(); + + $this->assertFileExists( + $firstSocketFile, + 'the second run deleted the socket file of the first one' + ); + } + + public function testEachRunGetsItsOwnSocketFile(): void + { + $first = new ExecuteSuite(); + $first->beginRun(); + $firstSocketFile = TestsSuite::getFilePath('.browser'); + + $second = new ExecuteSuite(); + $second->beginRun(); + + $this->assertNotSame($firstSocketFile, TestsSuite::getFilePath('.browser')); + } + + public function testARunReleasesItsOwnSocketFile(): void + { + $run = new ExecuteSuite(); + $run->beginRun(); + $socketFile = TestsSuite::getFilePath('.browser'); + $this->files[] = $socketFile; + file_put_contents($socketFile, 'ws://127.0.0.1:1/devtools/browser/own-run'); + + $run->releaseBrowser(); + + $this->assertFileDoesNotExist($socketFile); + } + + public function testWithoutARunTheSharedPathIsUnchanged(): void + { + TestsSuite::scopeBrowserFilesTo(null); + + $this->assertSame( + sys_get_temp_dir() . '/prestaflow-.browser', + TestsSuite::getFilePath('.browser') + ); + } +} diff --git a/tests/Unit/Command/ExitCodeTest.php b/tests/Unit/Command/ExitCodeTest.php new file mode 100644 index 0000000..b464ac2 --- /dev/null +++ b/tests/Unit/Command/ExitCodeTest.php @@ -0,0 +1,95 @@ +previousCwd = getcwd(); + $this->tmpDir = sys_get_temp_dir() . '/prestaflow-exit-' . bin2hex(random_bytes(6)); + mkdir($this->tmpDir . '/empty', 0777, true); + chdir($this->tmpDir); + } + + protected function tearDown(): void + { + chdir($this->previousCwd); + exec('rm -rf ' . escapeshellarg($this->tmpDir)); + } + + public function testAMissingSuitesPathExitsNonZero(): void + { + $tester = $this->tester(); + + $exitCode = $this->runCommand($tester, ['command' => 'run', 'folder' => $this->tmpDir . '/does-not-exist']); + + $this->assertNotSame(0, $exitCode); + $this->assertStringContainsString('ERROR', $tester->getErrorOutput()); + $this->assertStringContainsString('does-not-exist', $tester->getErrorOutput()); + } + + public function testAnUnknownCommandExitsNonZero(): void + { + $this->assertNotSame(0, $this->runCommand($this->tester(), ['command' => 'no-such-command'])); + } + + public function testAnEmptySuitesFolderStillExitsZero(): void + { + $this->assertSame(0, $this->runCommand($this->tester(), ['command' => 'run', 'folder' => $this->tmpDir . '/empty'])); + } + + /** + * End to end on the real entry point, so the binary cannot drift away from + * the application it is supposed to run. + */ + public function testTheBinaryExitsNonZeroOnAMissingSuitesPath(): void + { + $bin = dirname(__DIR__, 3) . '/bin/prestaflow'; + + $process = proc_open( + [PHP_BINARY, $bin, 'run', $this->tmpDir . '/does-not-exist'], + [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes, + $this->tmpDir + ); + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + $exitCode = proc_close($process); + + $this->assertNotSame(0, $exitCode, "stdout:\n" . $stdout . "\nstderr:\n" . $stderr); + $this->assertStringContainsString('does-not-exist', $stdout . $stderr); + } + + /** + * ExecuteSuite writes to console sections, which only a ConsoleOutput + * offers: capturing stderr separately gives the tester one. + */ + private function runCommand(ApplicationTester $tester, array $input): int + { + return $tester->run($input, ['capture_stderr_separately' => true]); + } + + private function tester(): ApplicationTester + { + $application = new Application(); + $application->setAutoExit(false); + + return new ApplicationTester($application); + } +} diff --git a/tests/Unit/Command/SuitesFilterTest.php b/tests/Unit/Command/SuitesFilterTest.php new file mode 100644 index 0000000..737057a --- /dev/null +++ b/tests/Unit/Command/SuitesFilterTest.php @@ -0,0 +1,206 @@ +tmpDir = sys_get_temp_dir() . '/prestaflow-suites-' . bin2hex(random_bytes(6)); + $this->root = $this->tmpDir . '/Suites'; + + foreach (['BackOffice', 'FrontOffice/Checkout', 'Empty'] as $dir) { + mkdir($this->root . '/' . $dir, 0777, true); + } + foreach (['Top.php', 'BackOffice/Login.php', 'FrontOffice/Home.php', 'FrontOffice/Checkout/Guest.php'] as $file) { + file_put_contents($this->root . '/' . $file, "previousCwd = getcwd(); + chdir($this->tmpDir); + + $this->previousEnv = [ + 'env' => $_ENV['PRESTAFLOW_SUITES'] ?? null, + 'process' => getenv('PRESTAFLOW_SUITES'), + ]; + unset($_ENV['PRESTAFLOW_SUITES']); + putenv('PRESTAFLOW_SUITES'); + } + + protected function tearDown(): void + { + chdir($this->previousCwd); + exec('rm -rf ' . escapeshellarg($this->tmpDir)); + + if ($this->previousEnv['env'] === null) { + unset($_ENV['PRESTAFLOW_SUITES']); + } else { + $_ENV['PRESTAFLOW_SUITES'] = $this->previousEnv['env']; + } + putenv($this->previousEnv['process'] === false ? 'PRESTAFLOW_SUITES' : 'PRESTAFLOW_SUITES=' . $this->previousEnv['process']); + } + + public function testParseTrimsAndDropsEmptyNamesAndDuplicates(): void + { + $this->assertSame( + ['BackOffice', 'FrontOffice/Checkout'], + (new ExecuteSuite())->parseSuitesFilter(' BackOffice, ,FrontOffice/Checkout/ ,BackOffice,') + ); + } + + public function testParseOfNothingIsNoFilter(): void + { + $this->assertSame([], (new ExecuteSuite())->parseSuitesFilter(null)); + $this->assertSame([], (new ExecuteSuite())->parseSuitesFilter(' , ')); + } + + public function testParseRejectsParentSegments(): void + { + $this->expectException(Error::class); + $this->expectExceptionMessageMatches('/\.\./'); + + (new ExecuteSuite())->parseSuitesFilter('BackOffice/../../etc'); + } + + public function testParseRejectsAbsolutePaths(): void + { + $this->expectException(Error::class); + $this->expectExceptionMessageMatches('/absolute/'); + + (new ExecuteSuite())->parseSuitesFilter('/etc'); + } + + public function testNoFilterKeepsTheWholeTree(): void + { + $this->assertSame( + $this->sorted((new ExecuteSuite())->resolveSuitePaths($this->root)), + $this->sorted((new ExecuteSuite())->resolveSuitePaths($this->root, [])) + ); + } + + public function testFilterKeepsOnlyTheNamedSubFoldersRecursively(): void + { + $resolved = (new ExecuteSuite())->resolveSuitePaths($this->root, ['FrontOffice']); + + $this->assertSame([ + $this->root . '/FrontOffice/Checkout/Guest.php', + $this->root . '/FrontOffice/Home.php', + ], $this->sorted($resolved)); + } + + public function testFilterAcceptsNestedPathsAndReturnsTheUnionWithoutDuplicates(): void + { + $resolved = (new ExecuteSuite())->resolveSuitePaths($this->root, ['FrontOffice/Checkout', 'BackOffice', 'FrontOffice']); + + $this->assertSame([ + $this->root . '/BackOffice/Login.php', + $this->root . '/FrontOffice/Checkout/Guest.php', + $this->root . '/FrontOffice/Home.php', + ], $this->sorted($resolved)); + $this->assertSame(count($resolved), count(array_unique($resolved))); + } + + public function testUnknownNamesFailListingMissingAndAvailableFolders(): void + { + try { + (new ExecuteSuite())->resolveSuitePaths($this->root, ['BackOffice', 'Nope', 'Front']); + $this->fail('An unknown sub-folder must fail the run'); + } catch (Error $e) { + $this->assertStringContainsString('Nope', $e->getMessage()); + $this->assertStringContainsString('Front', $e->getMessage()); + $this->assertStringContainsString('BackOffice, Empty, FrontOffice', $e->getMessage()); + } + } + + public function testFilterOnASingleSuiteFileIsRefused(): void + { + $this->expectException(Error::class); + + (new ExecuteSuite())->resolveSuitePaths($this->root . '/Top.php', ['BackOffice']); + } + + public function testEnvVariableFiltersTheRunAndFailsOnAnUnknownFolder(): void + { + putenv('PRESTAFLOW_SUITES=Nope'); + + $tester = $this->tester(); + $exitCode = $tester->run(['command' => 'run', 'folder' => $this->root], ['capture_stderr_separately' => true]); + + $this->assertNotSame(0, $exitCode); + $this->assertStringContainsString('Nope', $tester->getErrorOutput()); + } + + public function testCliOptionWinsOverTheEnvVariable(): void + { + putenv('PRESTAFLOW_SUITES=Nope'); + + $tester = $this->tester(); + $tester->run( + ['command' => 'run', 'folder' => $this->root, '--suites' => 'BackOffice'], + ['capture_stderr_separately' => true] + ); + + // The unknown name from the environment was never looked at. + $this->assertStringNotContainsString('Nope', $tester->getErrorOutput()); + } + + public function testAFilteredRunThatSelectsNoSuiteFails(): void + { + $tester = $this->tester(); + $exitCode = $tester->run( + ['command' => 'run', 'folder' => $this->root, '--suites' => 'Empty'], + ['capture_stderr_separately' => true] + ); + + $this->assertSame(1, $exitCode); + $this->assertStringContainsString('Suites filter [Empty] selected no suite', $tester->getErrorOutput()); + } + + public function testAnUnfilteredEmptyFolderStillSucceeds(): void + { + $this->assertSame(0, $this->tester()->run( + ['command' => 'run', 'folder' => $this->root . '/Empty'], + ['capture_stderr_separately' => true] + )); + } + + public function testCliOptionAloneFailsOnAnUnknownFolder(): void + { + $exitCode = $this->tester()->run( + ['command' => 'run', 'folder' => $this->root, '--suites' => 'Missing'], + ['capture_stderr_separately' => true] + ); + + $this->assertNotSame(0, $exitCode); + } + + private function tester(): ApplicationTester + { + $application = new Application(); + $application->setAutoExit(false); + + return new ApplicationTester($application); + } + + private function sorted(array $paths): array + { + sort($paths); + + return $paths; + } +} diff --git a/tests/Unit/Pages/VisibilityOnLostBrowserTest.php b/tests/Unit/Pages/VisibilityOnLostBrowserTest.php new file mode 100644 index 0000000..ddb38b9 --- /dev/null +++ b/tests/Unit/Pages/VisibilityOnLostBrowserTest.php @@ -0,0 +1,56 @@ +error; + } + }; + + return new class($fakePage) extends FrontOfficePage { + public function __construct(private $fakePage) {} + public function getPage() { return $this->fakePage; } + }; + } + + public function testAMissingElementIsNotVisible(): void + { + $page = $this->pageThrowing(new OperationTimedOut('Operation timed out after 1000ms.')); + + $this->assertFalse($page->isVisible('#psflowdemo-block', 1000)); + } + + public function testALostBrowserIsReportedNotReadAsAMissingElement(): void + { + $page = $this->pageThrowing(new TargetDestroyed('The session is destroyed.')); + + $this->expectException(TargetDestroyed::class); + $this->expectExceptionMessage('The session is destroyed.'); + + $page->isVisible('#psflowdemo-block', 1000); + } +} diff --git a/tests/Unit/Pages/WaitForPageLoadedTest.php b/tests/Unit/Pages/WaitForPageLoadedTest.php new file mode 100644 index 0000000..46c999e --- /dev/null +++ b/tests/Unit/Pages/WaitForPageLoadedTest.php @@ -0,0 +1,107 @@ +fakeDomPage([false, true]); + + $this->makePage($fakePage)->waitForPageLoaded(1000); + + $this->assertCount(2, $fakePage->evaluated, 'waitForPageLoaded() should poll the page until it is ready'); + $this->assertStringContainsString('document.readyState', $fakePage->evaluated[0]); + } + + public function testWaitForPageLoadedThrowsWhenThePageNeverFinishesLoading(): void + { + $fakePage = $this->fakeDomPage([false]); + + $this->expectException(TimeoutException::class); + + $this->makePage($fakePage)->waitForPageLoaded(50); + } + + public function testUnknownMethodIsNotSilentlySwallowed(): void + { + $page = $this->makePage($this->fakeDomPage([true])); + + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage('methodThatDoesNotExist'); + + $page->methodThatDoesNotExist(); + } + + public function testKnownChromePageMethodIsStillProxied(): void + { + $fakePage = $this->fakeDomPage([true]); + + $result = $this->makePage($fakePage)->waitUntilContainsElement('#main', 10); + + $this->assertSame([['#main', 10]], $fakePage->waited); + $this->assertSame($fakePage, $result, '__call should hand back what the chrome-php page returned'); + } + + private function makePage(object $fakePage): CommonPage + { + return new class ($fakePage) extends CommonPage { + private object $fakePage; + + public function __construct(object $fakePage) + { + $this->fakePage = $fakePage; + } + + public function getPage() + { + return $this->fakePage; + } + }; + } + + /** + * Fake chrome-php Page: evaluate() records the expression and returns the + * next scripted readiness value (the last one repeats). + */ + private function fakeDomPage(array $readiness): object + { + return new class ($readiness) { + public array $evaluated = []; + public array $waited = []; + + public function __construct(private array $readiness) + { + } + + public function evaluate($js) + { + $this->evaluated[] = $js; + $value = count($this->readiness) > 1 ? array_shift($this->readiness) : $this->readiness[0]; + + return new class ($value) { + public function __construct(private $value) + { + } + + public function getReturnValue($timeout = null) + { + return $this->value; + } + }; + } + + public function waitUntilContainsElement($selector, int $timeout = 30000) + { + $this->waited[] = [$selector, $timeout]; + + return $this; + } + }; + } +} diff --git a/tests/Unit/Tests/BrowserOptionsTest.php b/tests/Unit/Tests/BrowserOptionsTest.php new file mode 100644 index 0000000..898c5d0 --- /dev/null +++ b/tests/Unit/Tests/BrowserOptionsTest.php @@ -0,0 +1,68 @@ +assertSame(['windowSize' => [1024, 768], 'userAgent' => 'UA'], TestsSuite::browserOptions()); + } + + public function test_defaults_without_override(): void + { + $this->assertSame(['windowSize' => [1920, 1080], 'userAgent' => 'PrestaFlow'], TestsSuite::browserOptions()); + } + + public function test_env_used_without_override(): void + { + $_ENV['PRESTAFLOW_WINDOW_SIZE_WIDTH'] = '390'; + $_ENV['PRESTAFLOW_WINDOW_SIZE_HEIGHT'] = '844'; + $_ENV['PRESTAFLOW_USER_AGENT'] = 'Mobile'; + $this->assertSame(['windowSize' => [390, 844], 'userAgent' => 'Mobile'], TestsSuite::browserOptions()); + } + + public function test_reset_browser_removes_socket_files(): void + { + $socket = TestsSuite::getFilePath('.browser'); + $options = TestsSuite::getFilePath('.browser-options'); + file_put_contents($socket, 'ws://127.0.0.1:1/devtools/browser/nope'); + file_put_contents($options, '{}'); + + TestsSuite::resetBrowser(); + + $this->assertFileDoesNotExist($socket); + $this->assertFileDoesNotExist($options); + } + + public function test_reset_browser_twice_in_a_row_does_not_throw(): void + { + TestsSuite::resetBrowser(); + TestsSuite::resetBrowser(); + + $this->assertFileDoesNotExist(TestsSuite::getFilePath('.browser')); + $this->assertFileDoesNotExist(TestsSuite::getFilePath('.browser-options')); + } + + public function test_partial_override_falls_back_to_default_height_and_user_agent(): void + { + TestsSuite::useBrowserOptions(390, null, null); + $this->assertSame(['windowSize' => [390, 1080], 'userAgent' => 'PrestaFlow'], TestsSuite::browserOptions()); + } + + public function test_invalid_env_width_falls_back_to_default(): void + { + $_ENV['PRESTAFLOW_WINDOW_SIZE_WIDTH'] = 'abc'; + $this->assertSame(['windowSize' => [1920, 1080], 'userAgent' => 'PrestaFlow'], TestsSuite::browserOptions()); + } +} diff --git a/tests/Unit/Tests/ExtraHeadersFromEnvTest.php b/tests/Unit/Tests/ExtraHeadersFromEnvTest.php index 18c0808..d9ee38f 100644 --- a/tests/Unit/Tests/ExtraHeadersFromEnvTest.php +++ b/tests/Unit/Tests/ExtraHeadersFromEnvTest.php @@ -36,6 +36,7 @@ protected function tearDown(): void } } TestsSuite::$extraHttpHeaders = $this->headersBackup; + putenv('PRESTAFLOW_EXTRA_HEADERS'); } private function invoke(): void @@ -124,4 +125,18 @@ public function testEmptyKeyIsRejected(): void $this->invoke(); $this->assertSame(['X-Ok' => 'v'], TestsSuite::$extraHttpHeaders); } + + /** + * CI runners set env vars on the process; with a variables_order without + * "E" they never reach $_ENV, only getenv() sees them. + */ + public function testProcessEnvironmentIsReadWhenAbsentFromSuperglobal(): void + { + unset($_ENV['PRESTAFLOW_EXTRA_HEADERS']); + putenv('PRESTAFLOW_EXTRA_HEADERS={"X-CI-Bypass":"from-process"}'); + + $this->invoke(); + + $this->assertSame(['X-CI-Bypass' => 'from-process'], TestsSuite::$extraHttpHeaders); + } } diff --git a/tests/Unit/Visual/VisualCheckpointMasksTest.php b/tests/Unit/Visual/VisualCheckpointMasksTest.php new file mode 100644 index 0000000..2fc9551 --- /dev/null +++ b/tests/Unit/Visual/VisualCheckpointMasksTest.php @@ -0,0 +1,137 @@ +cwd = getcwd(); + $this->tmpDir = sys_get_temp_dir() . '/pfvis_masks_' . getmypid() . '_' . uniqid(); + @mkdir($this->tmpDir, 0777, true); + chdir($this->tmpDir); + + TestsSuite::$visualResults = []; + } + + protected function tearDown(): void + { + chdir($this->cwd); + TestsSuite::$visualResults = []; + } + + private function makePage(bool $screenshotThrows = false): CommonPage + { + $page = new class ('en', '8.1.0', [], $screenshotThrows) extends CommonPage { + public array $evaluatedLog = []; + public bool $screenshotThrows; + + public function __construct($locale, $version, $options, bool $screenshotThrows = false) + { + parent::__construct($locale, $version, $options); + $this->screenshotThrows = $screenshotThrows; + } + + public function getPage() + { + $outer = $this; + + return new class ($outer) { + private $outer; + + public function __construct($outer) + { + $this->outer = $outer; + } + + public function screenshot(array $opts = []) + { + if ($this->outer->screenshotThrows) { + throw new \RuntimeException('capture échouée (stub)'); + } + + return new class { + public function saveToFile(string $path): void + { + $img = imagecreatetruecolor(40, 40); + imagefill($img, 0, 0, imagecolorallocate($img, 255, 255, 255)); + imagepng($img, $path); + imagedestroy($img); + } + }; + } + + public function getFullPageClip() + { + return []; + } + + public function evaluate(string $js) + { + $this->outer->evaluatedLog[] = $js; + + return new class { + public function getReturnValue($timeout = null) + { + return [1280, 720]; + } + }; + } + }; + } + }; + + $page->setMajorVersion('9'); + $page->setLocale('fr'); + + return $page; + } + + public function test_masks_are_injected_then_removed(): void + { + $page = $this->makePage(); + $page->visualCheckpoint('hdr', null, 0.98, false, 'auto', ['.carousel', '.price']); + + $js = implode("\n", $page->evaluatedLog); + $this->assertStringContainsString('pf-visual-masks', $js); + // Une règle CSS par sélecteur, pas un sélecteur groupé : un sélecteur + // invalide ne doit pas désactiver le masquage des autres. + $this->assertStringContainsString('.carousel { visibility: hidden', $js); + $this->assertStringContainsString('.price { visibility: hidden', $js); + $this->assertStringContainsString("getElementById('pf-visual-masks')", $js); + $inject = array_key_first(array_filter($page->evaluatedLog, fn ($s) => str_contains($s, 'visibility: hidden'))); + $remove = array_key_first(array_filter($page->evaluatedLog, fn ($s) => str_contains($s, '.remove()'))); + $this->assertNotNull($inject); + $this->assertNotNull($remove); + $this->assertLessThan($remove, $inject); + } + + public function test_no_mask_js_without_masks(): void + { + $page = $this->makePage(); + $page->visualCheckpoint('hdr', null, 0.98, false); + $this->assertStringNotContainsString('pf-visual-masks', implode("\n", $page->evaluatedLog)); + } + + public function test_masks_are_removed_even_when_capture_throws(): void + { + $page = $this->makePage(screenshotThrows: true); + + $this->expectException(\RuntimeException::class); + + try { + $page->visualCheckpoint('hdr', null, 0.98, false, 'auto', ['.carousel']); + } finally { + $js = implode("\n", $page->evaluatedLog); + $this->assertStringContainsString("getElementById('pf-visual-masks')", $js); + $this->assertStringContainsString('.remove()', $js); + } + } +} diff --git a/tests/Unit/Visual/VisualDevicesTest.php b/tests/Unit/Visual/VisualDevicesTest.php new file mode 100644 index 0000000..e4a1fb2 --- /dev/null +++ b/tests/Unit/Visual/VisualDevicesTest.php @@ -0,0 +1,23 @@ +assertSame(1920, VisualDevices::get('desktop')['width']); + $this->assertSame([768, 1024], [VisualDevices::get('tablet')['width'], VisualDevices::get('tablet')['height']]); + $this->assertStringContainsString('iPhone', VisualDevices::get('mobile')['userAgent']); + $this->assertSame(['desktop', 'tablet', 'mobile'], VisualDevices::names()); + } + + public function test_unknown_device_throws(): void + { + $this->expectException(\InvalidArgumentException::class); + VisualDevices::get('watch'); + } +} diff --git a/tests/Unit/Visual/VisualTestsSuiteTest.php b/tests/Unit/Visual/VisualTestsSuiteTest.php new file mode 100644 index 0000000..b8363f6 --- /dev/null +++ b/tests/Unit/Visual/VisualTestsSuiteTest.php @@ -0,0 +1,190 @@ + 'login', 'path' => 'connexion', 'paths' => ['en' => 'login']], + ['name' => 'footer', 'path' => '', 'zone' => 'element', 'selector' => '#footer', 'excludeDevices' => ['mobile']], + ['name' => 'promos', 'path' => 'promotions', 'paths' => ['en' => false]], + ]; + // pas de page réelle en test unitaire + protected function importVisualPage(): void {} + }; + $suite->setGlobals(array_merge([ + 'PS_VERSION' => '8.1.0', 'LOCALE' => 'fr', 'PREFIX_LOCALE' => true, + 'FO' => ['URL' => 'https://shop.test', 'EMAIL' => '', 'PASSWD' => ''], + 'BO' => ['URL' => '', 'EMAIL' => '', 'PASSWD' => ''], + ], $globals)); + + return $suite; + } + + private array $envBackup = []; + private array $serverBackup = []; + + protected function setUp(): void + { + $this->envBackup = $_ENV; + $this->serverBackup = $_SERVER; + } + + protected function tearDown(): void + { + TestsSuite::useBrowserOptions(null, null, null); + $_ENV = $this->envBackup; + $_SERVER = $this->serverBackup; + } + + /** Suite construite avec loadGlobals: true (chemin CLI), sans navigateur. */ + private function cliSuite(): VisualTestsSuite + { + return new class (loadGlobals: true, getBrowser: false) extends VisualTestsSuite { + protected array $devices = ['desktop']; + protected array $locales = ['fr', 'en']; + protected array $checkpoints = []; + protected function importVisualPage(): void {} + }; + } + + /** Expose la logique de décision du preset (pas de navigateur réel). */ + private function presetProbe(): string + { + $probe = new class (loadGlobals: false, getBrowser: false) extends VisualTestsSuite { + protected function importVisualPage(): void {} + public static function apply(string $device): bool { return static::applyDevicePreset($device); } + }; + + return $probe::class; + } + + public function test_resolve_path(): void + { + $s = $this->suite(); + $this->assertSame('connexion', $s->resolvePath(['path' => 'connexion', 'paths' => ['en' => 'login']], 'fr')); + $this->assertSame('login', $s->resolvePath(['path' => 'connexion', 'paths' => ['en' => 'login']], 'en')); + $this->assertNull($s->resolvePath(['path' => 'x', 'paths' => ['en' => false]], 'en')); + $this->assertSame('x', $s->resolvePath(['path' => 'x', 'paths' => ['en' => '']], 'en')); + } + + public function test_resolve_url(): void + { + $this->assertSame('https://shop.test/fr/connexion', $this->suite()->resolveUrl('connexion', 'fr')); + $this->assertSame('https://shop.test/connexion', $this->suite(['PREFIX_LOCALE' => false])->resolveUrl('/connexion', 'fr')); + $this->assertSame('https://shop.test/fr/', $this->suite()->resolveUrl('', 'fr')); + } + + public function test_current_device_from_globals_then_default(): void + { + $this->assertSame('desktop', $this->suite()->currentDevice()); + $this->assertSame('mobile', $this->suite(['DEVICE' => 'mobile'])->currentDevice()); + } + + public function test_init_registers_tests_and_skips(): void + { + $s = $this->suite(['DEVICE' => 'mobile', 'LOCALE' => 'en']); + $s->init(); + $tests = array_values($s->tests); + + $this->assertCount(3, $tests); + $this->assertSame('capture visuelle : login', $tests[0]['title']); + $this->assertArrayNotHasKey('skip', $tests[0]); + $this->assertTrue($tests[1]['skip'] ?? false); // footer exclu sur mobile + $this->assertTrue($tests[2]['skip'] ?? false); // promos sautée en EN + } + + public function test_cli_locale_defaults_to_first_declared_locale(): void + { + unset($_ENV['PRESTAFLOW_LOCALE'], $_SERVER['PRESTAFLOW_LOCALE']); + putenv('PRESTAFLOW_LOCALE'); + + $this->assertSame('fr', $this->cliSuite()->currentLocale()); + } + + public function test_cli_locale_env_wins_over_declared_locales(): void + { + $_ENV['PRESTAFLOW_LOCALE'] = 'en'; + + $this->assertSame('en', $this->cliSuite()->currentLocale()); + } + + public function test_init_rejects_undeclared_device(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('tablet'); + $this->suite(['DEVICE' => 'tablet'])->init(); + } + + public function test_init_rejects_undeclared_locale(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('de'); + $this->suite(['LOCALE' => 'de'])->init(); + } + + public function test_default_title_is_short_class_name(): void + { + $s = $this->suite(); + $s->init(); + $this->assertStringNotContainsString('\\', $s->title); + $this->assertSame(substr(strrchr('\\'.$s::class, '\\'), 1), $s->title); + } + + public function test_normalize_clamps_threshold(): void + { + $this->assertSame(0.5, VisualTestsSuite::normalize(['name' => 'x', 'threshold' => 0.1])['threshold']); + $this->assertSame(1.0, VisualTestsSuite::normalize(['name' => 'x', 'threshold' => 3])['threshold']); + $this->assertSame(0.9, VisualTestsSuite::normalize(['name' => 'x', 'threshold' => '0.9'])['threshold']); + } + + public function test_device_preset_applied_and_resets_browser_when_options_differ(): void + { + $probe = $this->presetProbe(); + $socket = TestsSuite::getFilePath('.browser'); + file_put_contents($socket, 'ws://127.0.0.1:1/devtools/browser/nope'); + + $this->assertTrue($probe::apply('mobile')); + $this->assertFileDoesNotExist($socket); + $this->assertSame([390, 844], TestsSuite::browserOptions()['windowSize']); + + // même device : options déjà bonnes → pas de reset, socket réutilisable + file_put_contents($socket, 'ws://127.0.0.1:1/devtools/browser/nope'); + $this->assertFalse($probe::apply('mobile')); + $this->assertFileExists($socket); + @unlink($socket); + + // device différent, preset posé par nous → on le remplace + $this->assertTrue($probe::apply('tablet')); + $this->assertSame([768, 1024], TestsSuite::browserOptions()['windowSize']); + } + + public function test_device_preset_never_overrides_app_options(): void + { + $probe = $this->presetProbe(); + $probe::apply('mobile'); + + // l'app pose ses propres options (matrice) → intouchables + TestsSuite::useBrowserOptions(1280, 720, 'AppUA'); + $this->assertFalse($probe::apply('desktop')); + $this->assertSame(['windowSize' => [1280, 720], 'userAgent' => 'AppUA'], TestsSuite::browserOptions()); + } + + public function test_normalize_defaults(): void + { + $cp = VisualTestsSuite::normalize(['name' => 'x', 'path' => '']); + $this->assertSame('viewport', $cp['zone']); + $this->assertSame(0.98, $cp['threshold']); + $this->assertSame([], $cp['masks']); + $this->assertSame([], $cp['excludeDevices']); + } +}