From fece677f0d47a8fbca6ed27b26ad04fbceb51454 Mon Sep 17 00:00:00 2001 From: Jonathan Danse Date: Thu, 24 Sep 2026 16:32:53 +0200 Subject: [PATCH 01/16] fix(pages): make waitForPageLoaded() actually wait, stop __call swallowing typos waitForPageLoaded() called $this->waitForNavigation(), which CommonPage does not define. __call only forwards to the chrome-php Page, which does not have it either (it lives on PageNavigation), and it dropped anything it could not forward. So the method returned immediately and every caller believed the page was loaded. goToProduct() and the "[Debug] This page has moved" fallback in FrontOfficePage::goToPage() made the same dead call. waitForPageLoaded() now polls document.readyState through waitForJsCondition() until the DOM is parsed and has a body, takes an optional timeout (30s by default) and throws TimeoutException when it runs out. The dead calls now go through it. __call now throws BadMethodCallException for a name neither the page object nor the chrome-php Page defines, so the next such mistake fails the step instead of passing silently. It also returns what the proxied method returned rather than null. With no browser page yet, a genuine chrome-php method is still a no-op, as before. Co-Authored-By: Claude Opus 5.5 --- src/Pages/CommonPage.php | 39 +++++++- src/Pages/FrontOfficePage.php | 4 +- src/Pages/v9/FrontOffice/Product/Page.php | 2 +- tests/Unit/Pages/WaitForPageLoadedTest.php | 107 +++++++++++++++++++++ 4 files changed, 146 insertions(+), 6 deletions(-) create mode 100644 tests/Unit/Pages/WaitForPageLoadedTest.php diff --git a/src/Pages/CommonPage.php b/src/Pages/CommonPage.php index 4c6905c..fdb0152 100644 --- a/src/Pages/CommonPage.php +++ b/src/Pages/CommonPage.php @@ -125,8 +125,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) @@ -558,9 +573,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) 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/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; + } + }; + } +} From cfff0a20cf9dd444b9190358728c4d4e54fa0868 Mon Sep 17 00:00:00 2001 From: Jonathan Danse Date: Thu, 24 Sep 2026 16:34:45 +0200 Subject: [PATCH 02/16] fix(cli): exit non-zero when a run throws bin/prestaflow listened to ConsoleEvents::ERROR to print its ERROR/TRACE lines, then called setExitCode(Command::SUCCESS). Symfony treats an exit code of 0 from that event as "handled" and drops the error, so a run that could not even start (Chrome failing to launch, a suites path that does not exist, a mistyped command) ended with status 0 and turned the CI job green. The application now lives in PrestaFlow\Library\Console\Application, which the binary merely runs, so its exit codes can be tested. The ERROR/TRACE rendering moved into doRenderThrowable(); Symfony's own handling then turns the error into exit code 1 (or the exception's code). catchErrors is on, because most failures here are \Error and would otherwise escape as a PHP fatal. Assertion failures still exit 1 through ExecuteSuite's return value, and a clean run still exits 0. The ERROR/TRACE lines now go to stderr, where Symfony writes rendered errors. Co-Authored-By: Claude Opus 5.5 --- bin/prestaflow | 48 +-------------- src/Console/Application.php | 40 ++++++++++++ tests/Unit/Command/ExitCodeTest.php | 95 +++++++++++++++++++++++++++++ 3 files changed, 138 insertions(+), 45 deletions(-) create mode 100644 src/Console/Application.php create mode 100644 tests/Unit/Command/ExitCodeTest.php 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/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/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); + } +} From 2042b26b3cd7cba8ebfa7772704eb471f895d046 Mon Sep 17 00:00:00 2001 From: Jonathan Danse Date: Thu, 24 Sep 2026 16:35:13 +0200 Subject: [PATCH 03/16] fix(tests): read PRESTAFLOW_EXTRA_HEADERS from the process environment too presetExtraHeadersFromEnv() read $_ENV directly. When PHP runs with a variables_order without "E", the usual case on CI, a variable set by the runner (a GitHub Actions env: block, for instance) never reaches $_ENV, so the headers were silently dropped: a WAF bypass header configured in CI did nothing and the run hit the challenge page instead. It now goes through Env::get(), like every other PRESTAFLOW_* read: $_ENV first (values loaded from .env), then getenv(). It was the last direct read of a PRESTAFLOW_* variable in src/; the remaining $_ENV writes in TestsSuite normalise booleans after reading through Env. Co-Authored-By: Claude Opus 5.5 --- src/Tests/TestsSuite.php | 2 +- tests/Unit/Tests/ExtraHeadersFromEnvTest.php | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/Tests/TestsSuite.php b/src/Tests/TestsSuite.php index 4e5af19..551663e 100644 --- a/src/Tests/TestsSuite.php +++ b/src/Tests/TestsSuite.php @@ -635,7 +635,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; } 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); + } } From f14cd13036f360755836271a0b303d0025fe492a Mon Sep 17 00:00:00 2001 From: Jonathan Danse Date: Thu, 24 Sep 2026 16:36:13 +0200 Subject: [PATCH 04/16] fix(composer): expose bin/prestaflow as a Composer binary composer.json had no "bin" key, so installing the library as a dependency never created vendor/bin/prestaflow: the command every guide tells users to run did not exist, and projects had to call the script inside vendor/prestaflow/php-library/bin themselves. bin/prestaflow already found the right autoloader in both installed layouts, through $_composer_autoload_path (set by Composer's vendor/bin proxy) and through ../../../autoload.php (direct call inside vendor/). The new test pins that down for both, running from an unrelated directory so the getcwd() fallback cannot be what makes them pass. Checked by hand too, with a throwaway project requiring the library from a path repository. Co-Authored-By: Claude Opus 5.5 --- composer.json | 3 + tests/Unit/Command/BinaryInstallTest.php | 94 ++++++++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 tests/Unit/Command/BinaryInstallTest.php diff --git a/composer.json b/composer.json index 71bc1a1..e80fe7c 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/", 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]; + } +} From f81717bc9074c5a4efc694dc2b059697717e76fe Mon Sep 17 00:00:00 2001 From: Jonathan Danse Date: Thu, 24 Sep 2026 16:53:54 +0200 Subject: [PATCH 05/16] feat(cli): run only the suite folders named by --suites / PRESTAFLOW_SUITES The GitHub Action turns its `suites` input into PRESTAFLOW_SUITES, but the library never read it: a workflow asking for BackOffice ran every suite. `prestaflow run ` now takes the comma-separated names from --suites, or from PRESTAFLOW_SUITES (read through Env::get) when the option is absent, as sub-folders of . Nested paths such as FrontOffice/Checkout are allowed, each folder is scanned recursively as before, and the run takes the union without duplicates. --group and --draft then apply to what is left. A name that matches no folder fails the run with the missing names and the available sub-folders: a typo in a workflow must not become a green job that ran zero tests. Absolute paths and ".." are refused, since the filter is only meant to narrow . Combining a filter with a single suite file is an error for the same reason. Unset or empty, nothing changes. Co-Authored-By: Claude Opus 5.5 --- README.md | 21 +++ src/Command/ExecuteSuite.php | 94 +++++++++++- tests/Unit/Command/SuitesFilterTest.php | 186 ++++++++++++++++++++++++ 3 files changed, 298 insertions(+), 3 deletions(-) create mode 100644 tests/Unit/Command/SuitesFilterTest.php diff --git a/README.md b/README.md index 0799320..7473fcd 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,27 @@ 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. +- 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/src/Command/ExecuteSuite.php b/src/Command/ExecuteSuite.php index b9afe06..68cec7d 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', @@ -138,7 +139,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(); @@ -443,14 +448,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 +477,79 @@ 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)); + } + + /** + * 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/tests/Unit/Command/SuitesFilterTest.php b/tests/Unit/Command/SuitesFilterTest.php new file mode 100644 index 0000000..62e6dd4 --- /dev/null +++ b/tests/Unit/Command/SuitesFilterTest.php @@ -0,0 +1,186 @@ +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'); + + // Empty/ holds no suite: the run succeeds with "Tests folder is empty", + // which proves the unknown name from the environment was ignored. + $exitCode = $this->tester()->run( + ['command' => 'run', 'folder' => $this->root, '--suites' => 'Empty'], + ['capture_stderr_separately' => true] + ); + + $this->assertSame(0, $exitCode); + } + + 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; + } +} From 5d544e0f25e8cdae6980dab3a650d66b91a00011 Mon Sep 17 00:00:00 2001 From: Jonathan Danse Date: Thu, 24 Sep 2026 16:55:24 +0200 Subject: [PATCH 06/16] fix(cli): fail a filtered run that selects no suite A --suites / PRESTAFLOW_SUITES filter naming a folder that exists but holds no suite still ended with "Tests folder is empty" and exit code 0. Asking for specific suites and running none is a mistake in the filter, not an empty project, and on CI it shows up as a green job that tested nothing. When a filter is set and the run ends up with zero suites, whether the folders were empty or --group / --draft removed everything, it now stops with "Suites filter [...] selected no suite under [...]" and exit code 1. Without a filter an empty folder still exits 0. The filter's test fixtures now declare a namespace. Bare " --- README.md | 2 ++ src/Command/ExecuteSuite.php | 20 +++++++++++++++++ tests/Unit/Command/SuitesFilterTest.php | 30 ++++++++++++++++++++----- 3 files changed, 47 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 7473fcd..cafa7e4 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,8 @@ PRESTAFLOW_SUITES=BackOffice,FrontOffice/Checkout ./vendor/bin/prestaflow run te - 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. diff --git a/src/Command/ExecuteSuite.php b/src/Command/ExecuteSuite.php index 68cec7d..b96e51e 100644 --- a/src/Command/ExecuteSuite.php +++ b/src/Command/ExecuteSuite.php @@ -154,6 +154,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; }; @@ -260,6 +261,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; }; @@ -514,6 +516,24 @@ public function parseSuitesFilter(?string $raw): array 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). * diff --git a/tests/Unit/Command/SuitesFilterTest.php b/tests/Unit/Command/SuitesFilterTest.php index 62e6dd4..737057a 100644 --- a/tests/Unit/Command/SuitesFilterTest.php +++ b/tests/Unit/Command/SuitesFilterTest.php @@ -28,7 +28,7 @@ protected function setUp(): void 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, 'root . '/' . $file, "previousCwd = getcwd(); @@ -149,14 +149,34 @@ public function testCliOptionWinsOverTheEnvVariable(): void { putenv('PRESTAFLOW_SUITES=Nope'); - // Empty/ holds no suite: the run succeeds with "Tests folder is empty", - // which proves the unknown name from the environment was ignored. - $exitCode = $this->tester()->run( + $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(0, $exitCode); + $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 From 94d369741298f5e69d916464a82a6c80a85380f7 Mon Sep 17 00:00:00 2001 From: Jonathan Danse Date: Fri, 25 Sep 2026 09:48:07 +0200 Subject: [PATCH 07/16] fix(runner): give each run its own browser instead of a machine-wide one The keepAlive browser was found again through a socket file at one fixed path per machine ($TMPDIR/prestaflow-.browser), shared by every PrestaFlow process. A run reconnected to whatever Chrome that file named, and ExecuteSuite ended every run by closing that browser and deleting the file. So any run that finished while another was in flight, even a browser-free one such as a smoke suite, closed the other run's Chrome under it. The victim failed with "The page was closed and is not available anymore", typically on its first navigation, or silently lost its session when it relaunched a browser between two steps (an emptied cart, a checkout that no longer matches). Whether it happened depended on what else was running on the machine, not on the PrestaShop version under test. ExecuteSuite now scopes the socket and options files to the run (pid plus a random token) before it starts and releases only that browser at the end. Callers outside ExecuteSuite keep the shared path, and the reconnection they may rely on. Co-Authored-By: Claude Opus 5.5 --- src/Command/ExecuteSuite.php | 27 ++++++- src/Tests/TestsSuite.php | 27 +++++++ tests/Unit/Command/BrowserOwnershipTest.php | 90 +++++++++++++++++++++ 3 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 tests/Unit/Command/BrowserOwnershipTest.php diff --git a/src/Command/ExecuteSuite.php b/src/Command/ExecuteSuite.php index b96e51e..2c9a439 100644 --- a/src/Command/ExecuteSuite.php +++ b/src/Command/ExecuteSuite.php @@ -118,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; @@ -302,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) diff --git a/src/Tests/TestsSuite.php b/src/Tests/TestsSuite.php index 551663e..20ef20f 100644 --- a/src/Tests/TestsSuite.php +++ b/src/Tests/TestsSuite.php @@ -154,6 +154,11 @@ 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; + protected $draft = false; protected $groups = 'all'; @@ -316,8 +321,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)) { 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') + ); + } +} From e596c36d50413e92c3be26f1c21f4e5f6ed3cc85 Mon Sep 17 00:00:00 2001 From: Jonathan Danse Date: Fri, 25 Sep 2026 10:14:16 +0200 Subject: [PATCH 08/16] fix(pages): report a lost browser instead of reading it as a missing element elementIsVisible() caught every Exception and returned false. When the Chrome a run drives is closed under it (another PrestaFlow process releasing the shared browser, a crash), chrome-php throws TargetDestroyed ("The session is destroyed."), and isVisible() answered "not visible". A suite checking a block on the home page then failed with "false must be the same as true": a lost browser disguised as a regression of the shop, which is how psflowdemo's DisplayHome looked broken on 1.7.8.11 while the block was served, hooked and rendered. TargetDestroyed now propagates, so the step fails with the browser error the runner already reports. A missing element still answers false. Co-Authored-By: Claude Opus 5.5 --- src/Pages/CommonPage.php | 6 ++ .../Pages/VisibilityOnLostBrowserTest.php | 56 +++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 tests/Unit/Pages/VisibilityOnLostBrowserTest.php diff --git a/src/Pages/CommonPage.php b/src/Pages/CommonPage.php index fdb0152..93a24d5 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; @@ -857,6 +858,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/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); + } +} From 4c41c3dcb5605dab3330bf5afde56c364c436fd2 Mon Sep 17 00:00:00 2001 From: Jonathan Danse Date: Mon, 28 Sep 2026 14:38:13 +0200 Subject: [PATCH 09/16] feat(browser): per-run window size/UA overrides + resetBrowser() Co-Authored-By: Claude Opus 5.5 --- src/Tests/TestsSuite.php | 58 +++++++++++++++++++++---- tests/Unit/Tests/BrowserOptionsTest.php | 50 +++++++++++++++++++++ 2 files changed, 100 insertions(+), 8 deletions(-) create mode 100644 tests/Unit/Tests/BrowserOptionsTest.php diff --git a/src/Tests/TestsSuite.php b/src/Tests/TestsSuite.php index 20ef20f..083a48b 100644 --- a/src/Tests/TestsSuite.php +++ b/src/Tests/TestsSuite.php @@ -159,6 +159,47 @@ private static function buildVisualBlock(int $startIndex): array */ protected static ?string $browserFilesScope = null; + /** Overrides posés par l'app (matrice visuelle) avant la création du navigateur. */ + protected static ?array $browserOptionOverrides = null; + + 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 ?? []; + + return [ + 'windowSize' => [ + (int) ($o['width'] ?? null ?: (Env::get('PRESTAFLOW_WINDOW_SIZE_WIDTH') ?: 1920)), + (int) ($o['height'] ?? null ?: (Env::get('PRESTAFLOW_WINDOW_SIZE_HEIGHT') ?: 1080)), + ], + '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). + */ + public static function resetBrowser(): void + { + try { + self::$browserInstance?->close(); + } catch (\Throwable) { + // navigateur déjà mort : rien à faire + } + self::$browserInstance = null; + self::$browserInstanceSocket = null; + @unlink(self::getFilePath('.browser')); + @unlink(self::getFilePath('.browser-options')); + } + protected $draft = false; protected $groups = 'all'; @@ -473,15 +514,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, ]; @@ -969,8 +1011,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/tests/Unit/Tests/BrowserOptionsTest.php b/tests/Unit/Tests/BrowserOptionsTest.php new file mode 100644 index 0000000..6f0a90c --- /dev/null +++ b/tests/Unit/Tests/BrowserOptionsTest.php @@ -0,0 +1,50 @@ +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); + } +} From 220bcc180cbebcbf218321854d05c16f7dae301d Mon Sep 17 00:00:00 2001 From: Jonathan Danse Date: Mon, 28 Sep 2026 14:41:23 +0200 Subject: [PATCH 10/16] fix(browser): harden resetBrowser + browserOptions Co-Authored-By: Claude Opus 5.5 --- src/Tests/TestsSuite.php | 48 ++++++++++++++++++++----- tests/Unit/Tests/BrowserOptionsTest.php | 24 +++++++++++-- 2 files changed, 61 insertions(+), 11 deletions(-) diff --git a/src/Tests/TestsSuite.php b/src/Tests/TestsSuite.php index 083a48b..4768b0b 100644 --- a/src/Tests/TestsSuite.php +++ b/src/Tests/TestsSuite.php @@ -162,6 +162,12 @@ private static function buildVisualBlock(int $startIndex): array /** 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) @@ -174,29 +180,55 @@ 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' => [ - (int) ($o['width'] ?? null ?: (Env::get('PRESTAFLOW_WINDOW_SIZE_WIDTH') ?: 1920)), - (int) ($o['height'] ?? null ?: (Env::get('PRESTAFLOW_WINDOW_SIZE_HEIGHT') ?: 1080)), - ], - 'userAgent' => (string) ($o['userAgent'] ?? null ?: Env::get('PRESTAFLOW_USER_AGENT', 'PrestaFlow')), + '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 { - self::$browserInstance?->close(); + 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 : rien à faire + // navigateur déjà mort / injoignable : rien à faire } + self::$browserInstance = null; self::$browserInstanceSocket = null; - @unlink(self::getFilePath('.browser')); + @unlink($socketFile); @unlink(self::getFilePath('.browser-options')); } diff --git a/tests/Unit/Tests/BrowserOptionsTest.php b/tests/Unit/Tests/BrowserOptionsTest.php index 6f0a90c..898c5d0 100644 --- a/tests/Unit/Tests/BrowserOptionsTest.php +++ b/tests/Unit/Tests/BrowserOptionsTest.php @@ -10,9 +10,6 @@ final class BrowserOptionsTest extends TestCase protected function tearDown(): void { TestsSuite::useBrowserOptions(null, null, null); - putenv('PRESTAFLOW_WINDOW_SIZE_WIDTH'); - putenv('PRESTAFLOW_WINDOW_SIZE_HEIGHT'); - putenv('PRESTAFLOW_USER_AGENT'); unset($_ENV['PRESTAFLOW_WINDOW_SIZE_WIDTH'], $_ENV['PRESTAFLOW_WINDOW_SIZE_HEIGHT'], $_ENV['PRESTAFLOW_USER_AGENT']); } @@ -47,4 +44,25 @@ public function test_reset_browser_removes_socket_files(): void $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()); + } } From e0e3c83d583f2120655122e3d674611d166b9e69 Mon Sep 17 00:00:00 2001 From: Jonathan Danse Date: Mon, 28 Sep 2026 14:43:52 +0200 Subject: [PATCH 11/16] feat(visual): selector masks + scrollBelow/waitForStable helpers Co-Authored-By: Claude Opus 5.5 --- src/Pages/CommonPage.php | 76 ++++++++++--- .../Unit/Visual/VisualCheckpointMasksTest.php | 106 ++++++++++++++++++ 2 files changed, 167 insertions(+), 15 deletions(-) create mode 100644 tests/Unit/Visual/VisualCheckpointMasksTest.php diff --git a/src/Pages/CommonPage.php b/src/Pages/CommonPage.php index 93a24d5..1731474 100644 --- a/src/Pages/CommonPage.php +++ b/src/Pages/CommonPage.php @@ -429,6 +429,47 @@ 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; + } + $css = json_encode(implode(', ', $masks).' { visibility: hidden !important; }'); + $this->getPage()->evaluate( + "(function(){var s=document.createElement('style');s.id='pf-visual-masks';s.textContent={$css};document.head.appendChild(s);})()" + ); + } + + private function removeVisualMasks(array $masks): void + { + if (array_filter(array_map('trim', $masks)) === []) { + return; + } + $this->getPage()->evaluate( + "(function(){var s=document.getElementById('pf-visual-masks');if(s){s.remove();}})()" + ); + } + + /** Scrolle juste sous l'élément (ex. header) : le viewport commence au contenu utile. */ + public function scrollBelow(string $selector, int $settleMs = 400): void + { + $sel = json_encode($selector); + $this->getPage()->evaluate( + "(function(){var e=document.querySelector({$sel});if(!e){return;}window.scrollTo(0, e.getBoundingClientRect().bottom + window.scrollY);})()" + ); + usleep($settleMs * 1000); + } + + /** Attend le chargement complet + images, sans lever si le délai expire. */ + public function waitForStable(int $timeout = 10000): void + { + $this->waitForJsCondition( + "document.readyState === 'complete' && Array.from(document.images).every(function(i){return i.complete;})", + $timeout + ); + } + /** * 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 @@ -474,7 +515,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)) @@ -498,21 +539,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)) { diff --git a/tests/Unit/Visual/VisualCheckpointMasksTest.php b/tests/Unit/Visual/VisualCheckpointMasksTest.php new file mode 100644 index 0000000..d6dfb25 --- /dev/null +++ b/tests/Unit/Visual/VisualCheckpointMasksTest.php @@ -0,0 +1,106 @@ +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(): CommonPage + { + $page = new class ('en', '8.1.0', []) extends CommonPage { + public array $evaluatedLog = []; + + public function getPage() + { + $outer = $this; + + return new class ($outer) { + private $outer; + + public function __construct($outer) + { + $this->outer = $outer; + } + + public function screenshot(array $opts = []) + { + 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() + { + 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); + $this->assertStringContainsString('.carousel, .price', $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->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)); + } +} From 3554489ea59b6be5acd0b6f005419c0b1fd349df Mon Sep 17 00:00:00 2001 From: Jonathan Danse Date: Mon, 28 Sep 2026 14:47:17 +0200 Subject: [PATCH 12/16] fix(visual): await mask/scroll JS, per-selector masks, best-effort helpers Co-Authored-By: Claude Opus 5.5 --- src/Pages/CommonPage.php | 48 +++++++++++++------ .../Unit/Visual/VisualCheckpointMasksTest.php | 39 +++++++++++++-- 2 files changed, 68 insertions(+), 19 deletions(-) diff --git a/src/Pages/CommonPage.php b/src/Pages/CommonPage.php index 1731474..6c3d9c3 100644 --- a/src/Pages/CommonPage.php +++ b/src/Pages/CommonPage.php @@ -435,10 +435,15 @@ private function applyVisualMasks(array $masks): void if ($masks === []) { return; } - $css = json_encode(implode(', ', $masks).' { visibility: hidden !important; }'); + // 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 @@ -446,28 +451,41 @@ private function removeVisualMasks(array $masks): void if (array_filter(array_map('trim', $masks)) === []) { return; } - $this->getPage()->evaluate( - "(function(){var s=document.getElementById('pf-visual-masks');if(s){s.remove();}})()" - ); + 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 { - $sel = json_encode($selector); - $this->getPage()->evaluate( - "(function(){var e=document.querySelector({$sel});if(!e){return;}window.scrollTo(0, e.getBoundingClientRect().bottom + window.scrollY);})()" - ); - usleep($settleMs * 1000); + 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 + images, sans lever si le délai expire. */ + /** 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 { - $this->waitForJsCondition( - "document.readyState === 'complete' && Array.from(document.images).every(function(i){return i.complete;})", - $timeout - ); + 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 + } } /** diff --git a/tests/Unit/Visual/VisualCheckpointMasksTest.php b/tests/Unit/Visual/VisualCheckpointMasksTest.php index d6dfb25..2fc9551 100644 --- a/tests/Unit/Visual/VisualCheckpointMasksTest.php +++ b/tests/Unit/Visual/VisualCheckpointMasksTest.php @@ -27,10 +27,17 @@ protected function tearDown(): void TestsSuite::$visualResults = []; } - private function makePage(): CommonPage + private function makePage(bool $screenshotThrows = false): CommonPage { - $page = new class ('en', '8.1.0', []) extends 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() { @@ -46,6 +53,10 @@ public function __construct($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 { @@ -67,7 +78,7 @@ public function evaluate(string $js) $this->outer->evaluatedLog[] = $js; return new class { - public function getReturnValue() + public function getReturnValue($timeout = null) { return [1280, 720]; } @@ -90,10 +101,15 @@ public function test_masks_are_injected_then_removed(): void $js = implode("\n", $page->evaluatedLog); $this->assertStringContainsString('pf-visual-masks', $js); - $this->assertStringContainsString('.carousel, .price', $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); } @@ -103,4 +119,19 @@ public function test_no_mask_js_without_masks(): void $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); + } + } } From dfa2ad9e67eab1f9d2eba6651778f484c9e26d60 Mon Sep 17 00:00:00 2001 From: Jonathan Danse Date: Mon, 28 Sep 2026 14:48:56 +0200 Subject: [PATCH 13/16] feat(visual): data-driven VisualTestsSuite + device presets Co-Authored-By: Claude Opus 5.5 --- src/Tests/VisualTestsSuite.php | 129 +++++++++++++++++++++ src/Visual/VisualDevices.php | 37 ++++++ tests/Unit/Visual/VisualDevicesTest.php | 23 ++++ tests/Unit/Visual/VisualTestsSuiteTest.php | 81 +++++++++++++ 4 files changed, 270 insertions(+) create mode 100644 src/Tests/VisualTestsSuite.php create mode 100644 src/Visual/VisualDevices.php create mode 100644 tests/Unit/Visual/VisualDevicesTest.php create mode 100644 tests/Unit/Visual/VisualTestsSuiteTest.php diff --git a/src/Tests/VisualTestsSuite.php b/src/Tests/VisualTestsSuite.php new file mode 100644 index 0000000..935d7a5 --- /dev/null +++ b/src/Tests/VisualTestsSuite.php @@ -0,0 +1,129 @@ +deviceFromEnv() ?? ($this->devices[0] ?? 'desktop')); + self::useBrowserOptions($preset['width'], $preset['height'], $preset['userAgent']); + } + parent::__construct(loadGlobals: $loadGlobals, getBrowser: $getBrowser); + } + + 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 + { + return array_merge([ + 'path' => '', 'paths' => [], 'zone' => 'viewport', 'selector' => null, + 'waitFor' => null, 'scrollBelow' => null, 'threshold' => 0.98, + 'excludeDevices' => [], 'masks' => [], + ], $cp); + } + + public function currentDevice(): string + { + $globals = $this->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(); + + $this->describe($this->title ?: static::class); + + 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/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..60c1f92 --- /dev/null +++ b/tests/Unit/Visual/VisualTestsSuiteTest.php @@ -0,0 +1,81 @@ + '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; + } + + protected function tearDown(): void + { + TestsSuite::useBrowserOptions(null, null, null); + } + + 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_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']); + } +} From e79c975a13c8ac1702e6145fcb5e71a198de9b67 Mon Sep 17 00:00:00 2001 From: Jonathan Danse Date: Mon, 28 Sep 2026 14:52:53 +0200 Subject: [PATCH 14/16] fix(visual): CLI locale/device handling in VisualTestsSuite Co-Authored-By: Claude Opus 5.5 --- src/Tests/VisualTestsSuite.php | 68 +++++++++++-- tests/Unit/Visual/VisualTestsSuiteTest.php | 109 +++++++++++++++++++++ 2 files changed, 170 insertions(+), 7 deletions(-) diff --git a/src/Tests/VisualTestsSuite.php b/src/Tests/VisualTestsSuite.php index 935d7a5..f466908 100644 --- a/src/Tests/VisualTestsSuite.php +++ b/src/Tests/VisualTestsSuite.php @@ -17,14 +17,54 @@ abstract class VisualTestsSuite extends TestsSuite protected array $locales = []; protected array $checkpoints = []; + /** + * Dernier override posé par applyDevicePreset(). Tant que les overrides + * courants lui sont identiques, ils « nous appartiennent » et peuvent être + * remplacés (suite visuelle suivante, autre device). Si l'app a posé les + * siens entre-temps, ils diffèrent → on n'y touche jamais. + */ + private static ?array $appliedPreset = null; + public function __construct(bool $loadGlobals = true, bool $getBrowser = true) { - // CLI : aucun override posé par l'app → preset du device (env ou 1er déclaré). - if ($getBrowser && self::$browserOptionOverrides === null) { - $preset = VisualDevices::get($this->deviceFromEnv() ?? ($this->devices[0] ?? 'desktop')); - self::useBrowserOptions($preset['width'], $preset['height'], $preset['userAgent']); + // CLI : preset du device (env ou 1er déclaré), sauf si l'app a posé ses options. + if ($getBrowser) { + static::applyDevicePreset($this->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; } @@ -33,16 +73,23 @@ public function checkpoints(): array { return array_map([self::class, 'normalize public static function normalize(array $cp): array { - return array_merge([ + $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 { - $globals = $this->globals ?? []; + try { + $globals = $this->getGlobals(); + } catch (\Throwable) { + $globals = []; + } return $globals['DEVICE'] ?? $this->deviceFromEnv() ?? ($this->devices[0] ?? 'desktop'); } @@ -79,7 +126,14 @@ public function init() $device = $this->currentDevice(); $locale = $this->currentLocale(); - $this->describe($this->title ?: static::class); + 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']; diff --git a/tests/Unit/Visual/VisualTestsSuiteTest.php b/tests/Unit/Visual/VisualTestsSuiteTest.php index 60c1f92..b8363f6 100644 --- a/tests/Unit/Visual/VisualTestsSuiteTest.php +++ b/tests/Unit/Visual/VisualTestsSuiteTest.php @@ -30,9 +30,42 @@ protected function importVisualPage(): void {} 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 @@ -70,6 +103,82 @@ public function test_init_registers_tests_and_skips(): void $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' => '']); From 735ce59686d49e4fc8a8d0273f5f25c84b616837 Mon Sep 17 00:00:00 2001 From: Jonathan Danse Date: Mon, 28 Sep 2026 15:28:08 +0200 Subject: [PATCH 15/16] fix(composer): allow symfony/console and event-dispatcher 7.x Laravel 11+ hosts (the desktop app) require symfony/console ^7; the ^6.0-only constraint made the library uninstallable there. PHP 8.1 still resolves 6.x. Co-Authored-By: Claude Opus 5.5 --- composer.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index e80fe7c..cea7697 100644 --- a/composer.json +++ b/composer.json @@ -35,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": { From 55c39f8e031289e7e76375d2d1b6bb9dfa0817b1 Mon Sep 17 00:00:00 2001 From: Jonathan Danse Date: Mon, 28 Sep 2026 15:28:30 +0200 Subject: [PATCH 16/16] chore(composer): refresh lock hash Co-Authored-By: Claude Opus 5.5 --- composer.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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",