diff --git a/src/Pages/CommonPage.php b/src/Pages/CommonPage.php index 93a24d5..6c3d9c3 100644 --- a/src/Pages/CommonPage.php +++ b/src/Pages/CommonPage.php @@ -429,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 @@ -474,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)) @@ -498,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)) { diff --git a/src/Tests/TestsSuite.php b/src/Tests/TestsSuite.php index 20ef20f..4768b0b 100644 --- a/src/Tests/TestsSuite.php +++ b/src/Tests/TestsSuite.php @@ -159,6 +159,79 @@ 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; + + /** + * 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'; @@ -473,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, ]; @@ -969,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/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/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']); + } +}