diff --git a/src/Pages/v9/BackOffice/Login/Page.php b/src/Pages/v9/BackOffice/Login/Page.php index a62d8d6..afa1bd7 100644 --- a/src/Pages/v9/BackOffice/Login/Page.php +++ b/src/Pages/v9/BackOffice/Login/Page.php @@ -54,11 +54,41 @@ public function login($email = null, $password = null, $waitForNavigation = true if ($waitForNavigation) { $this->click($this->getSelector('submitLoginButton')); $this->waitForPageReload(); + $this->waitForLoginOutcome(); } else { $this->click($this->getSelector('submitLoginButton')); } } + /** + * Wait until the login has an outcome: the logout link (session open) or + * an error alert with text in it (credentials refused). + * + * waitForPageReload() alone is a fixed 10 s, and isLoggedIn() then looks + * for 5 s more. The first dashboard after a cache clear can take longer + * than that on a loaded CI runner: the session was open, the dashboard was + * still loading, and the login was reported as refused (PS 9.2, reproduced + * with a CPU-throttled shop). Waiting on the outcome costs nothing when + * the shop is fast — either signal ends the wait as soon as it shows — and + * the ceiling is only reached by a shop that is actually broken. + * + * The alert must have TEXT: 1.7 and 8 ship an empty .alert-danger in the + * login page (see getLoginError()). Returns whether an outcome was seen; + * callers still assert with isLoggedIn() / getLoginError(). + */ + public function waitForLoginOutcome(int $timeout = 60000, int $interval = 200): bool + { + return $this->waitForJsCondition( + sprintf( + '!!document.querySelector(%s) || (function(){var e=document.querySelector(%s);return !!e && e.textContent.trim() !== "";})()', + json_encode($this->getSelector('logoutLink')), + json_encode($this->getSelector('alertDangerTextBlock')) + ), + $timeout, + $interval + ); + } + /** * Whether an employee session is actually open. * diff --git a/src/Tests/VisualTestsSuite.php b/src/Tests/VisualTestsSuite.php index 453e7b5..a63d3f0 100644 --- a/src/Tests/VisualTestsSuite.php +++ b/src/Tests/VisualTestsSuite.php @@ -418,9 +418,10 @@ protected function ensureBackOfficeLogin(?object $login): void } $login->login(); // identifiants des globals BO_EMAIL / BO_PASSWD if (!$login->isLoggedIn()) { - $error = $this->readNow($login, sprintf('(function(){var e=document.querySelector(%s);return e?e.textContent:"";})()', json_encode((string) $login->getSelector('alertDangerDiv')))); - $error = trim(is_string($error) ? $error : ''); - throw new \RuntimeException('identifiants refusés ou page inattendue'.($error !== '' ? ' : '.$error : '')); + // 9.2 : .alert-text, sans le bouton de fermeture (« close ») ; 1.7 et 8 : l'alerte entière. + $error = $this->readNow($login, sprintf('(function(){var e=document.querySelector(%s);if(!e)return "";return (e.querySelector(".alert-text")||e).textContent;})()', json_encode((string) $login->getSelector('alertDangerDiv')))); + $error = trim(preg_replace('/\s+/', ' ', is_string($error) ? $error : '')); + throw new \RuntimeException('identifiants refusés ou page inattendue'.($error !== '' ? ' : '.$error : $this->whereIs($login))); } $this->boLoggedIn = true; @@ -433,6 +434,17 @@ protected function ensureBackOfficeLogin(?object $login): void throw new \RuntimeException('Connexion au back-office impossible : '.$this->boLoginError); } + /** + * Sans alerte, la connexion n'a pas été refusée : l'onglet est ailleurs. On + * donne le chemin et le contrôleur, jamais le jeton de l'URL. + */ + protected function whereIs(object $page): string + { + $where = $this->readNow($page, '(function(){var c=new URLSearchParams(location.search).get("controller");return location.pathname+(c?"?controller="+c:"");})()'); + + return is_string($where) && $where !== '' ? ' (page : '.$where.')' : ''; + } + /** Formulaire de connexion affiché sur la page courante (lu sans attendre, sélecteur de la page Login). */ protected function loginFormPresent(object $page, ?object $login): bool { diff --git a/tests/Unit/Pages/BackOfficeLoginOutcomeTest.php b/tests/Unit/Pages/BackOfficeLoginOutcomeTest.php new file mode 100644 index 0000000..0a2263d --- /dev/null +++ b/tests/Unit/Pages/BackOfficeLoginOutcomeTest.php @@ -0,0 +1,146 @@ +value; + } +} + +final class FakeLoginOutcomeBrowserPage +{ + public function __construct(private FakeLoginOutcomePage $owner) + { + } + + public function evaluate(string $js): FakeLoginOutcomeEvaluation + { + $this->owner->polls++; + $this->owner->evaluated[] = $js; + + return new FakeLoginOutcomeEvaluation($this->owner->settled()); + } +} + +/** + * Test double for a login whose next page is slow: the outcome (session open, + * or error shown) only becomes visible after $settlesOnPoll polls. A cold + * dashboard on a loaded CI runner took longer than the old fixed 10 s + 5 s. + */ +final class FakeLoginOutcomePage extends LoginPage +{ + public int $polls = 0; + public int $settlesOnPoll = 3; + public bool $neverSettles = false; + public array $evaluated = []; + public array $log = []; + + public function settled(): bool + { + return !$this->neverSettles && $this->polls >= $this->settlesOnPoll; + } + + public function getPage() + { + return new FakeLoginOutcomeBrowserPage($this); + } + + public function setValue($selector, $value) + { + $this->log[] = 'set '.$selector; + } + + public function click($selector, $nth = 1) + { + $this->log[] = 'click '.$selector; + } + + public function waitForPageReload() + { + $this->log[] = 'reload'; + } +} + +final class BackOfficeLoginOutcomeTest extends TestCase +{ + private function page(): FakeLoginOutcomePage + { + return new FakeLoginOutcomePage( + locale: 'en', + patchVersion: '9.2.0', + globals: [ + 'PS_VERSION' => '9.2.0', + 'LOCALE' => 'en', + 'PREFIX_LOCALE' => false, + 'BO' => ['URL' => 'http://localhost/admin-dev/', 'EMAIL' => 'a@b.c', 'PASSWD' => 'x'], + 'FO' => ['URL' => 'http://localhost/', 'EMAIL' => 'a@b.c', 'PASSWD' => 'x'], + 'DEBUG' => false, + 'VERBOSE' => false, + ], + customs: [] + ); + } + + public function testASlowOutcomeIsWaitedFor(): void + { + $page = $this->page(); + $page->settlesOnPoll = 4; + + $this->assertTrue($page->waitForLoginOutcome(2000, 1)); + $this->assertSame(4, $page->polls); + } + + public function testTheOutcomeIsTheLogoutLinkOrAFilledErrorAlert(): void + { + $page = $this->page(); + $page->settlesOnPoll = 1; + + $page->waitForLoginOutcome(2000, 1); + + $js = $page->evaluated[0]; + $this->assertStringContainsString('#header_logout', $js); + $this->assertStringContainsString('.alert-danger', $js); + // An empty alert container (1.7 / 8 ship one) is not an outcome. + $this->assertStringContainsString('textContent', $js); + } + + public function testAnOutcomeThatNeverComesReturnsFalseRatherThanThrowing(): void + { + $page = $this->page(); + $page->neverSettles = true; + + $this->assertFalse($page->waitForLoginOutcome(150, 10)); + $this->assertGreaterThan(1, $page->polls); + } + + public function testLoginWaitsForTheOutcomeAfterTheReload(): void + { + $page = $this->page(); + $page->settlesOnPoll = 2; + + $page->login(); + + $this->assertSame(['set #email', 'set #passwd', 'click #submit_login', 'reload'], $page->log); + $this->assertSame(2, $page->polls, 'login() must poll until the outcome is visible'); + } + + public function testLoginWithoutNavigationWaitDoesNotWait(): void + { + $page = $this->page(); + + $page->login(waitForNavigation: false); + + $this->assertSame(['set #email', 'set #passwd', 'click #submit_login'], $page->log); + $this->assertSame(0, $page->polls); + } +} diff --git a/tests/Unit/Visual/VisualTestsSuiteBackOfficeTest.php b/tests/Unit/Visual/VisualTestsSuiteBackOfficeTest.php index 0437f49..589eaac 100644 --- a/tests/Unit/Visual/VisualTestsSuiteBackOfficeTest.php +++ b/tests/Unit/Visual/VisualTestsSuiteBackOfficeTest.php @@ -17,11 +17,13 @@ protected function setUp(): void $this->chrome = new class { public bool $form = true; public string $error = ''; + public string $location = ''; public function evaluate(string $js): object { $value = match (true) { str_contains($js, '#email') => $this->form, str_contains($js, '.alert-danger') => $this->error, + str_contains($js, 'location.pathname') => $this->location, default => null, }; @@ -351,6 +353,36 @@ public function test_refused_login_reports_the_form_error(): void ); } + public function test_refused_login_reports_the_form_error_on_one_line(): void + { + // 9.2 : bouton de fermeture puis .alert-text, sur plusieurs lignes ; seule + // la première ligne (« close ») apparaissait dans le rapport. + $this->chrome->error = "\n The employee does not exist,\n or the password provided is incorrect.\n"; + $log = new \ArrayObject(); + $s = $this->suite(page: $this->page($log), login: $this->login($log, ok: false), checkpoints: [['name' => 'dashboard']]); + $s->init(); + + $this->assertSame( + ['capture visuelle : dashboard' => 'Connexion au back-office impossible : identifiants refusés ou page inattendue : The employee does not exist, or the password provided is incorrect.'], + $this->runSteps($s) + ); + } + + public function test_unexpected_page_reports_where_the_browser_is(): void + { + // Pas d'alerte : la connexion n'a pas été refusée, l'onglet est ailleurs. + // Le chemin et le contrôleur suffisent au diagnostic, pas le jeton. + $this->chrome->location = '/admin-dev/?controller=AdminDashboard'; + $log = new \ArrayObject(); + $s = $this->suite(page: $this->page($log), login: $this->login($log, ok: false), checkpoints: [['name' => 'dashboard']]); + $s->init(); + + $this->assertSame( + ['capture visuelle : dashboard' => 'Connexion au back-office impossible : identifiants refusés ou page inattendue (page : /admin-dev/?controller=AdminDashboard)'], + $this->runSteps($s) + ); + } + public function test_failed_checkpoint_forces_navigation_on_next_one_with_same_target(): void { $log = new \ArrayObject();