From dc127500af535052071621ba461215ab4c71d7c9 Mon Sep 17 00:00:00 2001 From: Jonathan Danse Date: Tue, 6 Oct 2026 14:50:27 +0200 Subject: [PATCH 01/10] =?UTF-8?q?docs(spec):=20capture=20du=20s=C3=A9lecte?= =?UTF-8?q?ur=20visuel=20en=20back-office?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../2026-10-06-bo-picker-snapshot-design.md | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-06-bo-picker-snapshot-design.md diff --git a/docs/superpowers/specs/2026-10-06-bo-picker-snapshot-design.md b/docs/superpowers/specs/2026-10-06-bo-picker-snapshot-design.md new file mode 100644 index 0000000..2e75ecd --- /dev/null +++ b/docs/superpowers/specs/2026-10-06-bo-picker-snapshot-design.md @@ -0,0 +1,83 @@ +# Capture du sélecteur visuel en back-office (lib) : design + +Date : 2026-10-06 +Statut : validé, à implémenter + +Partie lib du sélecteur visuel BO de l'app PrestaFlow. La spec complète, côté +app, est `docs/superpowers/specs/2026-10-06-visual-bo-picker-design.md` du dépôt +PrestaFlow/app. + +## Contexte + +L'app veut ouvrir, pour son sélecteur visuel, la même page BO qu'un run : +connexion employé, tableau de bord, entrée du menu, ou page de connexion +déconnectée. Elle veut ensuite capturer cette page (carte des éléments et image) +avec `PageSnapshot`. + +Aujourd'hui, `PageSnapshot::take()` ne sait que naviguer vers une URL. La +connexion et le menu n'existent qu'à l'intérieur du run de `VisualTestsSuite` +(`ensureBackOfficeLogin`, `goToMenu`). + +## Design + +### `PageSnapshot::captureCurrent()` + +- Nouvelle méthode publique : + `captureCurrent(\HeadlessChromium\Page $page, int $timeoutMs = 15000): SnapshotResult`. +- Elle contient tout ce que `take()` fait après la navigation : + - attente de stabilité ; + - `PageScripts::SETTLE_ANIMATIONS` ; + - statut HTTP ; + - dimensions du document (hauteur plafonnée) ; + - `PageScripts::ELEMENT_MAP` ; + - capture pleine page en JPEG. +- `take()` crée son navigateur, navigue, puis appelle `captureCurrent()`. Son + comportement et son résultat ne changent pas : les tests existants le prouvent. + +### `VisualTestsSuite::openBackOfficeCheckpoint()` et `closeBackOfficeSession()` + +- `openBackOfficeCheckpoint(array $checkpoint, int $loginTimeoutMs = 25000, int $menuTimeoutMs = 15000): void`, publique. + - Elle ouvre, dans le navigateur courant de la suite (`TestsSuite::getPage()`), + la page d'un checkpoint BO, **avec le même code que le run** : + - le checkpoint est normalisé par `normalize()` ; + - `auth: false` : racine du BO, et l'erreur « session déjà ouverte » si le + formulaire de connexion est absent ; + - sinon : `ensureBackOfficeLogin()`, puis `goToPage('index')` et, si `menu` est + défini, `goToMenu($menu)`. + - Les plafonds bornent l'attente de l'issue de la connexion (`waitForLoginOutcome`) + et la navigation du menu. Un dépassement lève une exception de délai dédiée, + au message lisible. + - Les erreurs sont celles du run (identifiants refusés avec le message du + formulaire, page inattendue avec son chemin, entrée de menu introuvable). Elles + sont relevées comme `\RuntimeException`, avec le message déjà formulé par le + run. + - Elle exige `area === 'bo'`, sinon `\LogicException`. +- `closeBackOfficeSession(): void`, publique : déconnexion par le lien + `#header_logout` de la page courante (`BackOffice\Login\Page::logout()`) si une + session est ouverte. Elle ne lève jamais : un échec de déconnexion ne doit pas + faire échouer la capture. +- Aucune autre méthode du run ne change. `ensureBackOfficeLogin` et `goToMenu` + restent la seule implémentation de la connexion et du menu. + +### Tests de la lib + +- `captureCurrent` : `take()` donne le même `SnapshotResult` qu'avant. On le vérifie + avec les doubles de `PageSnapshotTest`, en appelant `captureCurrent` sur une page + factice. +- `openBackOfficeCheckpoint`, avec des pages factices (comme + `VisualTestsSuiteBackOfficeTest`) : + - ordre connexion → tableau de bord → menu ; + - checkpoint `auth: false` (pas de connexion) ; + - identifiants refusés ; + - menu introuvable ; + - délai dépassé ; + - refus hors BO. +- `closeBackOfficeSession` : appelle `logout()` quand une session est ouverte, ne + lève jamais. + +### Livraison de la lib + +- PR vers `dev`, merge en commit de merge. +- Release `dev` → `main` en commit de merge. +- Bump dans l'app : seul `composer.lock` change. Voir le flux de release de la lib. + From 6adc9395a454e07564f5972b0081cbb4142d63c5 Mon Sep 17 00:00:00 2001 From: Jonathan Danse Date: Tue, 6 Oct 2026 15:19:01 +0200 Subject: [PATCH 02/10] =?UTF-8?q?docs(plan):=20capture=20du=20s=C3=A9lecte?= =?UTF-8?q?ur=20visuel=20en=20back-office?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-06-bo-picker-snapshot.md | 1165 +++++++++++++++++ ...026-10-06-bo-picker-snapshot.md.tasks.json | 39 + 2 files changed, 1204 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-06-bo-picker-snapshot.md create mode 100644 docs/superpowers/plans/2026-10-06-bo-picker-snapshot.md.tasks.json diff --git a/docs/superpowers/plans/2026-10-06-bo-picker-snapshot.md b/docs/superpowers/plans/2026-10-06-bo-picker-snapshot.md new file mode 100644 index 0000000..1b8411f --- /dev/null +++ b/docs/superpowers/plans/2026-10-06-bo-picker-snapshot.md @@ -0,0 +1,1165 @@ +# Capture du sélecteur visuel en back-office (lib) : plan d'implémentation + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development (recommended) or superpowers-extended-cc:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal :** +- capturer la page déjà ouverte dans un onglet (`PageSnapshot::captureCurrent()`) ; +- ouvrir la page d'un checkpoint BO avec le code du run (`VisualTestsSuite::openBackOfficeCheckpoint()`), avec des plafonds courts pour le sélecteur visuel de l'app ; +- refermer la session employé (`VisualTestsSuite::closeBackOfficeSession()`) sans jamais faire échouer la capture. + +**Architecture :** +- `PageSnapshot::take()` = navigateur + navigation + `captureCurrent()`. Le corps de la capture est déplacé, pas réécrit. +- Plafonds sans changer aucune signature du run : deux propriétés publiques, lues par le code existant. + - `CommonPage::$navigationTimeout` (`?int`, `null` = défaut chrome-php 30 s) : `goToUrl()`, `BackOfficePage::goToPage()`, `BackOfficePage::goToMenu()`. + - `v9\BackOffice\Login\Page::$loginOutcomeTimeout` (`int`, 60 000 par défaut) : lu par `login()`. `login()` note l'issue dans `$loginOutcomeSeen` (`?bool`). +- `openBackOfficeCheckpoint()` pose les plafonds, appelle `ensureBackOfficeLogin()` / `goToPage('index')` / `goToMenu()` (inchangés), puis rétablit les valeurs. Un dépassement devient `BackOfficeTimeoutException`. + +**Tech Stack :** PHP ^8.1, PHPUnit 10.5, chrome-php. + +**Spec :** `docs/superpowers/specs/2026-10-06-bo-picker-snapshot-design.md` (spec complète côté app : `docs/superpowers/specs/2026-10-06-visual-bo-picker-design.md` du dépôt PrestaFlow/app, worktree `app-claude-bo-picker`). + +--- + +## Écarts et précisions vs spec + +1. **`captureCurrent(object $page, int $timeoutMs = 15000)`** au lieu de `\HeadlessChromium\Page $page`. Les doubles de `PageSnapshotTest` sont des classes anonymes, pas des `\HeadlessChromium\Page`. `waitUntilStable(object $page)` est déjà typé ainsi. L'app passe `TestsSuite::getPage()`, un vrai `\HeadlessChromium\Page`. Le docblock le précise. +2. **Plafonds par propriétés, pas par paramètres.** + - `login()` et `waitForLoginOutcome()` gardent leur signature et leur défaut de 60 s. `login()` lit `$this->loginOutcomeTimeout` (60 000 par défaut) et le passe à `waitForLoginOutcome()`. + - Un paramètre de plus sur `login()` obligerait `ensureBackOfficeLogin()` à le transmettre, et casserait les surcharges `login($email, $password, $waitForNavigation)` (doubles de test, pages de projets). + - Même raison pour la navigation : `goToPage($page, $params)` est surchargeable par les pages des projets. Un paramètre optionnel ajouté dans `BackOfficePage` rendrait ces surcharges incompatibles (erreur fatale). La propriété `CommonPage::$navigationTimeout` ne change aucune signature. +3. **Issue de connexion non vue = délai.** Sans issue (ni lien de déconnexion ni alerte) dans le plafond, `login()` n'échoue pas : le run conclut ensuite « identifiants refusés ou page inattendue (page : …) ». Pour distinguer ce cas, `login()` note `$loginOutcomeSeen` (`true` / `false`). `openBackOfficeCheckpoint()` transforme l'échec de connexion en `BackOfficeTimeoutException` quand `$loginOutcomeSeen === false`. +4. **`ensureBackOfficeLogin()` garde la cause.** Elle relève désormais `\RuntimeException('Connexion au back-office impossible : …', 0, $cause)`, et garde la cause dans `$boLoginCause` (remise à `null` par `init()`). Le message ne change pas. Sans la cause, un délai de chargement de la page de connexion (`OperationTimedOut`) serait indiscernable d'un refus. Aucun test existant ne regarde `getPrevious()`. +5. **Plafond de navigation = `$menuTimeoutMs`, appliqué à chaque navigation** de `openBackOfficeCheckpoint()` : racine du BO de la page Login, racine du BO, entrée du menu. La spec ne borne que « la navigation du menu ». Mais la racine et la page de connexion ont le même défaut de 30 s, qui ferait seul dépasser les 60 s de nginx. +6. **`openBackOfficeCheckpoint()` renvoie `string`, pas `void`.** Elle renvoie le chemin et le contrôleur de la page ouverte, sans jeton (même JS que `whereIs()`, mis en constante `LOCATION_JS`), ou `''` si illisible. L'app en fait le champ `url` du payload : `SnapshotResult` ne porte pas d'URL, et l'URL de l'onglet porte le jeton. +7. **`closeBackOfficeSession(int $timeoutMs = 5000): void`.** Le paramètre optionnel borne la navigation de déconnexion (`goToUrl()` du lien `#header_logout`). Il correspond aux « 5 s » du budget de la spec côté app. +8. **Exception de délai : `PrestaFlow\Library\Exceptions\BackOfficeTimeoutException`**, qui étend `TimeoutException` (donc `\RuntimeException`). Messages lisibles : + - connexion : « Le back-office n'a pas répondu à la connexion en 25 s. » ; + - navigation : « Le back-office n'a pas répondu en 15 s (chargement d'une page). » +9. **Refus.** + - Hors zone `bo` : `\LogicException`. + - Checkpoint `auth => false` avec un `menu` : `\InvalidArgumentException`, qui est une `\LogicException`, avec le message d'`init()`. L'app le refuse avant d'appeler. + - Toute autre `\Exception` non `\RuntimeException` (exceptions chrome-php comme `ResponseHasError`) est relevée en `\RuntimeException` avec le même message. Les `\Error` (défaut de code) passent telles quelles. +10. **Pages importées à la demande.** `openBackOfficeCheckpoint()` appelle `importVisualPage()` si `backOfficePage` manque : l'app ne lance pas `init()`, qui enregistrerait les étapes du run. L'état de connexion (`boLoggedIn`, `boLoginError`, `boLoginCause`, `lastVisualUrl`) est remis à zéro à chaque ouverture, comme dans `init()`. +11. **`goToPage()` n'est pas testé unitairement.** Il appelle `TestsSuite::recreatePageIfContextChanged()`, qui lance Chrome. Sa modification se limite à un argument de `waitForNavigation()`. `goToMenu()` et `goToUrl()` sont testés. + +--- + +## Contexte pour l'implémenteur + +- **Dépôt :** worktree `/Users/jonathan/Documents/GitHub/PrestaFlow/php-library-visual`, branche `feat/bo-picker-snapshot` (créée depuis `dev`). Les commandes se lancent depuis ce dossier. +- **Ne jamais toucher** `/Users/jonathan/Documents/GitHub/PrestaFlow/php-library` : c'est le checkout de l'utilisateur. +- **PHP :** + - `php` = 8.4 ; + - `"$HOME/Library/Application Support/Herd/bin/php81"` = 8.1. +- **Tests :** + - suite complète : `php vendor/bin/phpunit` (≈ 550 tests verts au départ ; relève le nombre exact au premier lancement) ; + - redirige TOUJOURS la sortie vers un fichier, par exemple `/private/tmp/claude-501/-Users-jonathan-Documents-GitHub-PrestaFlow-app/2393cd17-28d4-44c5-96f2-fa24f981a8c8/scratchpad/.txt 2>&1`, puis lance `tail -3`. Un processus Chrome enfant bloque les pipes ; + - **aucun test ne lance Chrome** : doubles comme dans `PageSnapshotTest`, `VisualTestsSuiteBackOfficeTest`, `BackOfficeGoToMenuTest`, `BackOfficeLoginOutcomeTest`. N'appelle jamais `goToPage()` d'une vraie `BackOfficePage` dans un test. +- **Commits :** + - chemins explicites, jamais `git add -A`, `git add .` ni `git commit -a` ; + - messages en français, terminés par une ligne vide puis `Co-Authored-By: Claude Opus 5.5 `. +- **Lecture des fichiers :** `rtk proxy cat` ou `sed -n` (la sortie de `cat` est filtrée). Garde les commentaires non visés. +- **Noms partagés avec le plan de l'app** (`docs/superpowers/plans/2026-10-06-visual-bo-picker.md` du worktree `app-claude-bo-picker`) : ne pas les changer. + - `PageSnapshot::captureCurrent(object $page, int $timeoutMs = 15000): SnapshotResult` ; + - `VisualTestsSuite::openBackOfficeCheckpoint(array $checkpoint, int $loginTimeoutMs = 25000, int $menuTimeoutMs = 15000): string` ; + - `VisualTestsSuite::closeBackOfficeSession(int $timeoutMs = 5000): void` ; + - `PrestaFlow\Library\Exceptions\BackOfficeTimeoutException`. + +--- + +### Task 1 : `PageSnapshot::captureCurrent()` + +**Goal :** la capture d'une page déjà ouverte est une méthode publique ; `take()` l'appelle après sa navigation, sans changer de comportement. + +**Files :** +- Modify : `src/Visual/PageSnapshot.php` (`take()` l.40-102 ; nouvelle méthode juste après) +- Modify : `tests/Unit/Visual/PageSnapshotTest.php` (propriété `$factory`, `snapshot()` l.16-127, deux tests ajoutés) + +**Acceptance Criteria :** +- [ ] `captureCurrent(object $page, int $timeoutMs = 15000): SnapshotResult` fait, dans l'ordre : stabilité, `SETTLE_ANIMATIONS`, statut, dimensions (hauteur plafonnée à `MAX_HEIGHT`), `scrollTo(0, 0)`, `ELEMENT_MAP`, capture JPEG qualité 80. +- [ ] Elle ne navigue pas et ne ferme pas le navigateur. +- [ ] `take()` crée le navigateur, navigue (même message d'erreur), appelle `captureCurrent($page, $timeoutMs)` et ferme le navigateur dans son `finally`. +- [ ] Les 6 tests existants de `PageSnapshotTest` passent sans modification de leurs assertions. +- [ ] `take()` et `captureCurrent()` donnent le même `SnapshotResult` sur la même page factice. + +**Verify :** `php vendor/bin/phpunit tests/Unit/Visual/PageSnapshotTest.php` → OK (8 tests) + +**Steps :** + +- [ ] **Step 1 : tests qui échouent** + +Dans `tests/Unit/Visual/PageSnapshotTest.php` : + +- ajoute, sous la propriété `$rec` : + +```php + /** @var \Closure(array): object fabrique de navigateur du dernier snapshot() (pour obtenir une page factice) */ + private \Closure $factory; +``` + +- dans `snapshot()`, remplace la dernière ligne + +```php + return new PageSnapshot($factory, stableTimeoutMs: 50, pollMs: 10); +``` + +par : + +```php + $this->factory = $factory; + + return new PageSnapshot($factory, stableTimeoutMs: 50, pollMs: 10); +``` + +- ajoute à la fin de la classe : + +```php + public function test_capture_current_captures_the_open_page_without_navigating_or_closing(): void + { + $snapshot = $this->snapshot(['status' => 404, 'size' => [1280, 2000]]); + $page = ($this->factory)([])->createPage(); + + $result = $snapshot->captureCurrent($page); + + $this->assertSame(['stable', 'settle', 'status', 'size', 'top', 'map', 'screenshot'], $this->rec['log']); + $this->assertFalse($this->rec['closed']); + $this->assertSame('JPEGDATA', $result->image); + $this->assertSame('image/jpeg', $result->mime); + $this->assertSame([1280, 2000], [$result->width, $result->height]); + $this->assertSame(404, $result->status); + $this->assertSame('body', $result->elements[0]['selector']); + } + + public function test_take_gives_what_capture_current_gives_after_navigating(): void + { + $taken = $this->snapshot()->take('https://shop.test/', 'desktop'); + + $snapshot = $this->snapshot(); + $current = $snapshot->captureCurrent(($this->factory)([])->createPage()); + + $this->assertEquals($taken, $current); + } +``` + +Lance `php vendor/bin/phpunit tests/Unit/Visual/PageSnapshotTest.php` (sortie vers un fichier) → 2 erreurs : `Call to undefined method …::captureCurrent()`. + +- [ ] **Step 2 : implémentation** + +Dans `src/Visual/PageSnapshot.php`, remplace le bloc de `take()` qui va de + +```php + $stable = $this->waitUntilStable($page); +``` + +jusqu'à + +```php + return new SnapshotResult((string) $image, 'image/jpeg', $width, $height, is_array($elements) ? $elements : [], $stable, $status); +``` + +(inclus) par : + +```php + return $this->captureCurrent($page, $timeoutMs); +``` + +Le `try { $page->navigate(...) } catch` qui précède et le `finally` qui ferme le navigateur restent tels quels. + +Ajoute, juste après la méthode `take()` : + +```php + /** + * Capture de la page déjà ouverte dans $page : stabilité (bornée par + * stableTimeoutMs), animations figées, statut HTTP, carte des éléments et + * capture pleine page en JPEG. Ne navigue pas et ne ferme rien : l'onglet et + * son navigateur appartiennent à l'appelant (take(), ou l'app qui a ouvert + * une page back-office avec VisualTestsSuite::openBackOfficeCheckpoint()). + * + * @param object $page onglet chrome-php (\HeadlessChromium\Page) ; typé object + * pour les doubles de test, comme waitUntilStable() + */ + public function captureCurrent(object $page, int $timeoutMs = 15000): SnapshotResult + { + $stable = $this->waitUntilStable($page); + $page->evaluate(PageScripts::SETTLE_ANIMATIONS)->getReturnValue(); + + $status = (int) $page->evaluate( + "(function(){var n=performance.getEntriesByType('navigation')[0];return n&&n.responseStatus?n.responseStatus:0;})()" + )->getReturnValue(); + // Une page en erreur (404, 500…) est capturée comme les autres : un point de + // contrôle peut légitimement cibler une page d'erreur. Le statut est renvoyé. + + [$width, $height] = $page->evaluate( + '[Math.ceil(document.documentElement.scrollWidth), Math.ceil(document.documentElement.scrollHeight)]' + )->getReturnValue(); + $width = max(1, (int) $width); + $height = max(1, min((int) $height, self::MAX_HEIGHT)); + + $page->evaluate('window.scrollTo(0, 0)')->getReturnValue(); + $json = $page->evaluate(PageScripts::ELEMENT_MAP.'('.self::MAX_ELEMENTS.', '.$height.')')->getReturnValue($timeoutMs); + $elements = is_string($json) ? json_decode($json, true) : null; + + $image = base64_decode((string) $page->screenshot([ + 'format' => 'jpeg', + 'quality' => self::JPEG_QUALITY, + 'captureBeyondViewport' => true, + 'clip' => new Clip(0, 0, $width, $height), + ])->getBase64($timeoutMs), true); + + return new SnapshotResult((string) $image, 'image/jpeg', $width, $height, is_array($elements) ? $elements : [], $stable, $status); + } +``` + +Mets à jour le docblock de la classe : remplace la phrase « Navigateur DÉDIÉ (jamais l'instance statique de TestsSuite) : une capture ne doit pas perturber un run en cours, et inversement. » par : + +```php + * take() : navigateur DÉDIÉ (jamais l'instance statique de TestsSuite) : une + * capture ne doit pas perturber un run en cours, et inversement. + * captureCurrent() : page déjà ouverte par l'appelant (sélecteur visuel BO de + * l'app, navigateur à portée « picker-… »). +``` + +- [ ] **Step 3 : vérifier** + +```bash +php vendor/bin/phpunit tests/Unit/Visual/PageSnapshotTest.php > /private/tmp/claude-501/-Users-jonathan-Documents-GitHub-PrestaFlow-app/2393cd17-28d4-44c5-96f2-fa24f981a8c8/scratchpad/t1.txt 2>&1; tail -3 /private/tmp/claude-501/-Users-jonathan-Documents-GitHub-PrestaFlow-app/2393cd17-28d4-44c5-96f2-fa24f981a8c8/scratchpad/t1.txt +php vendor/bin/phpunit > /private/tmp/claude-501/-Users-jonathan-Documents-GitHub-PrestaFlow-app/2393cd17-28d4-44c5-96f2-fa24f981a8c8/scratchpad/full1.txt 2>&1; tail -3 /private/tmp/claude-501/-Users-jonathan-Documents-GitHub-PrestaFlow-app/2393cd17-28d4-44c5-96f2-fa24f981a8c8/scratchpad/full1.txt +``` + +Expected : `PageSnapshotTest` OK (8 tests) ; suite complète verte (nombre de départ + 2). + +- [ ] **Step 4 : commit** + +```bash +git add src/Visual/PageSnapshot.php tests/Unit/Visual/PageSnapshotTest.php +git commit -m "feat(visual): PageSnapshot::captureCurrent capture la page déjà ouverte ; take() l'appelle après sa navigation + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 2 : plafonds de navigation et de connexion, sans changer le run + +**Goal :** l'appelant peut borner la navigation (`CommonPage::$navigationTimeout`) et l'attente de l'issue de connexion (`Login\Page::$loginOutcomeTimeout`), et savoir si l'issue a été vue (`$loginOutcomeSeen`). Par défaut, rien ne change : 30 s chrome-php et 60 s. + +**Files :** +- Modify : `src/Pages/CommonPage.php` (propriété après `public string $pageTitle = '';`, l.~45 ; `goToUrl()` l.~912) +- Modify : `src/Pages/BackOfficePage.php` (`goToPage()` l.50, `goToMenu()` l.207) +- Modify : `src/Pages/v9/BackOffice/Login/Page.php` (propriétés en tête de classe ; `login()` l.57) +- Modify : `tests/Unit/Pages/BackOfficeGoToMenuTest.php` +- Modify : `tests/Unit/Pages/BackOfficeLoginOutcomeTest.php` + +**Acceptance Criteria :** +- [ ] `public ?int $navigationTimeout = null;` sur `CommonPage`, transmis comme 2e argument de `waitForNavigation()` par `goToUrl()`, `BackOfficePage::goToPage()` et `BackOfficePage::goToMenu()`. `null` = défaut chrome-php (30 s), donc comportement actuel. +- [ ] `public int $loginOutcomeTimeout = 60000;` et `public ?bool $loginOutcomeSeen = null;` sur `v9\BackOffice\Login\Page` (hérités par v7 et v8). +- [ ] `login()` (avec `$waitForNavigation`) appelle `waitForLoginOutcome($this->loginOutcomeTimeout)` et range le booléen renvoyé dans `$loginOutcomeSeen`. +- [ ] Signatures de `login()`, `waitForLoginOutcome()`, `goToPage()`, `goToMenu()` et `goToUrl()` inchangées. + +**Verify :** `php vendor/bin/phpunit tests/Unit/Pages/BackOfficeGoToMenuTest.php tests/Unit/Pages/BackOfficeLoginOutcomeTest.php` → OK + +**Steps :** + +- [ ] **Step 1 : tests de navigation qui échouent** + +Dans `tests/Unit/Pages/BackOfficeGoToMenuTest.php` : + +- ajoute `use PrestaFlow\Library\Tests\TestsSuite;` sous `use PrestaFlow\Library\Pages\BackOfficePage;` ; +- dans la classe anonyme de `page()`, sous `public array $navigated = [];`, ajoute : + +```php + /** Plafond reçu par chaque waitForNavigation() (null = défaut chrome-php). */ + public array $waits = []; +``` + +- dans `navigate()` du double, remplace + +```php + return new class { + public function waitForNavigation($event = null): void + { + } + }; +``` + +par : + +```php + return new class ($this->outer) { + public function __construct(private $outer) + { + } + + public function waitForNavigation($event = null, $timeout = null): void + { + $this->outer->waits[] = $timeout; + } + }; +``` + +- ajoute à la fin de la classe de test : + +```php + public function test_menu_navigation_keeps_the_chrome_default_unless_capped(): void + { + $href = 'http://shop.test/admin-dev/index.php?controller=AdminOrders&token=abc'; + $page = $this->page(['#subtab-AdminOrders' => $href]); + + $page->goToMenu('#subtab-AdminOrders'); + $page->navigationTimeout = 15000; + $page->goToMenu('#subtab-AdminOrders'); + + $this->assertSame([null, 15000], $page->waits); + } + + public function test_url_navigation_keeps_the_chrome_default_unless_capped(): void + { + // goToUrl() réapplique les en-têtes persistants : vides ici, aucun navigateur n'est lancé. + $headers = TestsSuite::$extraHttpHeaders; + TestsSuite::$extraHttpHeaders = []; + try { + $page = $this->page([]); + + $page->goToUrl('http://shop.test/admin-dev/logout'); + $page->navigationTimeout = 5000; + $page->goToUrl('http://shop.test/admin-dev/logout'); + + $this->assertSame([null, 5000], $page->waits); + $this->assertSame(['http://shop.test/admin-dev/logout', 'http://shop.test/admin-dev/logout'], $page->navigated); + } finally { + TestsSuite::$extraHttpHeaders = $headers; + } + } +``` + +Lance `php vendor/bin/phpunit tests/Unit/Pages/BackOfficeGoToMenuTest.php` → les 2 nouveaux tests échouent (`[null, null]` au lieu de `[null, 15000]` / `[null, 5000]`). Une dépréciation « Creation of dynamic property » peut s'afficher : elle disparaît avec l'implémentation. + +- [ ] **Step 2 : tests de connexion qui échouent** + +Dans `tests/Unit/Pages/BackOfficeLoginOutcomeTest.php`, dans `FakeLoginOutcomePage`, sous `public array $log = [];`, ajoute : + +```php + /** Plafond reçu par chaque waitForLoginOutcome(). */ + public array $outcomeTimeouts = []; + + public function waitForLoginOutcome(int $timeout = 60000, int $interval = 200): bool + { + $this->outcomeTimeouts[] = $timeout; + + return parent::waitForLoginOutcome($timeout, $interval); + } +``` + +Ajoute à la fin de `BackOfficeLoginOutcomeTest` : + +```php + public function testLoginKeepsTheRunCeilingByDefault(): void + { + $page = $this->page(); + $page->settlesOnPoll = 1; + + $page->login(); + + $this->assertSame([60000], $page->outcomeTimeouts); + $this->assertTrue($page->loginOutcomeSeen); + } + + public function testLoginUsesTheCeilingSetOnThePage(): void + { + $page = $this->page(); + $page->loginOutcomeTimeout = 150; + $page->settlesOnPoll = 1; + + $page->login(); + + $this->assertSame([150], $page->outcomeTimeouts); + } + + public function testAnOutcomeNotSeenWithinTheCeilingIsRecorded(): void + { + $page = $this->page(); + $page->loginOutcomeTimeout = 150; + $page->neverSettles = true; + + $page->login(); + + $this->assertFalse($page->loginOutcomeSeen); + } +``` + +Lance seulement les deux premiers (le 3e attendrait 60 s avant l'implémentation) : + +```bash +php vendor/bin/phpunit tests/Unit/Pages/BackOfficeLoginOutcomeTest.php --filter 'testLoginKeepsTheRunCeilingByDefault|testLoginUsesTheCeilingSetOnThePage' +``` + +Expected : FAIL. `loginOutcomeSeen` n'existe pas (null au lieu de true), et `[60000]` au lieu de `[150]`. + +- [ ] **Step 3 : implémentation** + +Dans `src/Pages/CommonPage.php`, juste après `public string $pageTitle = '';`, ajoute : + +```php + + /** + * Plafond (ms) des navigations de goToUrl() et, en back-office, de + * goToPage() / goToMenu() ; null = défaut de chrome-php (30 s). Posé puis + * rétabli par VisualTestsSuite::openBackOfficeCheckpoint() pour le + * sélecteur visuel de l'app, qui doit rester sous les 60 s de nginx. + */ + public ?int $navigationTimeout = null; +``` + +Dans `goToUrl()`, remplace : + +```php + $this->getPage()->navigate($url)->waitForNavigation(DomPage::DOM_CONTENT_LOADED); +``` + +par : + +```php + $this->getPage()->navigate($url)->waitForNavigation(DomPage::DOM_CONTENT_LOADED, $this->navigationTimeout); +``` + +Dans `src/Pages/BackOfficePage.php` : +- `goToPage()` : remplace `$this->getPage()->navigate($url)->waitForNavigation(\HeadlessChromium\Page::DOM_CONTENT_LOADED);` par `$this->getPage()->navigate($url)->waitForNavigation(\HeadlessChromium\Page::DOM_CONTENT_LOADED, $this->navigationTimeout);` ; +- `goToMenu()` : remplace `$page->navigate($href)->waitForNavigation(\HeadlessChromium\Page::DOM_CONTENT_LOADED);` par `$page->navigate($href)->waitForNavigation(\HeadlessChromium\Page::DOM_CONTENT_LOADED, $this->navigationTimeout);`. + +`setSingleShopContext()` et `goToSubMenu()` ne changent pas. + +Dans `src/Pages/v9/BackOffice/Login/Page.php`, sous `public string $pageTitle = 'PrestaShop';`, ajoute : + +```php + + /** + * Plafond (ms) de l'attente de l'issue de la connexion dans login(). + * 60 s pour un run (premier tableau de bord lent en CI, voir + * waitForLoginOutcome()) ; le sélecteur visuel de l'app le baisse. + */ + public int $loginOutcomeTimeout = 60000; + + /** + * Issue de la dernière connexion de login() : true = vue (session ouverte + * ou alerte d'erreur), false = plafond atteint sans issue, null = pas encore + * attendue. isLoggedIn() reste le constat de la session. + */ + public ?bool $loginOutcomeSeen = null; +``` + +Dans `login()`, remplace : + +```php + $this->waitForLoginOutcome(); +``` + +par : + +```php + $this->loginOutcomeSeen = $this->waitForLoginOutcome($this->loginOutcomeTimeout); +``` + +- [ ] **Step 4 : vérifier** + +```bash +php vendor/bin/phpunit tests/Unit/Pages/BackOfficeGoToMenuTest.php tests/Unit/Pages/BackOfficeLoginOutcomeTest.php > /private/tmp/claude-501/-Users-jonathan-Documents-GitHub-PrestaFlow-app/2393cd17-28d4-44c5-96f2-fa24f981a8c8/scratchpad/t2.txt 2>&1; tail -3 /private/tmp/claude-501/-Users-jonathan-Documents-GitHub-PrestaFlow-app/2393cd17-28d4-44c5-96f2-fa24f981a8c8/scratchpad/t2.txt +php vendor/bin/phpunit > /private/tmp/claude-501/-Users-jonathan-Documents-GitHub-PrestaFlow-app/2393cd17-28d4-44c5-96f2-fa24f981a8c8/scratchpad/full2.txt 2>&1; tail -3 /private/tmp/claude-501/-Users-jonathan-Documents-GitHub-PrestaFlow-app/2393cd17-28d4-44c5-96f2-fa24f981a8c8/scratchpad/full2.txt +"$HOME/Library/Application Support/Herd/bin/php81" vendor/bin/phpunit tests/Unit/Pages > /private/tmp/claude-501/-Users-jonathan-Documents-GitHub-PrestaFlow-app/2393cd17-28d4-44c5-96f2-fa24f981a8c8/scratchpad/t2-81.txt 2>&1; tail -3 /private/tmp/claude-501/-Users-jonathan-Documents-GitHub-PrestaFlow-app/2393cd17-28d4-44c5-96f2-fa24f981a8c8/scratchpad/t2-81.txt +``` + +Expected : OK partout ; suite complète = Task 1 + 5 tests, sans dépréciation nouvelle. + +- [ ] **Step 5 : commit** + +```bash +git add src/Pages/CommonPage.php src/Pages/BackOfficePage.php src/Pages/v9/BackOffice/Login/Page.php tests/Unit/Pages/BackOfficeGoToMenuTest.php tests/Unit/Pages/BackOfficeLoginOutcomeTest.php +git commit -m "feat(pages): plafonds de navigation et d'issue de connexion réglables par page, défauts du run inchangés + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 3 : `openBackOfficeCheckpoint()`, `closeBackOfficeSession()` et `BackOfficeTimeoutException` + +**Goal :** l'app ouvre, dans le navigateur courant, la page d'un checkpoint BO avec le code du run, sous des plafonds courts, puis referme la session. + +**Files :** +- Create : `src/Exceptions/BackOfficeTimeoutException.php` +- Modify : `src/Tests/VisualTestsSuite.php` (imports l.5-7 ; propriété `$boLoginCause` après `$boLoggedIn` l.52 ; `init()` l.~278 ; `ensureBackOfficeLogin()` l.403-435 ; `whereIs()` l.441-446 ; nouvelles méthodes après `ensureBackOfficeLogin()`) +- Modify : `tests/Unit/Visual/VisualTestsSuiteBackOfficeTest.php` (doubles `page()` l.86-109 et `login()` l.111-127 ; tests ajoutés) + +**Acceptance Criteria :** +- [ ] `openBackOfficeCheckpoint(array $checkpoint, int $loginTimeoutMs = 25000, int $menuTimeoutMs = 15000): string` : + - `\LogicException` hors `bo`, et `\InvalidArgumentException` pour `auth => false` + `menu`, sans navigation ; + - `auth` vrai : `ensureBackOfficeLogin()`, puis `goToPage('index')`, puis `goToMenu($menu)` si `menu` ; + - `auth => false` : `goToPage('index')` seulement, et l'erreur « Session back-office déjà ouverte… » si le formulaire est absent ; + - plafonds posés pendant l'appel (`navigationTimeout` des deux pages = `$menuTimeoutMs`, `loginOutcomeTimeout` = `$loginTimeoutMs`) et rétablis ensuite, même en cas d'exception ; + - issue de connexion non vue, ou `OperationTimedOut` / `TimeoutException` dans la chaîne des causes : `BackOfficeTimeoutException` (message lisible, avec la durée en secondes) ; + - autres erreurs : `\RuntimeException` avec le message du run (identifiants refusés + message du formulaire, page inattendue + chemin, entrée de menu introuvable) ; + - renvoie le chemin et le contrôleur de la page ouverte, sans jeton. +- [ ] `closeBackOfficeSession(int $timeoutMs = 5000): void` appelle `logout()` de la page Login une seule fois si une session est ouverte, sous le plafond donné (rétabli ensuite), et ne lève jamais. +- [ ] Les tests existants de `VisualTestsSuiteBackOfficeTest` passent sans changement de leurs assertions (journaux identiques). + +**Verify :** `php vendor/bin/phpunit tests/Unit/Visual/VisualTestsSuiteBackOfficeTest.php` → OK + +**Steps :** + +- [ ] **Step 1 : doubles enrichis (sans changer les journaux)** + +Dans `tests/Unit/Visual/VisualTestsSuiteBackOfficeTest.php` : + +- imports, sous `use PHPUnit\Framework\TestCase;` : + +```php +use HeadlessChromium\Exception\OperationTimedOut; +use PrestaFlow\Library\Exceptions\BackOfficeTimeoutException; +``` + +- remplace la méthode `page()` entière par : + +```php + /** Page factice : journalise navigation et captures dans un journal partagé. */ + private function page(\ArrayObject $log): object + { + return new class ($log, $this->chrome) { + public array $calls = []; + public ?string $failOn = null; + /** Plafond de navigation posé par la suite (CommonPage::$navigationTimeout). */ + public ?int $navigationTimeout = null; + /** Plafond vu à chaque navigation. */ + public array $navTimeouts = []; + public ?\Throwable $navThrows = null; + public ?\Throwable $menuThrows = null; + public function __construct(public \ArrayObject $log, public object $chrome) {} + public function getPage(): object { return $this->chrome; } + public function goToPage($page = null, $params = null): void + { + $this->log[] = 'page '.$page; + $this->navTimeouts[] = $this->navigationTimeout; + if ($this->navThrows !== null) { + throw $this->navThrows; + } + } + public function goToMenu(string $selectors): string + { + $this->log[] = 'menu '.$selectors; + $this->navTimeouts[] = $this->navigationTimeout; + if ($this->menuThrows !== null) { + throw $this->menuThrows; + } + + return 'http://shop.test/admin-dev/x?token=t'; + } + public function goToUrl(string $url): void { $this->log[] = 'goto '.$url; } + public function waitForStable(): bool { $this->log[] = 'stable'; return true; } + public function waitVisible(string $s): void {} + public function scrollBelow(string $s): void { $this->log[] = 'scrollBelow '.$s; } + public function scrollToTop(): void { $this->log[] = 'top'; } + public function visualCheckpoint(string $name, ?string $selector = null, ?float $threshold = null, bool $fullPage = true, string $tag = 'auto', array $masks = [], ?int $maxDiffPixels = null, array $hide = [], bool $freezeTransitions = false): void + { + $this->log[] = 'capture '.$name; + $this->calls[$name] = [$hide, $freezeTransitions]; + if ($this->failOn === $name) { + throw new \RuntimeException('capture ratée'); + } + } + }; + } +``` + +- remplace la méthode `login()` entière par : + +```php + private function login(\ArrayObject $log, bool $ok = true): object + { + // Ne navigue pas l'onglet partagé : la présence du formulaire est fixée par + // le test ($this->chrome->form), comme l'afficherait la racine du BO pour la + // session courante. Suffisant : la suite ne la lit qu'après une navigation. + return new class ($log, $ok, $this->chrome) { + /** Plafonds posés par la suite (CommonPage::$navigationTimeout, Login\Page::$loginOutcomeTimeout). */ + public ?int $navigationTimeout = null; + public int $loginOutcomeTimeout = 60000; + public ?bool $loginOutcomeSeen = null; + /** Issue vue par login() dans le plafond (false = délai). */ + public bool $outcome = true; + public bool $logoutThrows = false; + public ?\Throwable $navThrows = null; + public array $navTimeouts = []; + public array $outcomeTimeouts = []; + public array $logoutTimeouts = []; + public function __construct(public \ArrayObject $log, public bool $ok, public object $chrome) {} + public function getPage(): object { return $this->chrome; } + public function getSelector($selector, $replacements = []): string + { + return ['emailInput' => '#email', 'alertDangerDiv' => '.alert-danger'][$selector]; + } + public function goToPage($page = null, $params = null): void + { + $this->log[] = 'login:page '.$page; + $this->navTimeouts[] = $this->navigationTimeout; + if ($this->navThrows !== null) { + throw $this->navThrows; + } + } + public function login($email = null, $password = null, $waitForNavigation = true): void + { + $this->log[] = 'login:submit'; + $this->outcomeTimeouts[] = $this->loginOutcomeTimeout; + $this->loginOutcomeSeen = $this->outcome; + } + public function isLoggedIn(): bool { $this->log[] = 'login:check'; return $this->ok; } + public function logout(): void + { + $this->log[] = 'login:logout'; + $this->logoutTimeouts[] = $this->navigationTimeout; + if ($this->logoutThrows) { + throw new \RuntimeException('Cannot log out: no logout link found at "#header_logout".'); + } + } + }; + } +``` + +Lance `php vendor/bin/phpunit tests/Unit/Visual/VisualTestsSuiteBackOfficeTest.php` → OK, mêmes tests verts qu'avant (les journaux n'ont pas changé). + +- [ ] **Step 2 : tests qui échouent** + +Ajoute à la fin de `VisualTestsSuiteBackOfficeTest` : + +```php + public function test_open_checkpoint_logs_in_then_opens_the_dashboard_then_the_menu(): void + { + $this->chrome->location = '/admin-dev/index.php?controller=AdminProducts'; + $log = new \ArrayObject(); + $page = $this->page($log); + $login = $this->login($log); + $s = $this->suite(page: $page, login: $login); + + $where = $s->openBackOfficeCheckpoint(['name' => 'picker', 'menu' => '#subtab-AdminProducts'], 25000, 15000); + + $this->assertSame([ + 'login:page index', 'login:submit', 'login:check', + 'page index', 'menu #subtab-AdminProducts', + ], $log->getArrayCopy()); + // Chemin et contrôleur, jamais le jeton. + $this->assertSame('/admin-dev/index.php?controller=AdminProducts', $where); + // Plafonds pendant l'ouverture, valeurs du run rétablies ensuite. + $this->assertSame([25000], $login->outcomeTimeouts); + $this->assertSame([15000], $login->navTimeouts); + $this->assertSame([15000, 15000], $page->navTimeouts); + $this->assertNull($page->navigationTimeout); + $this->assertNull($login->navigationTimeout); + $this->assertSame(60000, $login->loginOutcomeTimeout); + } + + public function test_open_checkpoint_without_menu_stays_on_the_dashboard(): void + { + $log = new \ArrayObject(); + $s = $this->suite(page: $this->page($log), login: $this->login($log)); + + $s->openBackOfficeCheckpoint(['name' => 'picker', 'menu' => null]); + + $this->assertSame(['login:page index', 'login:submit', 'login:check', 'page index'], $log->getArrayCopy()); + } + + public function test_open_checkpoint_without_auth_opens_the_login_page_without_logging_in(): void + { + $log = new \ArrayObject(); + $s = $this->suite(page: $this->page($log), login: $this->login($log)); + + $s->openBackOfficeCheckpoint(['name' => 'picker', 'auth' => false]); + + $this->assertSame(['page index'], $log->getArrayCopy()); + } + + public function test_open_checkpoint_without_auth_fails_when_a_session_is_open(): void + { + $this->chrome->form = false; + $log = new \ArrayObject(); + $s = $this->suite(page: $this->page($log), login: $this->login($log)); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Session back-office déjà ouverte : la page de connexion ne peut pas être capturée'); + $s->openBackOfficeCheckpoint(['name' => 'picker', 'auth' => false]); + } + + public function test_open_checkpoint_reports_refused_credentials_like_the_run(): void + { + $this->chrome->error = ' The employee does not exist. '; + $log = new \ArrayObject(); + $s = $this->suite(page: $this->page($log), login: $this->login($log, ok: false)); + + try { + $s->openBackOfficeCheckpoint(['name' => 'picker', 'menu' => '#subtab-AdminProducts']); + $this->fail('RuntimeException attendue'); + } catch (BackOfficeTimeoutException $e) { + $this->fail('Pas un délai : '.$e->getMessage()); + } catch (\RuntimeException $e) { + $this->assertSame('Connexion au back-office impossible : identifiants refusés ou page inattendue : The employee does not exist.', $e->getMessage()); + } + $this->assertNotContains('page index', $log->getArrayCopy()); + } + + public function test_open_checkpoint_reports_a_missing_menu_entry_like_the_run(): void + { + $log = new \ArrayObject(); + $page = $this->page($log); + $page->menuThrows = new \RuntimeException('Entrée du menu introuvable : #subtab-Nope'); + $s = $this->suite(page: $page, login: $this->login($log)); + + try { + $s->openBackOfficeCheckpoint(['name' => 'picker', 'menu' => '#subtab-Nope']); + $this->fail('RuntimeException attendue'); + } catch (BackOfficeTimeoutException $e) { + $this->fail('Pas un délai : '.$e->getMessage()); + } catch (\RuntimeException $e) { + $this->assertSame('Entrée du menu introuvable : #subtab-Nope', $e->getMessage()); + } + $this->assertNull($page->navigationTimeout); + } + + public function test_open_checkpoint_turns_an_unseen_login_outcome_into_a_timeout(): void + { + $log = new \ArrayObject(); + $login = $this->login($log, ok: false); + $login->outcome = false; // ni lien de déconnexion ni alerte dans le plafond + $s = $this->suite(page: $this->page($log), login: $login); + + try { + $s->openBackOfficeCheckpoint(['name' => 'picker'], 25000, 15000); + $this->fail('BackOfficeTimeoutException attendue'); + } catch (BackOfficeTimeoutException $e) { + $this->assertInstanceOf(\RuntimeException::class, $e); + $this->assertStringContainsString('connexion en 25 s', $e->getMessage()); + } + $this->assertSame(60000, $login->loginOutcomeTimeout); + $this->assertNull($login->navigationTimeout); + } + + public function test_open_checkpoint_turns_a_navigation_timeout_into_a_timeout(): void + { + $log = new \ArrayObject(); + $page = $this->page($log); + $page->navThrows = new OperationTimedOut('Operation timed out after 15s.'); + $s = $this->suite(page: $page, login: $this->login($log)); + + try { + $s->openBackOfficeCheckpoint(['name' => 'picker'], 25000, 15000); + $this->fail('BackOfficeTimeoutException attendue'); + } catch (BackOfficeTimeoutException $e) { + $this->assertStringContainsString('15 s', $e->getMessage()); + $this->assertInstanceOf(OperationTimedOut::class, $e->getPrevious()); + } + $this->assertNull($page->navigationTimeout); + } + + public function test_open_checkpoint_turns_a_login_page_timeout_into_a_timeout(): void + { + // Le délai survient dans ensureBackOfficeLogin(), qui le relève avec sa cause. + $log = new \ArrayObject(); + $login = $this->login($log); + $login->navThrows = new OperationTimedOut('Operation timed out after 15s.'); + $s = $this->suite(page: $this->page($log), login: $login); + + $this->expectException(BackOfficeTimeoutException::class); + $this->expectExceptionMessage('15 s'); + $s->openBackOfficeCheckpoint(['name' => 'picker'], 25000, 15000); + } + + public function test_open_checkpoint_is_refused_outside_the_back_office(): void + { + $log = new \ArrayObject(); + $s = $this->suite('fo', page: $this->page($log)); + + try { + $s->openBackOfficeCheckpoint(['name' => 'picker']); + $this->fail('LogicException attendue'); + } catch (\LogicException $e) { + $this->assertStringContainsString('« bo »', $e->getMessage()); + } + $this->assertSame([], $log->getArrayCopy()); + } + + public function test_open_checkpoint_refuses_a_login_page_with_a_menu(): void + { + $log = new \ArrayObject(); + $s = $this->suite(page: $this->page($log), login: $this->login($log)); + + try { + $s->openBackOfficeCheckpoint(['name' => 'picker', 'auth' => false, 'menu' => '#subtab-AdminOrders']); + $this->fail('InvalidArgumentException attendue'); + } catch (\InvalidArgumentException $e) { + $this->assertStringContainsString('menu interdit', $e->getMessage()); + } + $this->assertSame([], $log->getArrayCopy()); + } + + public function test_close_session_logs_out_once_under_its_ceiling(): void + { + $log = new \ArrayObject(); + $login = $this->login($log); + $s = $this->suite(page: $this->page($log), login: $login); + $s->openBackOfficeCheckpoint(['name' => 'picker']); + + $s->closeBackOfficeSession(5000); + $s->closeBackOfficeSession(5000); + + $this->assertSame(1, count(array_keys($log->getArrayCopy(), 'login:logout'))); + $this->assertSame([5000], $login->logoutTimeouts); + $this->assertNull($login->navigationTimeout); + } + + public function test_close_session_without_session_does_nothing(): void + { + $log = new \ArrayObject(); + $s = $this->suite(page: $this->page($log), login: $this->login($log)); + $s->closeBackOfficeSession(); + + $s->openBackOfficeCheckpoint(['name' => 'picker', 'auth' => false]); + $s->closeBackOfficeSession(); + + $this->assertNotContains('login:logout', $log->getArrayCopy()); + } + + public function test_close_session_never_throws(): void + { + $log = new \ArrayObject(); + $login = $this->login($log); + $login->logoutThrows = true; + $s = $this->suite(page: $this->page($log), login: $login); + $s->openBackOfficeCheckpoint(['name' => 'picker']); + + $s->closeBackOfficeSession(); + + $this->assertContains('login:logout', $log->getArrayCopy()); + $this->assertNull($login->navigationTimeout); + } +``` + +Lance `php vendor/bin/phpunit tests/Unit/Visual/VisualTestsSuiteBackOfficeTest.php` → les nouveaux tests échouent : classe `BackOfficeTimeoutException` introuvable, méthodes `openBackOfficeCheckpoint()` / `closeBackOfficeSession()` inexistantes. + +- [ ] **Step 3 : exception de délai** + +Crée `src/Exceptions/BackOfficeTimeoutException.php` : + +```php +boLoginError = null;`, ajoute `$this->boLoginCause = null;`. + +4. Dans `ensureBackOfficeLogin()`, remplace + +```php + } catch (\Throwable $e) { + $this->boLoginError = $e->getMessage(); + } + } + + throw new \RuntimeException('Connexion au back-office impossible : '.$this->boLoginError); +``` + +par : + +```php + } catch (\Throwable $e) { + $this->boLoginError = $e->getMessage(); + $this->boLoginCause = $e; + } + } + + throw new \RuntimeException('Connexion au back-office impossible : '.$this->boLoginError, 0, $this->boLoginCause); +``` + +5. Dans `whereIs()`, remplace + +```php + $where = $this->readNow($page, '(function(){var c=new URLSearchParams(location.search).get("controller");return location.pathname+(c?"?controller="+c:"");})()'); +``` + +par : + +```php + $where = $this->readNow($page, self::LOCATION_JS); +``` + +6. Ajoute, juste après la méthode `ensureBackOfficeLogin()` : + +```php + /** + * Ouvre, dans le navigateur courant (TestsSuite::getPage()), la page d'un + * checkpoint BO avec le code du run : connexion (ensureBackOfficeLogin()), + * racine du BO, puis entrée du menu (goToMenu()) ; `auth => false` : racine + * du BO sans session (page de connexion). Pour le sélecteur visuel de l'app, + * qui capture ensuite la page avec PageSnapshot::captureCurrent(). + * + * $loginTimeoutMs borne l'attente de l'issue de la connexion + * (Login\Page::$loginOutcomeTimeout), $menuTimeoutMs chaque navigation + * (CommonPage::$navigationTimeout). Les valeurs du run sont rétablies avant + * de rendre la main, même en cas d'erreur. + * + * @return string chemin et contrôleur de la page ouverte, sans jeton ('' si illisible) + * + * @throws \LogicException hors zone 'bo' ; \InvalidArgumentException pour auth => false avec un menu + * @throws BackOfficeTimeoutException plafond dépassé (message lisible) + * @throws \RuntimeException erreur du run, message déjà formulé (identifiants refusés, page inattendue, menu introuvable) + */ + public function openBackOfficeCheckpoint(array $checkpoint, int $loginTimeoutMs = 25000, int $menuTimeoutMs = 15000): string + { + if ($this->area !== 'bo') { + throw new \LogicException(sprintf('%s : openBackOfficeCheckpoint() exige la zone « bo » (zone « %s »)', static::class, $this->area)); + } + $cp = self::normalize($checkpoint); + if ($cp['auth'] === false && $cp['menu'] !== null) { + throw new \InvalidArgumentException(sprintf('%s : checkpoint « %s » : auth => false capture la page de connexion, menu interdit', static::class, (string) ($cp['name'] ?? ''))); + } + + // init() n'est pas appelé (il enregistrerait les étapes du run) : pages importées ici. + if (!isset($this->pages['backOfficePage'])) { + $this->importVisualPage(); + } + $page = $this->pages['backOfficePage'] ?? null; + $login = $this->pages['backOfficeLoginPage'] ?? null; + if ($page === null) { + throw new \RuntimeException('page BackOffice absente'); + } + + // Une ouverture = une tentative de connexion, comme une exécution (init()). + $this->boLoggedIn = false; + $this->boLoginError = null; + $this->boLoginCause = null; + $this->lastVisualUrl = null; + + $restore = $this->capBackOfficeWaits($page, $login, $loginTimeoutMs, $menuTimeoutMs); + try { + if ($cp['auth']) { + try { + $this->ensureBackOfficeLogin($login); + } catch (\RuntimeException $e) { + if ($login !== null && ($login->loginOutcomeSeen ?? null) === false) { + throw new BackOfficeTimeoutException(sprintf("Le back-office n'a pas répondu à la connexion en %d s.", intdiv($loginTimeoutMs, 1000)), 0, $e); + } + throw $e; + } + } + $page->goToPage('index'); + if ($cp['menu'] !== null) { + $page->goToMenu($cp['menu']); + } + if (!$cp['auth'] && !$this->loginFormPresent($page, $login)) { + throw new \RuntimeException('Session back-office déjà ouverte : la page de connexion ne peut pas être capturée'); + } + + return $this->locationOf($page); + } catch (BackOfficeTimeoutException $e) { + throw $e; + } catch (\Throwable $e) { + if (self::isTimeout($e)) { + throw new BackOfficeTimeoutException(sprintf("Le back-office n'a pas répondu en %d s (chargement d'une page).", intdiv($menuTimeoutMs, 1000)), 0, $e); + } + if ($e instanceof \RuntimeException || !$e instanceof \Exception) { + throw $e; // erreur du run telle quelle ; un \Error (défaut de code) n'est pas masqué + } + throw new \RuntimeException($e->getMessage(), 0, $e); // exceptions chrome-php (ResponseHasError…) + } finally { + $restore(); + } + } + + /** + * Déconnexion par le lien #header_logout de la page courante + * (BackOffice\Login\Page::logout()) si une session est ouverte. Ne lève + * jamais : un échec de déconnexion ne doit pas faire échouer la capture + * (le navigateur de l'app est fermé juste après). $timeoutMs borne la + * navigation de déconnexion. + */ + public function closeBackOfficeSession(int $timeoutMs = 5000): void + { + if (!$this->boLoggedIn) { + return; + } + $this->boLoggedIn = false; + $login = $this->pages['backOfficeLoginPage'] ?? null; + if ($login === null) { + return; + } + + $before = $login->navigationTimeout ?? null; + try { + $login->navigationTimeout = $timeoutMs; + $login->logout(); + } catch (\Throwable) { + // session laissée ouverte : rien d'autre à faire + } finally { + try { + $login->navigationTimeout = $before; + } catch (\Throwable) { + } + } + } + + /** + * Pose les plafonds de openBackOfficeCheckpoint() sur les pages et renvoie + * de quoi rétablir les valeurs d'avant (celles du run). + */ + private function capBackOfficeWaits(object $page, ?object $login, int $loginTimeoutMs, int $navigationTimeoutMs): \Closure + { + $pageBefore = $page->navigationTimeout ?? null; + $loginBefore = $login?->navigationTimeout ?? null; + $outcomeBefore = $login?->loginOutcomeTimeout ?? null; + + $page->navigationTimeout = $navigationTimeoutMs; + if ($login !== null) { + $login->navigationTimeout = $navigationTimeoutMs; + $login->loginOutcomeTimeout = $loginTimeoutMs; + $login->loginOutcomeSeen = null; + } + + return static function () use ($page, $login, $pageBefore, $loginBefore, $outcomeBefore): void { + $page->navigationTimeout = $pageBefore; + if ($login !== null) { + $login->navigationTimeout = $loginBefore; + if ($outcomeBefore !== null) { + $login->loginOutcomeTimeout = $outcomeBefore; + } + } + }; + } + + /** Délai de chrome-php ou de la lib quelque part dans la chaîne des causes. */ + private static function isTimeout(\Throwable $e): bool + { + for ($t = $e; $t !== null; $t = $t->getPrevious()) { + if ($t instanceof OperationTimedOut || $t instanceof TimeoutException) { + return true; + } + } + + return false; + } + + /** Chemin et contrôleur de la page courante, sans jeton ; '' si illisible. */ + protected function locationOf(object $page): string + { + try { + $where = $this->readNow($page, self::LOCATION_JS); + } catch (\Throwable) { + return ''; + } + + return is_string($where) ? $where : ''; + } +``` + +7. Docblock de la classe (l.9-22) : ajoute à la fin, avant ` */` : + +```php + * + * Sélecteur visuel de l'app : openBackOfficeCheckpoint() ouvre la page d'un + * checkpoint BO avec ce même code (connexion, menu), sous des plafonds courts ; + * closeBackOfficeSession() referme la session employé. +``` + +- [ ] **Step 5 : vérifier** + +```bash +php vendor/bin/phpunit tests/Unit/Visual/VisualTestsSuiteBackOfficeTest.php > /private/tmp/claude-501/-Users-jonathan-Documents-GitHub-PrestaFlow-app/2393cd17-28d4-44c5-96f2-fa24f981a8c8/scratchpad/t3.txt 2>&1; tail -3 /private/tmp/claude-501/-Users-jonathan-Documents-GitHub-PrestaFlow-app/2393cd17-28d4-44c5-96f2-fa24f981a8c8/scratchpad/t3.txt +php vendor/bin/phpunit > /private/tmp/claude-501/-Users-jonathan-Documents-GitHub-PrestaFlow-app/2393cd17-28d4-44c5-96f2-fa24f981a8c8/scratchpad/full3.txt 2>&1; tail -3 /private/tmp/claude-501/-Users-jonathan-Documents-GitHub-PrestaFlow-app/2393cd17-28d4-44c5-96f2-fa24f981a8c8/scratchpad/full3.txt +"$HOME/Library/Application Support/Herd/bin/php81" vendor/bin/phpunit > /private/tmp/claude-501/-Users-jonathan-Documents-GitHub-PrestaFlow-app/2393cd17-28d4-44c5-96f2-fa24f981a8c8/scratchpad/full3-81.txt 2>&1; tail -3 /private/tmp/claude-501/-Users-jonathan-Documents-GitHub-PrestaFlow-app/2393cd17-28d4-44c5-96f2-fa24f981a8c8/scratchpad/full3-81.txt +git diff --stat dev -- src/Tests/VisualTestsSuite.php +``` + +Expected : +- `VisualTestsSuiteBackOfficeTest` OK, dont `test_unexpected_page_reports_where_the_browser_is` (vérifie `LOCATION_JS`) et `test_failed_login_fails_every_logged_in_checkpoint_without_retrying` (message inchangé) ; +- suite complète verte en 8.4 et en 8.1 (Task 2 + 14 tests). + +- [ ] **Step 6 : commit** + +```bash +git add src/Exceptions/BackOfficeTimeoutException.php src/Tests/VisualTestsSuite.php tests/Unit/Visual/VisualTestsSuiteBackOfficeTest.php +git commit -m "feat(visual): openBackOfficeCheckpoint ouvre la page BO d'un checkpoint comme le run, sous plafonds ; closeBackOfficeSession referme la session + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 4 : livraison (coordinateur) + +- [ ] Relecture finale (`rtk proxy git diff dev...HEAD > /private/tmp/claude-501/-Users-jonathan-Documents-GitHub-PrestaFlow-app/2393cd17-28d4-44c5-96f2-fa24f981a8c8/scratchpad/lib-branch.diff`). Points à vérifier : + - `take()` : même ordre d'appels, même message de délai de navigation, `finally` qui ferme le navigateur ; + - aucun défaut changé : `waitForLoginOutcome()` 60 s, `navigationTimeout` null ; + - `ensureBackOfficeLogin()` : seul changement = la cause en `previous` ; + - les noms partagés avec le plan de l'app (section « Contexte ») sont exacts. +- [ ] Suite complète verte en 8.4 et en 8.1 (sorties redirigées). +- [ ] Push de `feat/bo-picker-snapshot`, PR vers `dev` (titre : « Capture du sélecteur visuel en back-office »), description terminée par la ligne d'attribution prévue. CI verte. +- [ ] Sur demande seulement, dans cet ordre : + 1. merge de la PR en **commit de merge** (`gh pr merge --merge`), sujet « (#N) » ; + 2. release : PR `dev` → `main` intitulée « Release: capture du sélecteur visuel en back-office (#N) », mergée en **commit de merge** (`gh pr merge --merge`). **Jamais de rebase ni de squash** : une release rebasée réécrit les SHA de `main` et met la release suivante en conflit (incident #113/#115) ; + 3. signaler au coordinateur de l'app que la tâche 0 de son plan (`composer update prestaflow/php-library`, seul `composer.lock` change) peut commencer. + +--- + +## Auto-relecture : couverture de la spec + +| Section de la spec | Tâche(s) | +|---|---| +| `captureCurrent()` : tout ce que `take()` fait après la navigation (stabilité, `SETTLE_ANIMATIONS`, statut, dimensions plafonnées, `ELEMENT_MAP`, JPEG) | 1 | +| `take()` = navigateur + navigation + `captureCurrent()`, comportement et résultat inchangés (tests existants) | 1 (6 tests existants intacts + test d'égalité) | +| `openBackOfficeCheckpoint()` publique, navigateur courant, `normalize()` | 3 | +| `auth: false` : racine du BO, erreur « session déjà ouverte » | 3 | +| sinon `ensureBackOfficeLogin()`, `goToPage('index')`, `goToMenu()` si `menu` | 3 | +| Plafonds connexion (`waitForLoginOutcome`) et navigation, exception de délai dédiée au message lisible | 2 (propriétés), 3 (`BackOfficeTimeoutException`) ; écarts 2, 3, 5, 8 | +| Erreurs du run en `\RuntimeException` avec le message du run | 3 (identifiants refusés, menu introuvable, page inattendue via `ensureBackOfficeLogin`) | +| `area === 'bo'` exigée, sinon `\LogicException` | 3 | +| `closeBackOfficeSession()` : `logout()` si session ouverte, ne lève jamais | 3 ; écart 7 | +| Aucune autre méthode du run ne change ; `ensureBackOfficeLogin` / `goToMenu` seule implémentation | 2, 3 ; écarts 2 et 4 (cause en `previous`, `LOCATION_JS`), sans changement de comportement | +| Tests : `captureCurrent` avec les doubles de `PageSnapshotTest` | 1 | +| Tests : ordre connexion → tableau de bord → menu ; `auth: false` ; identifiants refusés ; menu introuvable ; délai ; refus hors BO | 3 | +| Tests : `closeBackOfficeSession` appelle `logout()` si session, ne lève jamais | 3 | +| Livraison : PR → `dev` (merge commit), release `dev` → `main` (merge commit), bump app (`composer.lock` seul) | 4 (bump : tâche 0 du plan de l'app) | diff --git a/docs/superpowers/plans/2026-10-06-bo-picker-snapshot.md.tasks.json b/docs/superpowers/plans/2026-10-06-bo-picker-snapshot.md.tasks.json new file mode 100644 index 0000000..55c925b --- /dev/null +++ b/docs/superpowers/plans/2026-10-06-bo-picker-snapshot.md.tasks.json @@ -0,0 +1,39 @@ +{ + "planPath": "docs/superpowers/plans/2026-10-06-bo-picker-snapshot.md", + "tasks": [ + { + "id": 1, + "subject": "Task 1 : `PageSnapshot::captureCurrent()`", + "status": "pending", + "description": "Voir le plan, section « Task 1 : `PageSnapshot::captureCurrent()` ».\n\n```json:metadata\n{\"files\": [\"src/Visual/PageSnapshot.php\", \"tests/Unit/Visual/PageSnapshotTest.php\"], \"verifyCommand\": \"`php vendor/bin/phpunit tests/Unit/Visual/PageSnapshotTest.php` → OK (8 tests)\", \"acceptanceCriteria\": [\"`captureCurrent(object $page, int $timeoutMs = 15000): SnapshotResult` fait, dans l'ordre : stabilité, `SETTLE_ANIMATIONS`, statut, dimensions (hauteur plafonnée à `MAX_HEIGHT`), `scrollTo(0, 0)`, `ELEMENT_MAP`, capture JPEG qualité 80.\", \"Elle ne navigue pas et ne ferme pas le navigateur.\", \"`take()` crée le navigateur, navigue (même message d'erreur), appelle `captureCurrent($page, $timeoutMs)` et ferme le navigateur dans son `finally`.\", \"Les 6 tests existants de `PageSnapshotTest` passent sans modification de leurs assertions.\", \"`take()` et `captureCurrent()` donnent le même `SnapshotResult` sur la même page factice.\"]}\n```" + }, + { + "id": 2, + "subject": "Task 2 : plafonds de navigation et de connexion, sans changer le run", + "status": "pending", + "description": "Voir le plan, section « Task 2 : plafonds de navigation et de connexion, sans changer le run ».\n\n```json:metadata\n{\"files\": [\"src/Pages/CommonPage.php\", \"src/Pages/BackOfficePage.php\", \"src/Pages/v9/BackOffice/Login/Page.php\", \"tests/Unit/Pages/BackOfficeGoToMenuTest.php\", \"tests/Unit/Pages/BackOfficeLoginOutcomeTest.php\"], \"verifyCommand\": \"`php vendor/bin/phpunit tests/Unit/Pages/BackOfficeGoToMenuTest.php tests/Unit/Pages/BackOfficeLoginOutcomeTest.php` → OK\", \"acceptanceCriteria\": [\"`public ?int $navigationTimeout = null;` sur `CommonPage`, transmis comme 2e argument de `waitForNavigation()` par `goToUrl()`, `BackOfficePage::goToPage()` et `BackOfficePage::goToMenu()`. `null` = défaut chrome-php (30 s), donc comportement actuel.\", \"`public int $loginOutcomeTimeout = 60000;` et `public ?bool $loginOutcomeSeen = null;` sur `v9\\\\BackOffice\\\\Login\\\\Page` (hérités par v7 et v8).\", \"`login()` (avec `$waitForNavigation`) appelle `waitForLoginOutcome($this->loginOutcomeTimeout)` et range le booléen renvoyé dans `$loginOutcomeSeen`.\", \"Signatures de `login()`, `waitForLoginOutcome()`, `goToPage()`, `goToMenu()` et `goToUrl()` inchangées.\"]}\n```", + "blockedBy": [ + 1 + ] + }, + { + "id": 3, + "subject": "Task 3 : `openBackOfficeCheckpoint()`, `closeBackOfficeSession()` et `BackOfficeTimeoutException`", + "status": "pending", + "description": "Voir le plan, section « Task 3 : `openBackOfficeCheckpoint()`, `closeBackOfficeSession()` et `BackOfficeTimeoutException` ».\n\n```json:metadata\n{\"files\": [\"src/Exceptions/BackOfficeTimeoutException.php\", \"src/Tests/VisualTestsSuite.php\", \"tests/Unit/Visual/VisualTestsSuiteBackOfficeTest.php\"], \"verifyCommand\": \"`php vendor/bin/phpunit tests/Unit/Visual/VisualTestsSuiteBackOfficeTest.php` → OK\", \"acceptanceCriteria\": [\"`openBackOfficeCheckpoint(array $checkpoint, int $loginTimeoutMs = 25000, int $menuTimeoutMs = 15000): string` :\", \"`closeBackOfficeSession(int $timeoutMs = 5000): void` appelle `logout()` de la page Login une seule fois si une session est ouverte, sous le plafond donné (rétabli ensuite), et ne lève jamais.\", \"Les tests existants de `VisualTestsSuiteBackOfficeTest` passent sans changement de leurs assertions (journaux identiques).\"]}\n```", + "blockedBy": [ + 2 + ] + }, + { + "id": 4, + "subject": "Task 4 : livraison (coordinateur)", + "status": "pending", + "description": "Voir le plan, section « Task 4 : livraison (coordinateur) ».\n\n```json:metadata\n{\"files\": [], \"verifyCommand\": \"\", \"acceptanceCriteria\": [\"Relecture finale (`rtk proxy git diff dev...HEAD > /private/tmp/claude-501/-Users-jonathan-Documents-GitHub-PrestaFlow-app/2393cd17-28d4-44c5-96f2-fa24f981a8c8/scratchpad/lib-branch.diff`). Points à vérifier :\", \"Suite complète verte en 8.4 et en 8.1 (sorties redirigées).\", \"Push de `feat/bo-picker-snapshot`, PR vers `dev` (titre : « Capture du sélecteur visuel en back-office »), description terminée par la ligne d'attribution prévue. CI verte.\", \"Sur demande seulement, dans cet ordre :\"]}\n```", + "blockedBy": [ + 3 + ] + } + ], + "lastUpdated": "2026-10-06T13:19:01.564365+00:00" +} \ No newline at end of file From 413a686954e2d71e9ac9abd28e0c07a4a5b65aa2 Mon Sep 17 00:00:00 2001 From: Jonathan Danse Date: Tue, 6 Oct 2026 15:24:16 +0200 Subject: [PATCH 03/10] =?UTF-8?q?feat(visual):=20PageSnapshot::captureCurr?= =?UTF-8?q?ent=20capture=20la=20page=20d=C3=A9j=C3=A0=20ouverte=20;=20take?= =?UTF-8?q?()=20l'appelle=20apr=C3=A8s=20sa=20navigation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/Visual/PageSnapshot.php | 75 ++++++++++++++++---------- tests/Unit/Visual/PageSnapshotTest.php | 31 +++++++++++ 2 files changed, 77 insertions(+), 29 deletions(-) diff --git a/src/Visual/PageSnapshot.php b/src/Visual/PageSnapshot.php index 23eadb1..dca3108 100644 --- a/src/Visual/PageSnapshot.php +++ b/src/Visual/PageSnapshot.php @@ -11,8 +11,10 @@ * (animations figées, comme au run ; bien plus léger qu'un PNG pleine page) * + carte des éléments visibles avec un sélecteur CSS proposé. * - * Navigateur DÉDIÉ (jamais l'instance statique de TestsSuite) : une capture ne - * doit pas perturber un run en cours, et inversement. + * take() : navigateur DÉDIÉ (jamais l'instance statique de TestsSuite) : une + * capture ne doit pas perturber un run en cours, et inversement. + * captureCurrent() : page déjà ouverte par l'appelant (sélecteur visuel BO de + * l'app, navigateur à portée « picker-… »). */ class PageSnapshot { @@ -65,33 +67,7 @@ public function take(string $url, string $device = 'desktop', int $timeoutMs = 2 throw new SnapshotException(sprintf("La page %s n'a pas répondu en %d s.", $url, intdiv($timeoutMs, 1000)), 0, $e); } - $stable = $this->waitUntilStable($page); - $page->evaluate(PageScripts::SETTLE_ANIMATIONS)->getReturnValue(); - - $status = (int) $page->evaluate( - "(function(){var n=performance.getEntriesByType('navigation')[0];return n&&n.responseStatus?n.responseStatus:0;})()" - )->getReturnValue(); - // Une page en erreur (404, 500…) est capturée comme les autres : un point de - // contrôle peut légitimement cibler une page d'erreur. Le statut est renvoyé. - - [$width, $height] = $page->evaluate( - '[Math.ceil(document.documentElement.scrollWidth), Math.ceil(document.documentElement.scrollHeight)]' - )->getReturnValue(); - $width = max(1, (int) $width); - $height = max(1, min((int) $height, self::MAX_HEIGHT)); - - $page->evaluate('window.scrollTo(0, 0)')->getReturnValue(); - $json = $page->evaluate(PageScripts::ELEMENT_MAP.'('.self::MAX_ELEMENTS.', '.$height.')')->getReturnValue($timeoutMs); - $elements = is_string($json) ? json_decode($json, true) : null; - - $image = base64_decode((string) $page->screenshot([ - 'format' => 'jpeg', - 'quality' => self::JPEG_QUALITY, - 'captureBeyondViewport' => true, - 'clip' => new Clip(0, 0, $width, $height), - ])->getBase64($timeoutMs), true); - - return new SnapshotResult((string) $image, 'image/jpeg', $width, $height, is_array($elements) ? $elements : [], $stable, $status); + return $this->captureCurrent($page, $timeoutMs); } finally { try { $browser->close(); @@ -101,6 +77,47 @@ public function take(string $url, string $device = 'desktop', int $timeoutMs = 2 } } + /** + * Capture de la page déjà ouverte dans $page : stabilité (bornée par + * stableTimeoutMs), animations figées, statut HTTP, carte des éléments et + * capture pleine page en JPEG. Ne navigue pas et ne ferme rien : l'onglet et + * son navigateur appartiennent à l'appelant (take(), ou l'app qui a ouvert + * une page back-office avec VisualTestsSuite::openBackOfficeCheckpoint()). + * + * @param object $page onglet chrome-php (\HeadlessChromium\Page) ; typé object + * pour les doubles de test, comme waitUntilStable() + */ + public function captureCurrent(object $page, int $timeoutMs = 15000): SnapshotResult + { + $stable = $this->waitUntilStable($page); + $page->evaluate(PageScripts::SETTLE_ANIMATIONS)->getReturnValue(); + + $status = (int) $page->evaluate( + "(function(){var n=performance.getEntriesByType('navigation')[0];return n&&n.responseStatus?n.responseStatus:0;})()" + )->getReturnValue(); + // Une page en erreur (404, 500…) est capturée comme les autres : un point de + // contrôle peut légitimement cibler une page d'erreur. Le statut est renvoyé. + + [$width, $height] = $page->evaluate( + '[Math.ceil(document.documentElement.scrollWidth), Math.ceil(document.documentElement.scrollHeight)]' + )->getReturnValue(); + $width = max(1, (int) $width); + $height = max(1, min((int) $height, self::MAX_HEIGHT)); + + $page->evaluate('window.scrollTo(0, 0)')->getReturnValue(); + $json = $page->evaluate(PageScripts::ELEMENT_MAP.'('.self::MAX_ELEMENTS.', '.$height.')')->getReturnValue($timeoutMs); + $elements = is_string($json) ? json_decode($json, true) : null; + + $image = base64_decode((string) $page->screenshot([ + 'format' => 'jpeg', + 'quality' => self::JPEG_QUALITY, + 'captureBeyondViewport' => true, + 'clip' => new Clip(0, 0, $width, $height), + ])->getBase64($timeoutMs), true); + + return new SnapshotResult((string) $image, 'image/jpeg', $width, $height, is_array($elements) ? $elements : [], $stable, $status); + } + private function waitUntilStable(object $page): bool { $deadline = microtime(true) + $this->stableTimeoutMs / 1000; diff --git a/tests/Unit/Visual/PageSnapshotTest.php b/tests/Unit/Visual/PageSnapshotTest.php index ac57384..696e58b 100644 --- a/tests/Unit/Visual/PageSnapshotTest.php +++ b/tests/Unit/Visual/PageSnapshotTest.php @@ -13,6 +13,9 @@ final class PageSnapshotTest extends TestCase /** @var array{log: string[], closed: bool, options: array|null, clip: Clip|null, shot: array|null} */ private array $rec; + /** @var \Closure(array): object fabrique de navigateur du dernier snapshot() (pour obtenir une page factice) */ + private \Closure $factory; + private function snapshot(array $values = [], ?\Throwable $navigateThrows = null, bool $factoryThrows = false): PageSnapshot { $this->rec = ['log' => [], 'event' => null, 'closed' => false, 'options' => null, 'clip' => null, 'shot' => null]; @@ -123,6 +126,8 @@ public function close() }; }; + $this->factory = $factory; + return new PageSnapshot($factory, stableTimeoutMs: 50, pollMs: 10); } @@ -191,4 +196,30 @@ public function test_browser_launch_failure_throws_a_readable_error(): void $snapshot->take('https://shop.test/', 'desktop'); } + + public function test_capture_current_captures_the_open_page_without_navigating_or_closing(): void + { + $snapshot = $this->snapshot(['status' => 404, 'size' => [1280, 2000]]); + $page = ($this->factory)([])->createPage(); + + $result = $snapshot->captureCurrent($page); + + $this->assertSame(['stable', 'settle', 'status', 'size', 'top', 'map', 'screenshot'], $this->rec['log']); + $this->assertFalse($this->rec['closed']); + $this->assertSame('JPEGDATA', $result->image); + $this->assertSame('image/jpeg', $result->mime); + $this->assertSame([1280, 2000], [$result->width, $result->height]); + $this->assertSame(404, $result->status); + $this->assertSame('body', $result->elements[0]['selector']); + } + + public function test_take_gives_what_capture_current_gives_after_navigating(): void + { + $taken = $this->snapshot()->take('https://shop.test/', 'desktop'); + + $snapshot = $this->snapshot(); + $current = $snapshot->captureCurrent(($this->factory)([])->createPage()); + + $this->assertEquals($taken, $current); + } } From 450f07ec723681113a12146d3d3cd8995bbc9c31 Mon Sep 17 00:00:00 2001 From: Jonathan Danse Date: Tue, 6 Oct 2026 15:25:35 +0200 Subject: [PATCH 04/10] =?UTF-8?q?feat(pages):=20plafonds=20de=20navigation?= =?UTF-8?q?=20et=20d'issue=20de=20connexion=20r=C3=A9glables=20par=20page,?= =?UTF-8?q?=20d=C3=A9fauts=20du=20run=20inchang=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/Pages/BackOfficePage.php | 4 +- src/Pages/CommonPage.php | 10 ++++- src/Pages/v9/BackOffice/Login/Page.php | 16 ++++++- tests/Unit/Pages/BackOfficeGoToMenuTest.php | 44 ++++++++++++++++++- .../Unit/Pages/BackOfficeLoginOutcomeTest.php | 43 ++++++++++++++++++ 5 files changed, 111 insertions(+), 6 deletions(-) diff --git a/src/Pages/BackOfficePage.php b/src/Pages/BackOfficePage.php index d3be4aa..f009ee1 100644 --- a/src/Pages/BackOfficePage.php +++ b/src/Pages/BackOfficePage.php @@ -47,7 +47,7 @@ public function goToPage($page = null, $params = null) // FrontOffice (ou au 1er appel) — évite un close+createPage inutile // pour deux navigations BO consécutives. TestsSuite::recreatePageIfContextChanged('BO'); - $this->getPage()->navigate($url)->waitForNavigation(\HeadlessChromium\Page::DOM_CONTENT_LOADED); + $this->getPage()->navigate($url)->waitForNavigation(\HeadlessChromium\Page::DOM_CONTENT_LOADED, $this->navigationTimeout); } public function getPageURL($page, $params = null): string @@ -204,7 +204,7 @@ public function goToMenu(string $selectors): string if (!is_string($href) || $href === '' || (str_contains($href, '#') && explode('#', $href, 2)[0] === $currentBase)) { continue; } - $page->navigate($href)->waitForNavigation(\HeadlessChromium\Page::DOM_CONTENT_LOADED); + $page->navigate($href)->waitForNavigation(\HeadlessChromium\Page::DOM_CONTENT_LOADED, $this->navigationTimeout); return $href; } diff --git a/src/Pages/CommonPage.php b/src/Pages/CommonPage.php index 4fed47b..17c979f 100644 --- a/src/Pages/CommonPage.php +++ b/src/Pages/CommonPage.php @@ -43,6 +43,14 @@ class CommonPage public string $url = ''; public string $pageTitle = ''; + /** + * Plafond (ms) des navigations de goToUrl() et, en back-office, de + * goToPage() / goToMenu() ; null = défaut de chrome-php (30 s). Posé puis + * rétabli par VisualTestsSuite::openBackOfficeCheckpoint() pour le + * sélecteur visuel de l'app, qui doit rester sous les 60 s de nginx. + */ + public ?int $navigationTimeout = null; + protected $patchVersion = null; public function __construct(string $locale, string $patchVersion, array $globals, array $customs = []) @@ -909,7 +917,7 @@ public function goToUrl(string $url) // DOM_CONTENT_LOADED (au lieu du LOAD par défaut) : on n'attend pas les // assets tardifs (images, tracking, iframes). Les getters ont chacun leur // waitUntilContainsElement, donc c'est safe pour la majorité des cas. - $this->getPage()->navigate($url)->waitForNavigation(DomPage::DOM_CONTENT_LOADED); + $this->getPage()->navigate($url)->waitForNavigation(DomPage::DOM_CONTENT_LOADED, $this->navigationTimeout); } /** diff --git a/src/Pages/v9/BackOffice/Login/Page.php b/src/Pages/v9/BackOffice/Login/Page.php index afa1bd7..3bd5b03 100644 --- a/src/Pages/v9/BackOffice/Login/Page.php +++ b/src/Pages/v9/BackOffice/Login/Page.php @@ -8,6 +8,20 @@ class Page extends BasePage { public string $pageTitle = 'PrestaShop'; + /** + * Plafond (ms) de l'attente de l'issue de la connexion dans login(). + * 60 s pour un run (premier tableau de bord lent en CI, voir + * waitForLoginOutcome()) ; le sélecteur visuel de l'app le baisse. + */ + public int $loginOutcomeTimeout = 60000; + + /** + * Issue de la dernière connexion de login() : true = vue (session ouverte + * ou alerte d'erreur), false = plafond atteint sans issue, null = pas encore + * attendue. isLoggedIn() reste le constat de la session. + */ + public ?bool $loginOutcomeSeen = null; + public function defineSelectors() { return [ @@ -54,7 +68,7 @@ public function login($email = null, $password = null, $waitForNavigation = true if ($waitForNavigation) { $this->click($this->getSelector('submitLoginButton')); $this->waitForPageReload(); - $this->waitForLoginOutcome(); + $this->loginOutcomeSeen = $this->waitForLoginOutcome($this->loginOutcomeTimeout); } else { $this->click($this->getSelector('submitLoginButton')); } diff --git a/tests/Unit/Pages/BackOfficeGoToMenuTest.php b/tests/Unit/Pages/BackOfficeGoToMenuTest.php index 096e57c..b60f5df 100644 --- a/tests/Unit/Pages/BackOfficeGoToMenuTest.php +++ b/tests/Unit/Pages/BackOfficeGoToMenuTest.php @@ -4,6 +4,7 @@ use PHPUnit\Framework\TestCase; use PrestaFlow\Library\Pages\BackOfficePage; +use PrestaFlow\Library\Tests\TestsSuite; final class BackOfficeGoToMenuTest extends TestCase { @@ -17,6 +18,9 @@ private function page(array $links): BackOfficePage return new class ('en', '9.0.0', $globals, $links, self::CURRENT) extends BackOfficePage { public array $navigated = []; + /** Plafond reçu par chaque waitForNavigation() (null = défaut chrome-php). */ + public array $waits = []; + public function __construct($locale, $version, $globals, private array $links, private string $current) { parent::__construct($locale, $version, $globals); @@ -60,9 +64,14 @@ public function navigate(string $url) { $this->outer->navigated[] = $url; - return new class { - public function waitForNavigation($event = null): void + return new class ($this->outer) { + public function __construct(private $outer) + { + } + + public function waitForNavigation($event = null, $timeout = null): void { + $this->outer->waits[] = $timeout; } }; } @@ -108,4 +117,35 @@ public function test_missing_entry_fails_with_the_selectors(): void $this->expectExceptionMessage('#subtab-AdminOrders, #nope'); $page->goToMenu('#subtab-AdminOrders, #nope'); } + + public function test_menu_navigation_keeps_the_chrome_default_unless_capped(): void + { + $href = 'http://shop.test/admin-dev/index.php?controller=AdminOrders&token=abc'; + $page = $this->page(['#subtab-AdminOrders' => $href]); + + $page->goToMenu('#subtab-AdminOrders'); + $page->navigationTimeout = 15000; + $page->goToMenu('#subtab-AdminOrders'); + + $this->assertSame([null, 15000], $page->waits); + } + + public function test_url_navigation_keeps_the_chrome_default_unless_capped(): void + { + // goToUrl() réapplique les en-têtes persistants : vides ici, aucun navigateur n'est lancé. + $headers = TestsSuite::$extraHttpHeaders; + TestsSuite::$extraHttpHeaders = []; + try { + $page = $this->page([]); + + $page->goToUrl('http://shop.test/admin-dev/logout'); + $page->navigationTimeout = 5000; + $page->goToUrl('http://shop.test/admin-dev/logout'); + + $this->assertSame([null, 5000], $page->waits); + $this->assertSame(['http://shop.test/admin-dev/logout', 'http://shop.test/admin-dev/logout'], $page->navigated); + } finally { + TestsSuite::$extraHttpHeaders = $headers; + } + } } diff --git a/tests/Unit/Pages/BackOfficeLoginOutcomeTest.php b/tests/Unit/Pages/BackOfficeLoginOutcomeTest.php index 0a2263d..cae41dd 100644 --- a/tests/Unit/Pages/BackOfficeLoginOutcomeTest.php +++ b/tests/Unit/Pages/BackOfficeLoginOutcomeTest.php @@ -45,6 +45,16 @@ final class FakeLoginOutcomePage extends LoginPage public array $evaluated = []; public array $log = []; + /** Plafond reçu par chaque waitForLoginOutcome(). */ + public array $outcomeTimeouts = []; + + public function waitForLoginOutcome(int $timeout = 60000, int $interval = 200): bool + { + $this->outcomeTimeouts[] = $timeout; + + return parent::waitForLoginOutcome($timeout, $interval); + } + public function settled(): bool { return !$this->neverSettles && $this->polls >= $this->settlesOnPoll; @@ -143,4 +153,37 @@ public function testLoginWithoutNavigationWaitDoesNotWait(): void $this->assertSame(['set #email', 'set #passwd', 'click #submit_login'], $page->log); $this->assertSame(0, $page->polls); } + + public function testLoginKeepsTheRunCeilingByDefault(): void + { + $page = $this->page(); + $page->settlesOnPoll = 1; + + $page->login(); + + $this->assertSame([60000], $page->outcomeTimeouts); + $this->assertTrue($page->loginOutcomeSeen); + } + + public function testLoginUsesTheCeilingSetOnThePage(): void + { + $page = $this->page(); + $page->loginOutcomeTimeout = 150; + $page->settlesOnPoll = 1; + + $page->login(); + + $this->assertSame([150], $page->outcomeTimeouts); + } + + public function testAnOutcomeNotSeenWithinTheCeilingIsRecorded(): void + { + $page = $this->page(); + $page->loginOutcomeTimeout = 150; + $page->neverSettles = true; + + $page->login(); + + $this->assertFalse($page->loginOutcomeSeen); + } } From 87182283f080ff2b24aafc5594b2eb3fa08f5565 Mon Sep 17 00:00:00 2001 From: Jonathan Danse Date: Tue, 6 Oct 2026 15:30:05 +0200 Subject: [PATCH 05/10] feat(visual): openBackOfficeCheckpoint ouvre la page BO d'un checkpoint comme le run, sous plafonds ; closeBackOfficeSession referme la session Co-Authored-By: Claude Opus 5.5 --- src/Exceptions/BackOfficeTimeoutException.php | 12 + src/Tests/VisualTestsSuite.php | 191 +++++++++++- .../Visual/VisualTestsSuiteBackOfficeTest.php | 285 +++++++++++++++++- 3 files changed, 482 insertions(+), 6 deletions(-) create mode 100644 src/Exceptions/BackOfficeTimeoutException.php diff --git a/src/Exceptions/BackOfficeTimeoutException.php b/src/Exceptions/BackOfficeTimeoutException.php new file mode 100644 index 0000000..008af8b --- /dev/null +++ b/src/Exceptions/BackOfficeTimeoutException.php @@ -0,0 +1,12 @@ + false` avec un `menu` est refusé. + * + * Sélecteur visuel de l'app : openBackOfficeCheckpoint() ouvre la page d'un + * checkpoint BO avec ce même code (connexion, menu), sous des plafonds courts ; + * closeBackOfficeSession() referme la session employé. */ abstract class VisualTestsSuite extends TestsSuite { @@ -51,6 +58,18 @@ abstract class VisualTestsSuite extends TestsSuite protected ?string $boLoginError = null; protected bool $boLoggedIn = false; + /** Cause de l'échec de connexion, gardée comme `previous` de l'erreur relevée (délai ≠ refus). */ + protected ?\Throwable $boLoginCause = null; + + /** Chemin et contrôleur de la page courante : jamais le jeton de l'URL. */ + private const LOCATION_JS = '(function(){var c=new URLSearchParams(location.search).get("controller");return location.pathname+(c?"?controller="+c:"");})()'; + + /** + * Rechargement attendu par Login\Page::login() avant l'issue de la connexion + * (CommonPage::waitForPageReload(), 10 s fixes) : déduit du plafond de connexion. + */ + private const PAGE_RELOAD_MS = 10000; + /** Avertissement posé sur le test quand waitForStable() expire (la capture est prise quand même). */ /** Budget par défaut de pixels changés (cf. CommonPage::visualCheckpoint). */ public const DEFAULT_MAX_DIFF_PIXELS = \PrestaFlow\Library\Pages\CommonPage::DEFAULT_MAX_DIFF_PIXELS; @@ -276,6 +295,7 @@ public function init() $this->lastVisualUrl = null; $this->boLoggedIn = false; $this->boLoginError = null; + $this->boLoginCause = null; $checkpoints = $this->orderedCheckpoints(); $only = self::onlyFromEnv(); @@ -428,10 +448,177 @@ protected function ensureBackOfficeLogin(?object $login): void return; } catch (\Throwable $e) { $this->boLoginError = $e->getMessage(); + $this->boLoginCause = $e; + } + } + + throw new \RuntimeException('Connexion au back-office impossible : '.$this->boLoginError, 0, $this->boLoginCause); + } + + /** + * Ouvre, dans le navigateur courant (TestsSuite::getPage()), la page d'un + * checkpoint BO avec le code du run : connexion (ensureBackOfficeLogin()), + * racine du BO, puis entrée du menu (goToMenu()) ; `auth => false` : racine + * du BO sans session (page de connexion). Pour le sélecteur visuel de l'app, + * qui capture ensuite la page avec PageSnapshot::captureCurrent(). + * + * $loginTimeoutMs borne la connexion après le chargement de la page de + * connexion : rechargement qui suit l'envoi du formulaire (10 s fixes, + * PAGE_RELOAD_MS) + issue de la connexion (Login\Page::$loginOutcomeTimeout, + * le reste, 1 s au minimum). $menuTimeoutMs borne chaque navigation + * (CommonPage::$navigationTimeout). Les valeurs du run sont rétablies avant + * de rendre la main, même en cas d'erreur. + * + * @return string chemin et contrôleur de la page ouverte, sans jeton ('' si illisible) + * + * @throws \LogicException hors zone 'bo' ; \InvalidArgumentException pour auth => false avec un menu + * @throws BackOfficeTimeoutException plafond dépassé (message lisible) + * @throws \RuntimeException erreur du run, message déjà formulé (identifiants refusés, page inattendue, menu introuvable) + */ + public function openBackOfficeCheckpoint(array $checkpoint, int $loginTimeoutMs = 25000, int $menuTimeoutMs = 15000): string + { + if ($this->area !== 'bo') { + throw new \LogicException(sprintf('%s : openBackOfficeCheckpoint() exige la zone « bo » (zone « %s »)', static::class, $this->area)); + } + $cp = self::normalize($checkpoint); + if ($cp['auth'] === false && $cp['menu'] !== null) { + throw new \InvalidArgumentException(sprintf('%s : checkpoint « %s » : auth => false capture la page de connexion, menu interdit', static::class, (string) ($cp['name'] ?? ''))); + } + + // init() n'est pas appelé (il enregistrerait les étapes du run) : pages importées ici. + if (!isset($this->pages['backOfficePage'])) { + $this->importVisualPage(); + } + $page = $this->pages['backOfficePage'] ?? null; + $login = $this->pages['backOfficeLoginPage'] ?? null; + if ($page === null) { + throw new \RuntimeException('page BackOffice absente'); + } + + // Une ouverture = une tentative de connexion, comme une exécution (init()). + $this->boLoggedIn = false; + $this->boLoginError = null; + $this->boLoginCause = null; + $this->lastVisualUrl = null; + + $restore = $this->capBackOfficeWaits($page, $login, $loginTimeoutMs, $menuTimeoutMs); + try { + if ($cp['auth']) { + try { + $this->ensureBackOfficeLogin($login); + } catch (\RuntimeException $e) { + if ($login !== null && ($login->loginOutcomeSeen ?? null) === false) { + throw new BackOfficeTimeoutException(sprintf("Le back-office n'a pas répondu à la connexion en %d s.", intdiv($loginTimeoutMs, 1000)), 0, $e); + } + throw $e; + } + } + $page->goToPage('index'); + if ($cp['menu'] !== null) { + $page->goToMenu($cp['menu']); + } + if (!$cp['auth'] && !$this->loginFormPresent($page, $login)) { + throw new \RuntimeException('Session back-office déjà ouverte : la page de connexion ne peut pas être capturée'); } + + return $this->locationOf($page); + } catch (BackOfficeTimeoutException $e) { + throw $e; + } catch (\Throwable $e) { + if (self::isTimeout($e)) { + throw new BackOfficeTimeoutException(sprintf("Le back-office n'a pas répondu en %d s (chargement d'une page).", intdiv($menuTimeoutMs, 1000)), 0, $e); + } + if ($e instanceof \RuntimeException || !$e instanceof \Exception) { + throw $e; // erreur du run telle quelle ; un \Error (défaut de code) n'est pas masqué + } + throw new \RuntimeException($e->getMessage(), 0, $e); // exceptions chrome-php (ResponseHasError…) + } finally { + $restore(); + } + } + + /** + * Déconnexion par le lien #header_logout de la page courante + * (BackOffice\Login\Page::logout()) si une session est ouverte. Ne lève + * jamais : un échec de déconnexion ne doit pas faire échouer la capture + * (le navigateur de l'app est fermé juste après). $timeoutMs borne la + * navigation de déconnexion. + */ + public function closeBackOfficeSession(int $timeoutMs = 5000): void + { + if (!$this->boLoggedIn) { + return; + } + $this->boLoggedIn = false; + $login = $this->pages['backOfficeLoginPage'] ?? null; + if ($login === null) { + return; + } + + $before = $login->navigationTimeout ?? null; + try { + $login->navigationTimeout = $timeoutMs; + $login->logout(); + } catch (\Throwable) { + // session laissée ouverte : rien d'autre à faire + } finally { + try { + $login->navigationTimeout = $before; + } catch (\Throwable) { + } + } + } + + /** + * Pose les plafonds de openBackOfficeCheckpoint() sur les pages et renvoie + * de quoi rétablir les valeurs d'avant (celles du run). + */ + private function capBackOfficeWaits(object $page, ?object $login, int $loginTimeoutMs, int $navigationTimeoutMs): \Closure + { + $pageBefore = $page->navigationTimeout ?? null; + $loginBefore = $login?->navigationTimeout ?? null; + $outcomeBefore = $login?->loginOutcomeTimeout ?? null; + + $page->navigationTimeout = $navigationTimeoutMs; + if ($login !== null) { + $login->navigationTimeout = $navigationTimeoutMs; + $login->loginOutcomeTimeout = max(1000, $loginTimeoutMs - self::PAGE_RELOAD_MS); + $login->loginOutcomeSeen = null; + } + + return static function () use ($page, $login, $pageBefore, $loginBefore, $outcomeBefore): void { + $page->navigationTimeout = $pageBefore; + if ($login !== null) { + $login->navigationTimeout = $loginBefore; + if ($outcomeBefore !== null) { + $login->loginOutcomeTimeout = $outcomeBefore; + } + } + }; + } + + /** Délai de chrome-php ou de la lib quelque part dans la chaîne des causes. */ + private static function isTimeout(\Throwable $e): bool + { + for ($t = $e; $t !== null; $t = $t->getPrevious()) { + if ($t instanceof OperationTimedOut || $t instanceof TimeoutException) { + return true; + } + } + + return false; + } + + /** Chemin et contrôleur de la page courante, sans jeton ; '' si illisible. */ + protected function locationOf(object $page): string + { + try { + $where = $this->readNow($page, self::LOCATION_JS); + } catch (\Throwable) { + return ''; } - throw new \RuntimeException('Connexion au back-office impossible : '.$this->boLoginError); + return is_string($where) ? $where : ''; } /** @@ -440,7 +627,7 @@ protected function ensureBackOfficeLogin(?object $login): void */ 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:"");})()'); + $where = $this->readNow($page, self::LOCATION_JS); return is_string($where) && $where !== '' ? ' (page : '.$where.')' : ''; } diff --git a/tests/Unit/Visual/VisualTestsSuiteBackOfficeTest.php b/tests/Unit/Visual/VisualTestsSuiteBackOfficeTest.php index 589eaac..0b8d2cb 100644 --- a/tests/Unit/Visual/VisualTestsSuiteBackOfficeTest.php +++ b/tests/Unit/Visual/VisualTestsSuiteBackOfficeTest.php @@ -2,7 +2,9 @@ namespace PrestaFlow\Tests\Unit\Visual; +use HeadlessChromium\Exception\OperationTimedOut; use PHPUnit\Framework\TestCase; +use PrestaFlow\Library\Exceptions\BackOfficeTimeoutException; use PrestaFlow\Library\Tests\VisualTestsSuite; final class VisualTestsSuiteBackOfficeTest extends TestCase @@ -88,10 +90,32 @@ private function page(\ArrayObject $log): object return new class ($log, $this->chrome) { public array $calls = []; public ?string $failOn = null; + /** Plafond de navigation posé par la suite (CommonPage::$navigationTimeout). */ + public ?int $navigationTimeout = null; + /** Plafond vu à chaque navigation. */ + public array $navTimeouts = []; + public ?\Throwable $navThrows = null; + public ?\Throwable $menuThrows = null; public function __construct(public \ArrayObject $log, public object $chrome) {} public function getPage(): object { return $this->chrome; } - public function goToPage($page = null, $params = null): void { $this->log[] = 'page '.$page; } - public function goToMenu(string $selectors): string { $this->log[] = 'menu '.$selectors; return 'http://shop.test/admin-dev/x?token=t'; } + public function goToPage($page = null, $params = null): void + { + $this->log[] = 'page '.$page; + $this->navTimeouts[] = $this->navigationTimeout; + if ($this->navThrows !== null) { + throw $this->navThrows; + } + } + public function goToMenu(string $selectors): string + { + $this->log[] = 'menu '.$selectors; + $this->navTimeouts[] = $this->navigationTimeout; + if ($this->menuThrows !== null) { + throw $this->menuThrows; + } + + return 'http://shop.test/admin-dev/x?token=t'; + } public function goToUrl(string $url): void { $this->log[] = 'goto '.$url; } public function waitForStable(): bool { $this->log[] = 'stable'; return true; } public function waitVisible(string $s): void {} @@ -114,15 +138,46 @@ private function login(\ArrayObject $log, bool $ok = true): object // le test ($this->chrome->form), comme l'afficherait la racine du BO pour la // session courante. Suffisant : la suite ne la lit qu'après une navigation. return new class ($log, $ok, $this->chrome) { + /** Plafonds posés par la suite (CommonPage::$navigationTimeout, Login\Page::$loginOutcomeTimeout). */ + public ?int $navigationTimeout = null; + public int $loginOutcomeTimeout = 60000; + public ?bool $loginOutcomeSeen = null; + /** Issue vue par login() dans le plafond (false = délai). */ + public bool $outcome = true; + public bool $logoutThrows = false; + public ?\Throwable $navThrows = null; + public array $navTimeouts = []; + public array $outcomeTimeouts = []; + public array $logoutTimeouts = []; public function __construct(public \ArrayObject $log, public bool $ok, public object $chrome) {} public function getPage(): object { return $this->chrome; } public function getSelector($selector, $replacements = []): string { return ['emailInput' => '#email', 'alertDangerDiv' => '.alert-danger'][$selector]; } - public function goToPage($page = null, $params = null): void { $this->log[] = 'login:page '.$page; } - public function login($email = null, $password = null, $waitForNavigation = true): void { $this->log[] = 'login:submit'; } + public function goToPage($page = null, $params = null): void + { + $this->log[] = 'login:page '.$page; + $this->navTimeouts[] = $this->navigationTimeout; + if ($this->navThrows !== null) { + throw $this->navThrows; + } + } + public function login($email = null, $password = null, $waitForNavigation = true): void + { + $this->log[] = 'login:submit'; + $this->outcomeTimeouts[] = $this->loginOutcomeTimeout; + $this->loginOutcomeSeen = $this->outcome; + } public function isLoggedIn(): bool { $this->log[] = 'login:check'; return $this->ok; } + public function logout(): void + { + $this->log[] = 'login:logout'; + $this->logoutTimeouts[] = $this->navigationTimeout; + if ($this->logoutThrows) { + throw new \RuntimeException('Cannot log out: no logout link found at "#header_logout".'); + } + } }; } @@ -401,4 +456,226 @@ public function test_failed_checkpoint_forces_navigation_on_next_one_with_same_t 'page index', 'menu #subtab-AdminOrders', 'stable', 'capture backoffice.orders-bis', ], $log->getArrayCopy()); } + + public function test_open_checkpoint_logs_in_then_opens_the_dashboard_then_the_menu(): void + { + $this->chrome->location = '/admin-dev/index.php?controller=AdminProducts'; + $log = new \ArrayObject(); + $page = $this->page($log); + $login = $this->login($log); + $s = $this->suite(page: $page, login: $login); + + $where = $s->openBackOfficeCheckpoint(['name' => 'picker', 'menu' => '#subtab-AdminProducts'], 25000, 15000); + + $this->assertSame([ + 'login:page index', 'login:submit', 'login:check', + 'page index', 'menu #subtab-AdminProducts', + ], $log->getArrayCopy()); + // Chemin et contrôleur, jamais le jeton. + $this->assertSame('/admin-dev/index.php?controller=AdminProducts', $where); + // Plafonds pendant l'ouverture, valeurs du run rétablies ensuite. L'issue de + // la connexion a le plafond moins les 10 s du rechargement qui la précède. + $this->assertSame([15000], $login->outcomeTimeouts); + $this->assertSame([15000], $login->navTimeouts); + $this->assertSame([15000, 15000], $page->navTimeouts); + $this->assertNull($page->navigationTimeout); + $this->assertNull($login->navigationTimeout); + $this->assertSame(60000, $login->loginOutcomeTimeout); + } + + public function test_open_checkpoint_without_menu_stays_on_the_dashboard(): void + { + $log = new \ArrayObject(); + $s = $this->suite(page: $this->page($log), login: $this->login($log)); + + $s->openBackOfficeCheckpoint(['name' => 'picker', 'menu' => null]); + + $this->assertSame(['login:page index', 'login:submit', 'login:check', 'page index'], $log->getArrayCopy()); + } + + public function test_open_checkpoint_without_auth_opens_the_login_page_without_logging_in(): void + { + $log = new \ArrayObject(); + $s = $this->suite(page: $this->page($log), login: $this->login($log)); + + $s->openBackOfficeCheckpoint(['name' => 'picker', 'auth' => false]); + + $this->assertSame(['page index'], $log->getArrayCopy()); + } + + public function test_open_checkpoint_without_auth_fails_when_a_session_is_open(): void + { + $this->chrome->form = false; + $log = new \ArrayObject(); + $s = $this->suite(page: $this->page($log), login: $this->login($log)); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Session back-office déjà ouverte : la page de connexion ne peut pas être capturée'); + $s->openBackOfficeCheckpoint(['name' => 'picker', 'auth' => false]); + } + + public function test_open_checkpoint_reports_refused_credentials_like_the_run(): void + { + $this->chrome->error = ' The employee does not exist. '; + $log = new \ArrayObject(); + $s = $this->suite(page: $this->page($log), login: $this->login($log, ok: false)); + + try { + $s->openBackOfficeCheckpoint(['name' => 'picker', 'menu' => '#subtab-AdminProducts']); + $this->fail('RuntimeException attendue'); + } catch (BackOfficeTimeoutException $e) { + $this->fail('Pas un délai : '.$e->getMessage()); + } catch (\RuntimeException $e) { + $this->assertSame('Connexion au back-office impossible : identifiants refusés ou page inattendue : The employee does not exist.', $e->getMessage()); + } + $this->assertNotContains('page index', $log->getArrayCopy()); + } + + public function test_open_checkpoint_reports_a_missing_menu_entry_like_the_run(): void + { + $log = new \ArrayObject(); + $page = $this->page($log); + $page->menuThrows = new \RuntimeException('Entrée du menu introuvable : #subtab-Nope'); + $s = $this->suite(page: $page, login: $this->login($log)); + + try { + $s->openBackOfficeCheckpoint(['name' => 'picker', 'menu' => '#subtab-Nope']); + $this->fail('RuntimeException attendue'); + } catch (BackOfficeTimeoutException $e) { + $this->fail('Pas un délai : '.$e->getMessage()); + } catch (\RuntimeException $e) { + $this->assertSame('Entrée du menu introuvable : #subtab-Nope', $e->getMessage()); + } + $this->assertNull($page->navigationTimeout); + } + + public function test_open_checkpoint_turns_an_unseen_login_outcome_into_a_timeout(): void + { + $log = new \ArrayObject(); + $login = $this->login($log, ok: false); + $login->outcome = false; // ni lien de déconnexion ni alerte dans le plafond + $s = $this->suite(page: $this->page($log), login: $login); + + try { + $s->openBackOfficeCheckpoint(['name' => 'picker'], 25000, 15000); + $this->fail('BackOfficeTimeoutException attendue'); + } catch (BackOfficeTimeoutException $e) { + $this->assertInstanceOf(\RuntimeException::class, $e); + $this->assertStringContainsString('connexion en 25 s', $e->getMessage()); + } + $this->assertSame(60000, $login->loginOutcomeTimeout); + $this->assertNull($login->navigationTimeout); + } + + public function test_open_checkpoint_turns_a_navigation_timeout_into_a_timeout(): void + { + $log = new \ArrayObject(); + $page = $this->page($log); + $page->navThrows = new OperationTimedOut('Operation timed out after 15s.'); + $s = $this->suite(page: $page, login: $this->login($log)); + + try { + $s->openBackOfficeCheckpoint(['name' => 'picker'], 25000, 15000); + $this->fail('BackOfficeTimeoutException attendue'); + } catch (BackOfficeTimeoutException $e) { + $this->assertStringContainsString('15 s', $e->getMessage()); + $this->assertInstanceOf(OperationTimedOut::class, $e->getPrevious()); + } + $this->assertNull($page->navigationTimeout); + } + + public function test_open_checkpoint_turns_a_login_page_timeout_into_a_timeout(): void + { + // Le délai survient dans ensureBackOfficeLogin(), qui le relève avec sa cause. + $log = new \ArrayObject(); + $login = $this->login($log); + $login->navThrows = new OperationTimedOut('Operation timed out after 15s.'); + $s = $this->suite(page: $this->page($log), login: $login); + + $this->expectException(BackOfficeTimeoutException::class); + $this->expectExceptionMessage('15 s'); + $s->openBackOfficeCheckpoint(['name' => 'picker'], 25000, 15000); + } + + public function test_open_checkpoint_is_refused_outside_the_back_office(): void + { + $log = new \ArrayObject(); + $s = $this->suite('fo', page: $this->page($log)); + + try { + $s->openBackOfficeCheckpoint(['name' => 'picker']); + $this->fail('LogicException attendue'); + } catch (\LogicException $e) { + $this->assertStringContainsString('« bo »', $e->getMessage()); + } + $this->assertSame([], $log->getArrayCopy()); + } + + public function test_open_checkpoint_refuses_a_login_page_with_a_menu(): void + { + $log = new \ArrayObject(); + $s = $this->suite(page: $this->page($log), login: $this->login($log)); + + try { + $s->openBackOfficeCheckpoint(['name' => 'picker', 'auth' => false, 'menu' => '#subtab-AdminOrders']); + $this->fail('InvalidArgumentException attendue'); + } catch (\InvalidArgumentException $e) { + $this->assertStringContainsString('menu interdit', $e->getMessage()); + } + $this->assertSame([], $log->getArrayCopy()); + } + + public function test_close_session_logs_out_once_under_its_ceiling(): void + { + $log = new \ArrayObject(); + $login = $this->login($log); + $s = $this->suite(page: $this->page($log), login: $login); + $s->openBackOfficeCheckpoint(['name' => 'picker']); + + $s->closeBackOfficeSession(5000); + $s->closeBackOfficeSession(5000); + + $this->assertSame(1, count(array_keys($log->getArrayCopy(), 'login:logout'))); + $this->assertSame([5000], $login->logoutTimeouts); + $this->assertNull($login->navigationTimeout); + } + + public function test_close_session_without_session_does_nothing(): void + { + $log = new \ArrayObject(); + $s = $this->suite(page: $this->page($log), login: $this->login($log)); + $s->closeBackOfficeSession(); + + $s->openBackOfficeCheckpoint(['name' => 'picker', 'auth' => false]); + $s->closeBackOfficeSession(); + + $this->assertNotContains('login:logout', $log->getArrayCopy()); + } + + public function test_close_session_never_throws(): void + { + $log = new \ArrayObject(); + $login = $this->login($log); + $login->logoutThrows = true; + $s = $this->suite(page: $this->page($log), login: $login); + $s->openBackOfficeCheckpoint(['name' => 'picker']); + + $s->closeBackOfficeSession(); + + $this->assertContains('login:logout', $log->getArrayCopy()); + $this->assertNull($login->navigationTimeout); + } + + public function test_open_checkpoint_keeps_a_minimal_login_outcome_wait_under_a_low_ceiling(): void + { + $log = new \ArrayObject(); + $login = $this->login($log); + $s = $this->suite(page: $this->page($log), login: $login); + + $s->openBackOfficeCheckpoint(['name' => 'picker'], 5000, 15000); + + // 5 s ne couvrent pas les 10 s du rechargement : 1 s d'attente de l'issue au minimum. + $this->assertSame([1000], $login->outcomeTimeouts); + $this->assertSame(60000, $login->loginOutcomeTimeout); + } } From 1f93ddba3819e254233d55b7ae48075e452c077b Mon Sep 17 00:00:00 2001 From: Jonathan Danse Date: Tue, 6 Oct 2026 15:37:51 +0200 Subject: [PATCH 06/10] =?UTF-8?q?fix(visual):=20openBackOfficeCheckpoint?= =?UTF-8?q?=20sous=20=C3=A9ch=C3=A9ance=20globale,=20messages=20sans=20jet?= =?UTF-8?q?on=20ni=20identifiants=20d'URL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Échéance = début + plafond de connexion + plafond de navigation ; chaque étape reçoit le reste, et n'est pas lancée faute de temps. Messages de délai neutres ; net::ERR_TIMED_OUT traité comme un délai. Co-Authored-By: Claude Opus 5.5 --- src/Tests/VisualTestsSuite.php | 146 ++++++++++++-- .../Visual/VisualTestsSuiteBackOfficeTest.php | 179 +++++++++++++++++- 2 files changed, 303 insertions(+), 22 deletions(-) diff --git a/src/Tests/VisualTestsSuite.php b/src/Tests/VisualTestsSuite.php index b6857ba..5604d2d 100644 --- a/src/Tests/VisualTestsSuite.php +++ b/src/Tests/VisualTestsSuite.php @@ -2,6 +2,7 @@ namespace PrestaFlow\Library\Tests; +use HeadlessChromium\Exception\CommunicationException\ResponseHasError; use HeadlessChromium\Exception\OperationTimedOut; use PrestaFlow\Library\Exceptions\BackOfficeTimeoutException; use PrestaFlow\Library\Exceptions\TimeoutException; @@ -70,6 +71,16 @@ abstract class VisualTestsSuite extends TestsSuite */ private const PAGE_RELOAD_MS = 10000; + /** Plus petit plafond d'étape accordé par openBackOfficeCheckpoint(). */ + private const MIN_STEP_MS = 1000; + + /** + * Appelé par ensureBackOfficeLogin() juste avant l'envoi du formulaire : + * openBackOfficeCheckpoint() y pose le plafond de l'issue selon le reste du + * budget (null pendant un run). + */ + private ?\Closure $boBeforeLoginSubmit = null; + /** Avertissement posé sur le test quand waitForStable() expire (la capture est prise quand même). */ /** Budget par défaut de pixels changés (cf. CommonPage::visualCheckpoint). */ public const DEFAULT_MAX_DIFF_PIXELS = \PrestaFlow\Library\Pages\CommonPage::DEFAULT_MAX_DIFF_PIXELS; @@ -436,6 +447,9 @@ protected function ensureBackOfficeLogin(?object $login): void return; } + if ($this->boBeforeLoginSubmit !== null) { + ($this->boBeforeLoginSubmit)($login); + } $login->login(); // identifiants des globals BO_EMAIL / BO_PASSWD if (!$login->isLoggedIn()) { // 9.2 : .alert-text, sans le bouton de fermeture (« close ») ; 1.7 et 8 : l'alerte entière. @@ -462,17 +476,34 @@ protected function ensureBackOfficeLogin(?object $login): void * du BO sans session (page de connexion). Pour le sélecteur visuel de l'app, * qui capture ensuite la page avec PageSnapshot::captureCurrent(). * - * $loginTimeoutMs borne la connexion après le chargement de la page de - * connexion : rechargement qui suit l'envoi du formulaire (10 s fixes, - * PAGE_RELOAD_MS) + issue de la connexion (Login\Page::$loginOutcomeTimeout, - * le reste, 1 s au minimum). $menuTimeoutMs borne chaque navigation - * (CommonPage::$navigationTimeout). Les valeurs du run sont rétablies avant - * de rendre la main, même en cas d'erreur. + * Une ouverture par instance de suite : l'état de connexion (boLoggedIn, + * erreur, cause) est remis à zéro à chaque appel, qui se reconnecte donc. + * + * Budget : échéance globale = début + $loginTimeoutMs + $menuTimeoutMs + * (40 s par défaut). Chaque étape plafonnée reçoit min(son plafond, reste + * avant l'échéance), et n'est pas lancée s'il reste moins de 1 s + * (BackOfficeTimeoutException) : + * - navigation de la page de connexion, du tableau de bord, du menu : + * min($menuTimeoutMs, reste) (CommonPage::$navigationTimeout) ; + * - envoi du formulaire : seulement s'il reste au moins PAGE_RELOAD_MS + 1 s, + * car Login\Page::login() attend d'abord un rechargement de 10 s fixes + * (CommonPage::waitForPageReload()) ; l'issue de la connexion + * (Login\Page::$loginOutcomeTimeout) reçoit + * max(1 s, min($loginTimeoutMs - 10 s, reste - 10 s)). + * Pire cas : échéance + 5 s (Login\Page::isLoggedIn(), qui suit l'issue sans + * plafond réglable), plus les lectures JS et le remplissage du formulaire. + * La capture (PageSnapshot::captureCurrent()) et la déconnexion + * (closeBackOfficeSession()) sont hors de ce budget. Les plafonds d'avant + * l'appel (ceux du run) sont rétablis avant de rendre la main, même en cas + * d'erreur. + * + * Les messages relayés ne portent ni jeton (`token`, `_token`) ni + * identifiants d'URL (`user:pass@`) ; la cause d'origine reste en `previous`. * * @return string chemin et contrôleur de la page ouverte, sans jeton ('' si illisible) * * @throws \LogicException hors zone 'bo' ; \InvalidArgumentException pour auth => false avec un menu - * @throws BackOfficeTimeoutException plafond dépassé (message lisible) + * @throws BackOfficeTimeoutException délai imparti dépassé (message lisible) * @throws \RuntimeException erreur du run, message déjà formulé (identifiants refusés, page inattendue, menu introuvable) */ public function openBackOfficeCheckpoint(array $checkpoint, int $loginTimeoutMs = 25000, int $menuTimeoutMs = 15000): string @@ -501,20 +532,45 @@ public function openBackOfficeCheckpoint(array $checkpoint, int $loginTimeoutMs $this->boLoginCause = null; $this->lastVisualUrl = null; - $restore = $this->capBackOfficeWaits($page, $login, $loginTimeoutMs, $menuTimeoutMs); + $budgetMs = $loginTimeoutMs + $menuTimeoutMs; + $deadline = $this->nowMs() + $budgetMs; + // Reste avant l'échéance, plafonné à $stepMs ; délai si moins de $neededMs. + $left = function (int $stepMs, int $neededMs = self::MIN_STEP_MS) use ($deadline, $budgetMs): int { + $left = $deadline - $this->nowMs(); + if ($left < $neededMs) { + throw new BackOfficeTimeoutException(sprintf("Le back-office n'a pas répondu dans le délai imparti (%d s au total).", intdiv($budgetMs, 1000))); + } + + return min($stepMs, $left); + }; + + $restore = $this->capBackOfficeWaits($page, $login); try { if ($cp['auth']) { + if ($login !== null) { + $login->navigationTimeout = $left($menuTimeoutMs); + $this->boBeforeLoginSubmit = static function (object $login) use ($left, $loginTimeoutMs): void { + $rest = $left(PHP_INT_MAX, self::PAGE_RELOAD_MS + self::MIN_STEP_MS); + $login->loginOutcomeTimeout = max(self::MIN_STEP_MS, min($loginTimeoutMs, $rest) - self::PAGE_RELOAD_MS); + }; + } try { $this->ensureBackOfficeLogin($login); } catch (\RuntimeException $e) { + $deadlineHit = self::findInChain($e, BackOfficeTimeoutException::class); + if ($deadlineHit !== null) { + throw $deadlineHit; + } if ($login !== null && ($login->loginOutcomeSeen ?? null) === false) { - throw new BackOfficeTimeoutException(sprintf("Le back-office n'a pas répondu à la connexion en %d s.", intdiv($loginTimeoutMs, 1000)), 0, $e); + throw new BackOfficeTimeoutException(sprintf("Le back-office n'a pas répondu à la connexion dans le délai imparti (%d s).", intdiv($loginTimeoutMs, 1000)), 0, $e); } throw $e; } } + $page->navigationTimeout = $left($menuTimeoutMs); $page->goToPage('index'); if ($cp['menu'] !== null) { + $page->navigationTimeout = $left($menuTimeoutMs); $page->goToMenu($cp['menu']); } if (!$cp['auth'] && !$this->loginFormPresent($page, $login)) { @@ -526,13 +582,19 @@ public function openBackOfficeCheckpoint(array $checkpoint, int $loginTimeoutMs throw $e; } catch (\Throwable $e) { if (self::isTimeout($e)) { - throw new BackOfficeTimeoutException(sprintf("Le back-office n'a pas répondu en %d s (chargement d'une page).", intdiv($menuTimeoutMs, 1000)), 0, $e); + throw new BackOfficeTimeoutException(sprintf("Le back-office n'a pas répondu dans le délai imparti (chargement d'une page, %d s au plus).", intdiv($menuTimeoutMs, 1000)), 0, $e); } - if ($e instanceof \RuntimeException || !$e instanceof \Exception) { - throw $e; // erreur du run telle quelle ; un \Error (défaut de code) n'est pas masqué + if (!$e instanceof \Exception) { + throw $e; // un \Error (défaut de code) n'est pas masqué } - throw new \RuntimeException($e->getMessage(), 0, $e); // exceptions chrome-php (ResponseHasError…) + $message = self::redactUrls($e->getMessage()); + if ($e instanceof \RuntimeException && $message === $e->getMessage()) { + throw $e; // erreur du run telle quelle + } + // exceptions chrome-php (ResponseHasError…) ou message portant une URL + throw new \RuntimeException($message, 0, $e); } finally { + $this->boBeforeLoginSubmit = null; $restore(); } } @@ -543,6 +605,11 @@ public function openBackOfficeCheckpoint(array $checkpoint, int $loginTimeoutMs * jamais : un échec de déconnexion ne doit pas faire échouer la capture * (le navigateur de l'app est fermé juste après). $timeoutMs borne la * navigation de déconnexion. + * + * Seule une connexion constatée (boLoggedIn) est fermée : une connexion + * validée par le serveur après le délai imparti (openBackOfficeCheckpoint() + * a levé BackOfficeTimeoutException) reste ouverte, et la session serveur + * expirera d'elle-même. */ public function closeBackOfficeSession(int $timeoutMs = 5000): void { @@ -569,20 +636,22 @@ public function closeBackOfficeSession(int $timeoutMs = 5000): void } } + /** Horloge monotone en millisecondes (surchargée en test unitaire). */ + protected function nowMs(): int + { + return intdiv(hrtime(true), 1_000_000); + } + /** - * Pose les plafonds de openBackOfficeCheckpoint() sur les pages et renvoie - * de quoi rétablir les valeurs d'avant (celles du run). + * Note les plafonds d'avant openBackOfficeCheckpoint() (ceux du run) et + * renvoie de quoi les rétablir. Les plafonds sont posés étape par étape. */ - private function capBackOfficeWaits(object $page, ?object $login, int $loginTimeoutMs, int $navigationTimeoutMs): \Closure + private function capBackOfficeWaits(object $page, ?object $login): \Closure { $pageBefore = $page->navigationTimeout ?? null; $loginBefore = $login?->navigationTimeout ?? null; $outcomeBefore = $login?->loginOutcomeTimeout ?? null; - - $page->navigationTimeout = $navigationTimeoutMs; if ($login !== null) { - $login->navigationTimeout = $navigationTimeoutMs; - $login->loginOutcomeTimeout = max(1000, $loginTimeoutMs - self::PAGE_RELOAD_MS); $login->loginOutcomeSeen = null; } @@ -597,13 +666,48 @@ private function capBackOfficeWaits(object $page, ?object $login, int $loginTime }; } - /** Délai de chrome-php ou de la lib quelque part dans la chaîne des causes. */ + /** + * Masque jetons (`token=`, `_token=`) et identifiants d'URL + * (`scheme://user:pass@`) d'un message relayé à l'utilisateur. + */ + private static function redactUrls(string $message): string + { + $message = preg_replace('~([?&]_?token=)[^&"\s]+~i', '$1…', $message) ?? $message; + + return preg_replace('~\b([a-z][a-z0-9+.\-]*://)[^\s/"@]+@~i', '$1…@', $message) ?? $message; + } + + /** + * @template T of \Throwable + * + * @param class-string $class + * + * @return T|null + */ + private static function findInChain(\Throwable $e, string $class): ?\Throwable + { + for ($t = $e; $t !== null; $t = $t->getPrevious()) { + if ($t instanceof $class) { + return $t; + } + } + + return null; + } + + /** + * Délai de chrome-php ou de la lib quelque part dans la chaîne des causes, + * y compris un chargement de page en échec net::ERR_TIMED_OUT. + */ private static function isTimeout(\Throwable $e): bool { for ($t = $e; $t !== null; $t = $t->getPrevious()) { if ($t instanceof OperationTimedOut || $t instanceof TimeoutException) { return true; } + if ($t instanceof ResponseHasError && str_contains($t->getMessage(), 'TIMED_OUT')) { + return true; + } } return false; diff --git a/tests/Unit/Visual/VisualTestsSuiteBackOfficeTest.php b/tests/Unit/Visual/VisualTestsSuiteBackOfficeTest.php index 0b8d2cb..2158186 100644 --- a/tests/Unit/Visual/VisualTestsSuiteBackOfficeTest.php +++ b/tests/Unit/Visual/VisualTestsSuiteBackOfficeTest.php @@ -2,6 +2,7 @@ namespace PrestaFlow\Tests\Unit\Visual; +use HeadlessChromium\Exception\CommunicationException\ResponseHasError; use HeadlessChromium\Exception\OperationTimedOut; use PHPUnit\Framework\TestCase; use PrestaFlow\Library\Exceptions\BackOfficeTimeoutException; @@ -20,6 +21,8 @@ protected function setUp(): void public bool $form = true; public string $error = ''; public string $location = ''; + /** Horloge factice (ms) lue par la suite (nowMs()), avancée par les doubles. */ + public int $now = 0; public function evaluate(string $js): object { $value = match (true) { @@ -50,6 +53,9 @@ private function suite(string $area = 'bo', ?bool $freeze = null, ?object $page $suite = new class (loadGlobals: false, getBrowser: false) extends VisualTestsSuite { public ?object $fakePage = null; public ?object $fakeLogin = null; + /** Horloge factice : l'onglet partagé ($this->chrome->now). */ + public ?object $clock = null; + protected function nowMs(): int { return $this->clock?->now ?? 0; } protected array $devices = ['desktop']; protected array $locales = ['en']; protected string $visualScope = 'backoffice'; @@ -74,6 +80,7 @@ public function setArea(string $area, ?bool $freeze): void { $this->area = $area $suite->setArea($area, $freeze); $suite->fakePage = $page; $suite->fakeLogin = $login; + $suite->clock = $this->chrome; if ($checkpoints !== null) { $suite->useDefinition(['desktop'], ['en'], $checkpoints); } @@ -96,12 +103,15 @@ private function page(\ArrayObject $log): object public array $navTimeouts = []; public ?\Throwable $navThrows = null; public ?\Throwable $menuThrows = null; + /** Durée simulée de chaque navigation (avance l'horloge factice). */ + public int $navCostMs = 0; public function __construct(public \ArrayObject $log, public object $chrome) {} public function getPage(): object { return $this->chrome; } public function goToPage($page = null, $params = null): void { $this->log[] = 'page '.$page; $this->navTimeouts[] = $this->navigationTimeout; + $this->chrome->now += $this->navCostMs; if ($this->navThrows !== null) { throw $this->navThrows; } @@ -110,6 +120,7 @@ public function goToMenu(string $selectors): string { $this->log[] = 'menu '.$selectors; $this->navTimeouts[] = $this->navigationTimeout; + $this->chrome->now += $this->navCostMs; if ($this->menuThrows !== null) { throw $this->menuThrows; } @@ -149,6 +160,9 @@ private function login(\ArrayObject $log, bool $ok = true): object public array $navTimeouts = []; public array $outcomeTimeouts = []; public array $logoutTimeouts = []; + /** Durées simulées : navigation, envoi du formulaire (rechargement + issue). */ + public int $navCostMs = 0; + public int $submitCostMs = 0; public function __construct(public \ArrayObject $log, public bool $ok, public object $chrome) {} public function getPage(): object { return $this->chrome; } public function getSelector($selector, $replacements = []): string @@ -159,6 +173,7 @@ public function goToPage($page = null, $params = null): void { $this->log[] = 'login:page '.$page; $this->navTimeouts[] = $this->navigationTimeout; + $this->chrome->now += $this->navCostMs; if ($this->navThrows !== null) { throw $this->navThrows; } @@ -168,6 +183,7 @@ public function login($email = null, $password = null, $waitForNavigation = true $this->log[] = 'login:submit'; $this->outcomeTimeouts[] = $this->loginOutcomeTimeout; $this->loginOutcomeSeen = $this->outcome; + $this->chrome->now += $this->submitCostMs; } public function isLoggedIn(): bool { $this->log[] = 'login:check'; return $this->ok; } public function logout(): void @@ -561,7 +577,7 @@ public function test_open_checkpoint_turns_an_unseen_login_outcome_into_a_timeou $this->fail('BackOfficeTimeoutException attendue'); } catch (BackOfficeTimeoutException $e) { $this->assertInstanceOf(\RuntimeException::class, $e); - $this->assertStringContainsString('connexion en 25 s', $e->getMessage()); + $this->assertStringContainsString('à la connexion dans le délai imparti (25 s)', $e->getMessage()); } $this->assertSame(60000, $login->loginOutcomeTimeout); $this->assertNull($login->navigationTimeout); @@ -678,4 +694,165 @@ public function test_open_checkpoint_keeps_a_minimal_login_outcome_wait_under_a_ $this->assertSame([1000], $login->outcomeTimeouts); $this->assertSame(60000, $login->loginOutcomeTimeout); } + + public function test_open_checkpoint_caps_each_step_to_what_is_left_of_the_global_budget(): void + { + // Échéance = 25 + 15 = 40 s après le début. + $log = new \ArrayObject(); + $page = $this->page($log); + $page->navCostMs = 10000; + $login = $this->login($log); + $login->navCostMs = 12000; // page de connexion : t = 12 s + $login->submitCostMs = 14000; // rechargement + issue : t = 26 s + $s = $this->suite(page: $page, login: $login); + + $s->openBackOfficeCheckpoint(['name' => 'picker', 'menu' => '#subtab-AdminProducts'], 25000, 15000); + + $this->assertSame([15000], $login->navTimeouts); + // Reste 28 s à l'envoi : issue = min(25 - 10, 28 - 10) = 15 s. + $this->assertSame([15000], $login->outcomeTimeouts); + // Tableau de bord : reste 14 s ; menu (t = 36 s) : reste 4 s. + $this->assertSame([14000, 4000], $page->navTimeouts); + } + + public function test_open_checkpoint_caps_the_login_outcome_to_what_is_left(): void + { + $log = new \ArrayObject(); + $login = $this->login($log); + $login->navCostMs = 20000; // reste 20 s à l'envoi + $s = $this->suite(page: $this->page($log), login: $login); + + $s->openBackOfficeCheckpoint(['name' => 'picker'], 25000, 15000); + + // min(25 - 10, 20 - 10) = 10 s : rechargement + issue tiennent dans le reste. + $this->assertSame([10000], $login->outcomeTimeouts); + } + + public function test_open_checkpoint_does_not_submit_the_login_when_the_reload_no_longer_fits(): void + { + $log = new \ArrayObject(); + $login = $this->login($log); + $login->navCostMs = 30000; // reste 10 s < 10 s de rechargement + 1 s + $s = $this->suite(page: $this->page($log), login: $login); + + try { + $s->openBackOfficeCheckpoint(['name' => 'picker'], 25000, 15000); + $this->fail('BackOfficeTimeoutException attendue'); + } catch (BackOfficeTimeoutException $e) { + $this->assertStringContainsString('délai imparti (40 s au total)', $e->getMessage()); + } + $this->assertNotContains('login:submit', $log->getArrayCopy()); + $this->assertSame(60000, $login->loginOutcomeTimeout); + } + + public function test_open_checkpoint_stops_before_a_step_once_the_deadline_is_reached(): void + { + $log = new \ArrayObject(); + $page = $this->page($log); + $login = $this->login($log); + $login->navCostMs = 15000; + $login->submitCostMs = 25000; // t = 40 s : échéance atteinte + $s = $this->suite(page: $page, login: $login); + + try { + $s->openBackOfficeCheckpoint(['name' => 'picker', 'menu' => '#subtab-AdminProducts'], 25000, 15000); + $this->fail('BackOfficeTimeoutException attendue'); + } catch (BackOfficeTimeoutException $e) { + $this->assertSame("Le back-office n'a pas répondu dans le délai imparti (40 s au total).", $e->getMessage()); + } + $this->assertNotContains('page index', $log->getArrayCopy()); + $this->assertSame([], $page->navTimeouts); + $this->assertNull($page->navigationTimeout); + } + + public function test_open_checkpoint_restores_the_run_ceilings_even_on_error(): void + { + $log = new \ArrayObject(); + $page = $this->page($log); + $page->navigationTimeout = 30000; + $page->menuThrows = new \RuntimeException('Entrée du menu introuvable : #subtab-Nope'); + $login = $this->login($log); + $login->navigationTimeout = 30000; + $login->loginOutcomeTimeout = 45000; + $s = $this->suite(page: $page, login: $login); + + try { + $s->openBackOfficeCheckpoint(['name' => 'picker', 'menu' => '#subtab-Nope'], 25000, 15000); + $this->fail('RuntimeException attendue'); + } catch (\RuntimeException) { + } + + $this->assertSame([15000, 15000], $page->navTimeouts); + $this->assertSame(30000, $page->navigationTimeout); + $this->assertSame(30000, $login->navigationTimeout); + $this->assertSame(45000, $login->loginOutcomeTimeout); + } + + public function test_open_checkpoint_masks_the_token_of_a_failed_page_load(): void + { + $log = new \ArrayObject(); + $page = $this->page($log); + $cause = new ResponseHasError('Cannot load page for url: "http://shop.test/admin-dev/index.php?controller=AdminProducts&token=abc123def". Reason: net::ERR_CONNECTION_RESET'); + $page->menuThrows = $cause; + $s = $this->suite(page: $page, login: $this->login($log)); + + try { + $s->openBackOfficeCheckpoint(['name' => 'picker', 'menu' => '#subtab-AdminProducts']); + $this->fail('RuntimeException attendue'); + } catch (BackOfficeTimeoutException $e) { + $this->fail('Pas un délai : '.$e->getMessage()); + } catch (\RuntimeException $e) { + $this->assertStringNotContainsString('abc123def', $e->getMessage()); + $this->assertStringContainsString('controller=AdminProducts&token=…', $e->getMessage()); + $this->assertSame($cause, $e->getPrevious()); + } + } + + public function test_open_checkpoint_masks_url_credentials_and_tokens_of_a_login_error(): void + { + $log = new \ArrayObject(); + $login = $this->login($log); + $login->navThrows = new ResponseHasError('Cannot load page for url: "https://admin:s3cret@shop.test/admin-dev/index.php?_token=xyz789". Reason: net::ERR_ABORTED'); + $s = $this->suite(page: $this->page($log), login: $login); + + try { + $s->openBackOfficeCheckpoint(['name' => 'picker']); + $this->fail('RuntimeException attendue'); + } catch (\RuntimeException $e) { + $this->assertStringStartsWith('Connexion au back-office impossible : ', $e->getMessage()); + $this->assertStringNotContainsString('s3cret', $e->getMessage()); + $this->assertStringNotContainsString('xyz789', $e->getMessage()); + $this->assertStringContainsString('https://…@shop.test/admin-dev/index.php?_token=…', $e->getMessage()); + $this->assertNotNull($e->getPrevious()); + } + } + + public function test_open_checkpoint_treats_a_timed_out_page_load_as_a_timeout(): void + { + $log = new \ArrayObject(); + $page = $this->page($log); + $page->navThrows = new ResponseHasError('Cannot load page for url: "http://shop.test/admin-dev/?token=abc". Reason: net::ERR_TIMED_OUT'); + $s = $this->suite(page: $page, login: $this->login($log)); + + try { + $s->openBackOfficeCheckpoint(['name' => 'picker'], 25000, 15000); + $this->fail('BackOfficeTimeoutException attendue'); + } catch (BackOfficeTimeoutException $e) { + $this->assertStringContainsString('délai imparti', $e->getMessage()); + $this->assertInstanceOf(ResponseHasError::class, $e->getPrevious()); + } + } + + public function test_open_checkpoint_accepts_a_login_seen_late_by_is_logged_in(): void + { + // Issue non vue dans le plafond, mais le lien de déconnexion est là ensuite. + $log = new \ArrayObject(); + $login = $this->login($log, ok: true); + $login->outcome = false; + $s = $this->suite(page: $this->page($log), login: $login); + + $s->openBackOfficeCheckpoint(['name' => 'picker']); + + $this->assertSame(['login:page index', 'login:submit', 'login:check', 'page index'], $log->getArrayCopy()); + } } From 7e672dbefca87ae58fccd6cecf2a6e2d350a5556 Mon Sep 17 00:00:00 2001 From: Jonathan Danse Date: Tue, 6 Oct 2026 15:38:02 +0200 Subject: [PATCH 07/10] docs(spec): openBackOfficeCheckpoint renvoie le chemin sans jeton, sous un budget global Co-Authored-By: Claude Opus 5.5 --- .../2026-10-06-bo-picker-snapshot-design.md | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/specs/2026-10-06-bo-picker-snapshot-design.md b/docs/superpowers/specs/2026-10-06-bo-picker-snapshot-design.md index 2e75ecd..ec4ca30 100644 --- a/docs/superpowers/specs/2026-10-06-bo-picker-snapshot-design.md +++ b/docs/superpowers/specs/2026-10-06-bo-picker-snapshot-design.md @@ -36,7 +36,9 @@ connexion et le menu n'existent qu'à l'intérieur du run de `VisualTestsSuite` ### `VisualTestsSuite::openBackOfficeCheckpoint()` et `closeBackOfficeSession()` -- `openBackOfficeCheckpoint(array $checkpoint, int $loginTimeoutMs = 25000, int $menuTimeoutMs = 15000): void`, publique. +- `openBackOfficeCheckpoint(array $checkpoint, int $loginTimeoutMs = 25000, int $menuTimeoutMs = 15000): string`, publique. + Elle renvoie le chemin et le contrôleur de la page ouverte, sans jeton (`''` si + illisible). - Elle ouvre, dans le navigateur courant de la suite (`TestsSuite::getPage()`), la page d'un checkpoint BO, **avec le même code que le run** : - le checkpoint est normalisé par `normalize()` ; @@ -44,15 +46,27 @@ connexion et le menu n'existent qu'à l'intérieur du run de `VisualTestsSuite` formulaire de connexion est absent ; - sinon : `ensureBackOfficeLogin()`, puis `goToPage('index')` et, si `menu` est défini, `goToMenu($menu)`. - - Les plafonds bornent l'attente de l'issue de la connexion (`waitForLoginOutcome`) - et la navigation du menu. Un dépassement lève une exception de délai dédiée, - au message lisible. + - Budget global : échéance = début + `$loginTimeoutMs` + `$menuTimeoutMs` (40 s + par défaut). Chaque étape plafonnée reçoit min(son plafond, reste avant + l'échéance). Une étape n'est pas lancée s'il reste moins de 1 s. + - Navigations (page de connexion, tableau de bord, menu) : min(`$menuTimeoutMs`, reste). + - Envoi du formulaire : seulement s'il reste au moins 11 s, car `login()` attend + d'abord un rechargement de 10 s fixes (`waitForPageReload()`). L'issue de la + connexion (`waitForLoginOutcome`) reçoit max(1 s, min(`$loginTimeoutMs` − 10 s, + reste − 10 s)). + - Pire cas : échéance + 5 s (`isLoggedIn()`). La capture et la déconnexion sont + hors de ce budget. + - Un dépassement lève une exception de délai dédiée + (`BackOfficeTimeoutException`), au message lisible (« … dans le délai + imparti (N s) »). + - Les messages relayés ne portent ni jeton ni identifiants d'URL (`user:pass@`). + La cause d'origine reste en `previous`. - Les erreurs sont celles du run (identifiants refusés avec le message du formulaire, page inattendue avec son chemin, entrée de menu introuvable). Elles sont relevées comme `\RuntimeException`, avec le message déjà formulé par le run. - Elle exige `area === 'bo'`, sinon `\LogicException`. -- `closeBackOfficeSession(): void`, publique : déconnexion par le lien +- `closeBackOfficeSession(int $timeoutMs = 5000): void`, publique : déconnexion par le lien `#header_logout` de la page courante (`BackOffice\Login\Page::logout()`) si une session est ouverte. Elle ne lève jamais : un échec de déconnexion ne doit pas faire échouer la capture. From 7324de9026f726831bec4b3aed3b74cb3d34d578 Mon Sep 17 00:00:00 2001 From: Jonathan Danse Date: Tue, 6 Oct 2026 15:46:20 +0200 Subject: [PATCH 08/10] =?UTF-8?q?fix(visual):=20budget=20BO=20r=C3=A9serva?= =?UTF-8?q?nt=20le=20constat=20de=20connexion,=20jetons=20encod=C3=A9s=20m?= =?UTF-8?q?asqu=C3=A9s,=20issue=20de=20connexion=20remise=20=C3=A0=20z?= =?UTF-8?q?=C3=A9ro?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - openBackOfficeCheckpoint : l'issue de la connexion réserve aussi les 5 s d'isLoggedIn() ; formulaire envoyé seulement s'il reste 16 s. - Masquage des jetons encodés (%26token%3D, %3F_token%3D) et en entité HTML, sans toucher un « @ » de requête. - Login\Page::login() remet loginOutcomeSeen à null en tête. - Doubles fidèles aux plafonds ; délais de captureCurrent()/take() vérifiés ; docblocks (captureCurrent, CommonPage::$navigationTimeout). Co-Authored-By: Claude Opus 5.5 --- src/Pages/CommonPage.php | 2 + src/Pages/v9/BackOffice/Login/Page.php | 2 + src/Tests/VisualTestsSuite.php | 35 +++-- src/Visual/PageSnapshot.php | 5 + .../Unit/Pages/BackOfficeLoginOutcomeTest.php | 12 ++ tests/Unit/Visual/PageSnapshotTest.php | 40 +++++- .../Visual/VisualTestsSuiteBackOfficeTest.php | 125 +++++++++++++++--- 7 files changed, 186 insertions(+), 35 deletions(-) diff --git a/src/Pages/CommonPage.php b/src/Pages/CommonPage.php index 17c979f..c24ca20 100644 --- a/src/Pages/CommonPage.php +++ b/src/Pages/CommonPage.php @@ -48,6 +48,8 @@ class CommonPage * goToPage() / goToMenu() ; null = défaut de chrome-php (30 s). Posé puis * rétabli par VisualTestsSuite::openBackOfficeCheckpoint() pour le * sélecteur visuel de l'app, qui doit rester sous les 60 s de nginx. + * FrontOfficePage::goToUrl() redéfinit goToUrl() et ne l'applique pas + * (goToUrlInPlace(), qui passe par CommonPage::goToUrl(), l'applique). */ public ?int $navigationTimeout = null; diff --git a/src/Pages/v9/BackOffice/Login/Page.php b/src/Pages/v9/BackOffice/Login/Page.php index 3bd5b03..85c8931 100644 --- a/src/Pages/v9/BackOffice/Login/Page.php +++ b/src/Pages/v9/BackOffice/Login/Page.php @@ -54,6 +54,8 @@ public function defineMessages() */ public function login($email = null, $password = null, $waitForNavigation = true) { + // Issue de CETTE connexion seulement : sans attente, elle reste inconnue. + $this->loginOutcomeSeen = null; if ($email === null) { $email = $this->getGlobal('BO_EMAIL'); } diff --git a/src/Tests/VisualTestsSuite.php b/src/Tests/VisualTestsSuite.php index 5604d2d..376c9c8 100644 --- a/src/Tests/VisualTestsSuite.php +++ b/src/Tests/VisualTestsSuite.php @@ -71,6 +71,13 @@ abstract class VisualTestsSuite extends TestsSuite */ private const PAGE_RELOAD_MS = 10000; + /** + * Constat de la session après l'issue de la connexion + * (Login\Page::isLoggedIn(), lien de déconnexion attendu 5 s au plus) : + * réservé lui aussi dans le budget de openBackOfficeCheckpoint(). + */ + private const LOGIN_CHECK_MS = 5000; + /** Plus petit plafond d'étape accordé par openBackOfficeCheckpoint(). */ private const MIN_STEP_MS = 1000; @@ -485,13 +492,15 @@ protected function ensureBackOfficeLogin(?object $login): void * (BackOfficeTimeoutException) : * - navigation de la page de connexion, du tableau de bord, du menu : * min($menuTimeoutMs, reste) (CommonPage::$navigationTimeout) ; - * - envoi du formulaire : seulement s'il reste au moins PAGE_RELOAD_MS + 1 s, - * car Login\Page::login() attend d'abord un rechargement de 10 s fixes - * (CommonPage::waitForPageReload()) ; l'issue de la connexion - * (Login\Page::$loginOutcomeTimeout) reçoit - * max(1 s, min($loginTimeoutMs - 10 s, reste - 10 s)). - * Pire cas : échéance + 5 s (Login\Page::isLoggedIn(), qui suit l'issue sans - * plafond réglable), plus les lectures JS et le remplissage du formulaire. + * - envoi du formulaire : seulement s'il reste au moins 16 s + * (PAGE_RELOAD_MS + LOGIN_CHECK_MS + 1 s), car Login\Page::login() attend + * d'abord un rechargement de 10 s fixes (CommonPage::waitForPageReload()), + * et Login\Page::isLoggedIn() jusqu'à 5 s après l'issue ; l'issue de la + * connexion (Login\Page::$loginOutcomeTimeout) reçoit + * max(1 s, min($loginTimeoutMs, reste) - 10 s - 5 s). Si $loginTimeoutMs + + * $menuTimeoutMs < 17 s, le formulaire n'est donc jamais envoyé. + * Pire cas : échéance + lectures JS et remplissage du formulaire (≤ 5 s + * chacun), non plafonnés par ce budget. * La capture (PageSnapshot::captureCurrent()) et la déconnexion * (closeBackOfficeSession()) sont hors de ce budget. Les plafonds d'avant * l'appel (ceux du run) sont rétablis avant de rendre la main, même en cas @@ -550,8 +559,8 @@ public function openBackOfficeCheckpoint(array $checkpoint, int $loginTimeoutMs if ($login !== null) { $login->navigationTimeout = $left($menuTimeoutMs); $this->boBeforeLoginSubmit = static function (object $login) use ($left, $loginTimeoutMs): void { - $rest = $left(PHP_INT_MAX, self::PAGE_RELOAD_MS + self::MIN_STEP_MS); - $login->loginOutcomeTimeout = max(self::MIN_STEP_MS, min($loginTimeoutMs, $rest) - self::PAGE_RELOAD_MS); + $rest = $left(PHP_INT_MAX, self::PAGE_RELOAD_MS + self::LOGIN_CHECK_MS + self::MIN_STEP_MS); + $login->loginOutcomeTimeout = max(self::MIN_STEP_MS, min($loginTimeoutMs, $rest) - self::PAGE_RELOAD_MS - self::LOGIN_CHECK_MS); }; } try { @@ -667,14 +676,16 @@ private function capBackOfficeWaits(object $page, ?object $login): \Closure } /** - * Masque jetons (`token=`, `_token=`) et identifiants d'URL + * Masque jetons (`token=`, `_token=`, encodés ou en entité HTML) et identifiants d'URL * (`scheme://user:pass@`) d'un message relayé à l'utilisateur. */ private static function redactUrls(string $message): string { - $message = preg_replace('~([?&]_?token=)[^&"\s]+~i', '$1…', $message) ?? $message; + // Jeton en clair, encodé (%26token%3D, %3F_token%3D) ou en entité HTML (&token=). + $message = preg_replace('~((?:[?&;]|%26|%3F)_?token(?:=|%3D))[^&"\'\s%<>]+~i', '$1…', $message) ?? $message; - return preg_replace('~\b([a-z][a-z0-9+.\-]*://)[^\s/"@]+@~i', '$1…@', $message) ?? $message; + // Identifiants avant l'hôte seulement : un « @ » de requête (?email=a@b.com) reste. + return preg_replace('~\b([a-z][a-z0-9+.\-]*://)[^\s/?#"\'@]+@~i', '$1…@', $message) ?? $message; } /** diff --git a/src/Visual/PageSnapshot.php b/src/Visual/PageSnapshot.php index dca3108..182e691 100644 --- a/src/Visual/PageSnapshot.php +++ b/src/Visual/PageSnapshot.php @@ -84,6 +84,11 @@ public function take(string $url, string $device = 'desktop', int $timeoutMs = 2 * son navigateur appartiennent à l'appelant (take(), ou l'app qui a ouvert * une page back-office avec VisualTestsSuite::openBackOfficeCheckpoint()). * + * Modifie l'onglet : animations et transitions figées, défilement remis en + * haut de page. Les exceptions de chrome-php (délai, onglet fermé…) remontent + * brutes : seul take() enveloppe en SnapshotException le lancement du + * navigateur et la navigation, rien après. + * * @param object $page onglet chrome-php (\HeadlessChromium\Page) ; typé object * pour les doubles de test, comme waitUntilStable() */ diff --git a/tests/Unit/Pages/BackOfficeLoginOutcomeTest.php b/tests/Unit/Pages/BackOfficeLoginOutcomeTest.php index cae41dd..56e1e71 100644 --- a/tests/Unit/Pages/BackOfficeLoginOutcomeTest.php +++ b/tests/Unit/Pages/BackOfficeLoginOutcomeTest.php @@ -186,4 +186,16 @@ public function testAnOutcomeNotSeenWithinTheCeilingIsRecorded(): void $this->assertFalse($page->loginOutcomeSeen); } + + public function testALoginWithoutWaitForgetsThePreviousOutcome(): void + { + $page = $this->page(); + $page->settlesOnPoll = 1; + $page->login(); + $this->assertTrue($page->loginOutcomeSeen); + + $page->login(waitForNavigation: false); + + $this->assertNull($page->loginOutcomeSeen); + } } diff --git a/tests/Unit/Visual/PageSnapshotTest.php b/tests/Unit/Visual/PageSnapshotTest.php index 696e58b..bb3efb0 100644 --- a/tests/Unit/Visual/PageSnapshotTest.php +++ b/tests/Unit/Visual/PageSnapshotTest.php @@ -18,7 +18,8 @@ final class PageSnapshotTest extends TestCase private function snapshot(array $values = [], ?\Throwable $navigateThrows = null, bool $factoryThrows = false): PageSnapshot { - $this->rec = ['log' => [], 'event' => null, 'closed' => false, 'options' => null, 'clip' => null, 'shot' => null]; + // timeouts : délais reçus par getReturnValue() / getBase64() (null non noté) + $this->rec = ['log' => [], 'event' => null, 'closed' => false, 'options' => null, 'clip' => null, 'shot' => null, 'timeouts' => []]; $rec = &$this->rec; $values += [ 'stable' => true, @@ -91,13 +92,19 @@ public function evaluate(string $js) $v = $this->values['map']; } - return new class ($v) { - public function __construct(private $v) + $rec = &$this->rec; + + return new class ($rec, $v) { + public function __construct(private array &$rec, private $v) { } public function getReturnValue($timeout = null) { + if ($timeout !== null) { + $this->rec['timeouts'][] = 'value '.$timeout; + } + return $this->v; } }; @@ -109,9 +116,19 @@ public function screenshot(array $opts = []) $this->rec['clip'] = $opts['clip'] ?? null; $this->rec['shot'] = $opts; - return new class { + $rec = &$this->rec; + + return new class ($rec) { + public function __construct(private array &$rec) + { + } + public function getBase64($timeout = null) { + if ($timeout !== null) { + $this->rec['timeouts'][] = 'base64 '.$timeout; + } + return base64_encode('JPEGDATA'); } }; @@ -222,4 +239,19 @@ public function test_take_gives_what_capture_current_gives_after_navigating(): v $this->assertEquals($taken, $current); } + + public function test_take_bounds_the_map_and_the_screenshot_by_its_default_timeout(): void + { + $this->snapshot()->take('https://shop.test/', 'desktop'); + + $this->assertSame(['value 20000', 'base64 20000'], $this->rec['timeouts']); + } + + public function test_capture_current_bounds_the_map_and_the_screenshot_by_its_default_timeout(): void + { + $snapshot = $this->snapshot(); + $snapshot->captureCurrent(($this->factory)([])->createPage()); + + $this->assertSame(['value 15000', 'base64 15000'], $this->rec['timeouts']); + } } diff --git a/tests/Unit/Visual/VisualTestsSuiteBackOfficeTest.php b/tests/Unit/Visual/VisualTestsSuiteBackOfficeTest.php index 2158186..2f11902 100644 --- a/tests/Unit/Visual/VisualTestsSuiteBackOfficeTest.php +++ b/tests/Unit/Visual/VisualTestsSuiteBackOfficeTest.php @@ -23,8 +23,11 @@ protected function setUp(): void public string $location = ''; /** Horloge factice (ms) lue par la suite (nowMs()), avancée par les doubles. */ public int $now = 0; + /** Durée simulée de chaque lecture JS (≤ 5 s, comme une lecture réelle bornée). */ + public int $evalCostMs = 0; public function evaluate(string $js): object { + $this->now += $this->evalCostMs; $value = match (true) { str_contains($js, '#email') => $this->form, str_contains($js, '.alert-danger') => $this->error, @@ -111,6 +114,11 @@ public function goToPage($page = null, $params = null): void { $this->log[] = 'page '.$page; $this->navTimeouts[] = $this->navigationTimeout; + // Fidèle à chrome-php : au-delà du plafond, la navigation expire. + if ($this->navigationTimeout !== null && $this->navCostMs > $this->navigationTimeout) { + $this->chrome->now += $this->navigationTimeout; + throw new OperationTimedOut('Operation timed out after '.$this->navigationTimeout.'ms'); + } $this->chrome->now += $this->navCostMs; if ($this->navThrows !== null) { throw $this->navThrows; @@ -120,6 +128,11 @@ public function goToMenu(string $selectors): string { $this->log[] = 'menu '.$selectors; $this->navTimeouts[] = $this->navigationTimeout; + // Fidèle à chrome-php : au-delà du plafond, la navigation expire. + if ($this->navigationTimeout !== null && $this->navCostMs > $this->navigationTimeout) { + $this->chrome->now += $this->navigationTimeout; + throw new OperationTimedOut('Operation timed out after '.$this->navigationTimeout.'ms'); + } $this->chrome->now += $this->navCostMs; if ($this->menuThrows !== null) { throw $this->menuThrows; @@ -160,7 +173,7 @@ private function login(\ArrayObject $log, bool $ok = true): object public array $navTimeouts = []; public array $outcomeTimeouts = []; public array $logoutTimeouts = []; - /** Durées simulées : navigation, envoi du formulaire (rechargement + issue). */ + /** Durées simulées : navigation, envoi du formulaire (rechargement + issue + constat). */ public int $navCostMs = 0; public int $submitCostMs = 0; public function __construct(public \ArrayObject $log, public bool $ok, public object $chrome) {} @@ -173,6 +186,11 @@ public function goToPage($page = null, $params = null): void { $this->log[] = 'login:page '.$page; $this->navTimeouts[] = $this->navigationTimeout; + // Fidèle à chrome-php : au-delà du plafond, la navigation expire. + if ($this->navigationTimeout !== null && $this->navCostMs > $this->navigationTimeout) { + $this->chrome->now += $this->navigationTimeout; + throw new OperationTimedOut('Operation timed out after '.$this->navigationTimeout.'ms'); + } $this->chrome->now += $this->navCostMs; if ($this->navThrows !== null) { throw $this->navThrows; @@ -182,8 +200,11 @@ public function login($email = null, $password = null, $waitForNavigation = true { $this->log[] = 'login:submit'; $this->outcomeTimeouts[] = $this->loginOutcomeTimeout; - $this->loginOutcomeSeen = $this->outcome; - $this->chrome->now += $this->submitCostMs; + // Fidèle à Login\Page : rechargement (10 s) + issue (plafond) + isLoggedIn() (5 s) + // au plus ; au-delà, l'issue n'est pas vue. + $max = 10000 + $this->loginOutcomeTimeout + 5000; + $this->loginOutcomeSeen = $this->outcome && $this->submitCostMs <= $max; + $this->chrome->now += min($this->submitCostMs, $max); } public function isLoggedIn(): bool { $this->log[] = 'login:check'; return $this->ok; } public function logout(): void @@ -490,8 +511,9 @@ public function test_open_checkpoint_logs_in_then_opens_the_dashboard_then_the_m // Chemin et contrôleur, jamais le jeton. $this->assertSame('/admin-dev/index.php?controller=AdminProducts', $where); // Plafonds pendant l'ouverture, valeurs du run rétablies ensuite. L'issue de - // la connexion a le plafond moins les 10 s du rechargement qui la précède. - $this->assertSame([15000], $login->outcomeTimeouts); + // la connexion a le plafond moins les 10 s du rechargement qui la précède et + // les 5 s du constat (isLoggedIn()) qui la suit. + $this->assertSame([10000], $login->outcomeTimeouts); $this->assertSame([15000], $login->navTimeouts); $this->assertSame([15000, 15000], $page->navTimeouts); $this->assertNull($page->navigationTimeout); @@ -700,46 +722,52 @@ public function test_open_checkpoint_caps_each_step_to_what_is_left_of_the_globa // Échéance = 25 + 15 = 40 s après le début. $log = new \ArrayObject(); $page = $this->page($log); - $page->navCostMs = 10000; + $page->navCostMs = 3000; $login = $this->login($log); - $login->navCostMs = 12000; // page de connexion : t = 12 s - $login->submitCostMs = 14000; // rechargement + issue : t = 26 s + $login->navCostMs = 12000; // page de connexion (plafond 15 s) : t = 12 s + $login->submitCostMs = 14000; // rechargement + issue + constat (≤ 25 s) : t = 26 s $s = $this->suite(page: $page, login: $login); $s->openBackOfficeCheckpoint(['name' => 'picker', 'menu' => '#subtab-AdminProducts'], 25000, 15000); $this->assertSame([15000], $login->navTimeouts); - // Reste 28 s à l'envoi : issue = min(25 - 10, 28 - 10) = 15 s. - $this->assertSame([15000], $login->outcomeTimeouts); - // Tableau de bord : reste 14 s ; menu (t = 36 s) : reste 4 s. - $this->assertSame([14000, 4000], $page->navTimeouts); + // Reste 28 s à l'envoi : issue = min(25, 28) - 10 - 5 = 10 s. + $this->assertSame([10000], $login->outcomeTimeouts); + // Tableau de bord : reste 14 s ; menu (t = 29 s) : reste 11 s. + $this->assertSame([14000, 11000], $page->navTimeouts); } public function test_open_checkpoint_caps_the_login_outcome_to_what_is_left(): void { $log = new \ArrayObject(); + // Navigation au plafond (15 s) puis lecture du formulaire (4 s) : reste 21 s à + // l'envoi, moins que le plafond de connexion (25 s). + $this->chrome->evalCostMs = 4000; $login = $this->login($log); - $login->navCostMs = 20000; // reste 20 s à l'envoi + $login->navCostMs = 15000; $s = $this->suite(page: $this->page($log), login: $login); $s->openBackOfficeCheckpoint(['name' => 'picker'], 25000, 15000); - // min(25 - 10, 20 - 10) = 10 s : rechargement + issue tiennent dans le reste. - $this->assertSame([10000], $login->outcomeTimeouts); + // min(25, 21) - 10 - 5 = 6 s : rechargement, issue et constat tiennent dans le reste. + $this->assertSame([6000], $login->outcomeTimeouts); } public function test_open_checkpoint_does_not_submit_the_login_when_the_reload_no_longer_fits(): void { $log = new \ArrayObject(); + // Échéance 10 + 15 = 25 s ; page de connexion en 12 s (plafond 15 s) : reste + // 13 s < 10 s de rechargement + 5 s de constat + 1 s. $login = $this->login($log); - $login->navCostMs = 30000; // reste 10 s < 10 s de rechargement + 1 s + $login->navCostMs = 12000; $s = $this->suite(page: $this->page($log), login: $login); try { - $s->openBackOfficeCheckpoint(['name' => 'picker'], 25000, 15000); + $s->openBackOfficeCheckpoint(['name' => 'picker'], 10000, 15000); $this->fail('BackOfficeTimeoutException attendue'); } catch (BackOfficeTimeoutException $e) { - $this->assertStringContainsString('délai imparti (40 s au total)', $e->getMessage()); + $this->assertSame("Le back-office n'a pas répondu dans le délai imparti (25 s au total).", $e->getMessage()); + $this->assertNull($e->getPrevious()); } $this->assertNotContains('login:submit', $log->getArrayCopy()); $this->assertSame(60000, $login->loginOutcomeTimeout); @@ -751,7 +779,7 @@ public function test_open_checkpoint_stops_before_a_step_once_the_deadline_is_re $page = $this->page($log); $login = $this->login($log); $login->navCostMs = 15000; - $login->submitCostMs = 25000; // t = 40 s : échéance atteinte + $login->submitCostMs = 25000; // 10 + 10 + 5 s au plus : t = 40 s, échéance atteinte $s = $this->suite(page: $page, login: $login); try { @@ -855,4 +883,63 @@ public function test_open_checkpoint_accepts_a_login_seen_late_by_is_logged_in() $this->assertSame(['login:page index', 'login:submit', 'login:check', 'page index'], $log->getArrayCopy()); } + + public function test_the_login_ceiling_hook_does_not_outlive_its_opening(): void + { + // Ouverture refusée par la closure (reste insuffisant à l'envoi), puis run + // sur la même instance : le plafond du run (60 s) s'applique de nouveau. + $login = $this->login($log = new \ArrayObject()); + $login->navCostMs = 12000; + $page = $this->page($log); + $s = $this->suite(page: $page, login: $login); + try { + $s->openBackOfficeCheckpoint(['name' => 'picker'], 10000, 15000); + $this->fail('BackOfficeTimeoutException attendue'); + } catch (BackOfficeTimeoutException) { + } + $log->exchangeArray([]); + + $s->init(); + $this->runSteps($s); + + $fresh = new \ArrayObject(); + $other = $this->suite(page: $this->page($fresh), login: $this->login($fresh)); + $other->init(); + $this->runSteps($other); + + $this->assertSame(60000, end($login->outcomeTimeouts)); + $this->assertSame($fresh->getArrayCopy(), $log->getArrayCopy()); + } + + public static function urlsToRedact(): array + { + return [ + 'jeton' => ['http://shop.test/admin-dev/index.php?controller=AdminProducts&token=abc123', 'http://shop.test/admin-dev/index.php?controller=AdminProducts&token=…'], + 'jeton _token en tête' => ['http://shop.test/admin-dev/?_token=abc123', 'http://shop.test/admin-dev/?_token=…'], + 'jeton encodé %26' => ['http://shop.test/r?back=x%26token%3Dabc123', 'http://shop.test/r?back=x%26token%3D…'], + 'jeton encodé %3F' => ['http://shop.test/r?back=y%3F_token%3Dxyz789', 'http://shop.test/r?back=y%3F_token%3D…'], + 'jeton en entité HTML' => ['http://shop.test/a?controller=X&token=html123', 'http://shop.test/a?controller=X&token=…'], + "guillemet simple conservé" => ["'http://shop.test/a?token=abc123'", "'http://shop.test/a?token=…'"], + 'identifiants' => ['https://admin:s3cret@shop.test/admin-dev/', 'https://…@shop.test/admin-dev/'], + 'arobase de requête, hôte gardé' => ['https://shop.test?email=a@b.com', 'https://shop.test?email=a@b.com'], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('urlsToRedact')] + public function test_open_checkpoint_redacts_relayed_urls(string $url, string $expected): void + { + $log = new \ArrayObject(); + $page = $this->page($log); + $page->menuThrows = new ResponseHasError('Cannot load page for url: "'.$url.'". Reason: net::ERR_ABORTED'); + $s = $this->suite(page: $page, login: $this->login($log)); + + try { + $s->openBackOfficeCheckpoint(['name' => 'picker', 'menu' => '#subtab-AdminProducts']); + $this->fail('RuntimeException attendue'); + } catch (BackOfficeTimeoutException $e) { + $this->fail('Pas un délai : '.$e->getMessage()); + } catch (\RuntimeException $e) { + $this->assertSame('Cannot load page for url: "'.$expected.'". Reason: net::ERR_ABORTED', $e->getMessage()); + } + } } From 96f2ff27d2cafc0224c110f70e42620d4132ebee Mon Sep 17 00:00:00 2001 From: Jonathan Danse Date: Tue, 6 Oct 2026 15:46:21 +0200 Subject: [PATCH 09/10] =?UTF-8?q?docs(spec):=20pire=20cas=20du=20budget=20?= =?UTF-8?q?BO=20align=C3=A9=20sur=20le=20docblock=20(constat=20de=20connex?= =?UTF-8?q?ion=20r=C3=A9serv=C3=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../specs/2026-10-06-bo-picker-snapshot-design.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/specs/2026-10-06-bo-picker-snapshot-design.md b/docs/superpowers/specs/2026-10-06-bo-picker-snapshot-design.md index ec4ca30..2aee7ea 100644 --- a/docs/superpowers/specs/2026-10-06-bo-picker-snapshot-design.md +++ b/docs/superpowers/specs/2026-10-06-bo-picker-snapshot-design.md @@ -50,11 +50,14 @@ connexion et le menu n'existent qu'à l'intérieur du run de `VisualTestsSuite` par défaut). Chaque étape plafonnée reçoit min(son plafond, reste avant l'échéance). Une étape n'est pas lancée s'il reste moins de 1 s. - Navigations (page de connexion, tableau de bord, menu) : min(`$menuTimeoutMs`, reste). - - Envoi du formulaire : seulement s'il reste au moins 11 s, car `login()` attend - d'abord un rechargement de 10 s fixes (`waitForPageReload()`). L'issue de la - connexion (`waitForLoginOutcome`) reçoit max(1 s, min(`$loginTimeoutMs` − 10 s, - reste − 10 s)). - - Pire cas : échéance + 5 s (`isLoggedIn()`). La capture et la déconnexion sont + - Envoi du formulaire : seulement s'il reste au moins 16 s. `login()` attend + d'abord un rechargement de 10 s fixes (`waitForPageReload()`), et + `isLoggedIn()` jusqu'à 5 s après l'issue. L'issue de la connexion + (`waitForLoginOutcome`) reçoit max(1 s, min(`$loginTimeoutMs`, reste) − 10 s + − 5 s). Si `$loginTimeoutMs` + `$menuTimeoutMs` < 17 s, le formulaire n'est + jamais envoyé. + - Pire cas : échéance + lectures JS et remplissage du formulaire (≤ 5 s + chacun), non plafonnés par ce budget. La capture et la déconnexion sont hors de ce budget. - Un dépassement lève une exception de délai dédiée (`BackOfficeTimeoutException`), au message lisible (« … dans le délai From 636039772d72f66fa7c32b37abc828d3bdec22e3 Mon Sep 17 00:00:00 2001 From: Jonathan Danse Date: Tue, 6 Oct 2026 15:51:03 +0200 Subject: [PATCH 10/10] =?UTF-8?q?docs:=20relecture=20finale=20=E2=80=94=20?= =?UTF-8?q?signature=20captureCurrent,=20ensureBackOfficeLogin,=20session?= =?UTF-8?q?=20reprise=20;=20suivi=20des=20t=C3=A2ches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- ...026-10-06-bo-picker-snapshot.md.tasks.json | 76 +++++++++---------- .../2026-10-06-bo-picker-snapshot-design.md | 8 +- src/Tests/VisualTestsSuite.php | 4 +- 3 files changed, 46 insertions(+), 42 deletions(-) diff --git a/docs/superpowers/plans/2026-10-06-bo-picker-snapshot.md.tasks.json b/docs/superpowers/plans/2026-10-06-bo-picker-snapshot.md.tasks.json index 55c925b..729e211 100644 --- a/docs/superpowers/plans/2026-10-06-bo-picker-snapshot.md.tasks.json +++ b/docs/superpowers/plans/2026-10-06-bo-picker-snapshot.md.tasks.json @@ -1,39 +1,39 @@ { - "planPath": "docs/superpowers/plans/2026-10-06-bo-picker-snapshot.md", - "tasks": [ - { - "id": 1, - "subject": "Task 1 : `PageSnapshot::captureCurrent()`", - "status": "pending", - "description": "Voir le plan, section « Task 1 : `PageSnapshot::captureCurrent()` ».\n\n```json:metadata\n{\"files\": [\"src/Visual/PageSnapshot.php\", \"tests/Unit/Visual/PageSnapshotTest.php\"], \"verifyCommand\": \"`php vendor/bin/phpunit tests/Unit/Visual/PageSnapshotTest.php` → OK (8 tests)\", \"acceptanceCriteria\": [\"`captureCurrent(object $page, int $timeoutMs = 15000): SnapshotResult` fait, dans l'ordre : stabilité, `SETTLE_ANIMATIONS`, statut, dimensions (hauteur plafonnée à `MAX_HEIGHT`), `scrollTo(0, 0)`, `ELEMENT_MAP`, capture JPEG qualité 80.\", \"Elle ne navigue pas et ne ferme pas le navigateur.\", \"`take()` crée le navigateur, navigue (même message d'erreur), appelle `captureCurrent($page, $timeoutMs)` et ferme le navigateur dans son `finally`.\", \"Les 6 tests existants de `PageSnapshotTest` passent sans modification de leurs assertions.\", \"`take()` et `captureCurrent()` donnent le même `SnapshotResult` sur la même page factice.\"]}\n```" - }, - { - "id": 2, - "subject": "Task 2 : plafonds de navigation et de connexion, sans changer le run", - "status": "pending", - "description": "Voir le plan, section « Task 2 : plafonds de navigation et de connexion, sans changer le run ».\n\n```json:metadata\n{\"files\": [\"src/Pages/CommonPage.php\", \"src/Pages/BackOfficePage.php\", \"src/Pages/v9/BackOffice/Login/Page.php\", \"tests/Unit/Pages/BackOfficeGoToMenuTest.php\", \"tests/Unit/Pages/BackOfficeLoginOutcomeTest.php\"], \"verifyCommand\": \"`php vendor/bin/phpunit tests/Unit/Pages/BackOfficeGoToMenuTest.php tests/Unit/Pages/BackOfficeLoginOutcomeTest.php` → OK\", \"acceptanceCriteria\": [\"`public ?int $navigationTimeout = null;` sur `CommonPage`, transmis comme 2e argument de `waitForNavigation()` par `goToUrl()`, `BackOfficePage::goToPage()` et `BackOfficePage::goToMenu()`. `null` = défaut chrome-php (30 s), donc comportement actuel.\", \"`public int $loginOutcomeTimeout = 60000;` et `public ?bool $loginOutcomeSeen = null;` sur `v9\\\\BackOffice\\\\Login\\\\Page` (hérités par v7 et v8).\", \"`login()` (avec `$waitForNavigation`) appelle `waitForLoginOutcome($this->loginOutcomeTimeout)` et range le booléen renvoyé dans `$loginOutcomeSeen`.\", \"Signatures de `login()`, `waitForLoginOutcome()`, `goToPage()`, `goToMenu()` et `goToUrl()` inchangées.\"]}\n```", - "blockedBy": [ - 1 - ] - }, - { - "id": 3, - "subject": "Task 3 : `openBackOfficeCheckpoint()`, `closeBackOfficeSession()` et `BackOfficeTimeoutException`", - "status": "pending", - "description": "Voir le plan, section « Task 3 : `openBackOfficeCheckpoint()`, `closeBackOfficeSession()` et `BackOfficeTimeoutException` ».\n\n```json:metadata\n{\"files\": [\"src/Exceptions/BackOfficeTimeoutException.php\", \"src/Tests/VisualTestsSuite.php\", \"tests/Unit/Visual/VisualTestsSuiteBackOfficeTest.php\"], \"verifyCommand\": \"`php vendor/bin/phpunit tests/Unit/Visual/VisualTestsSuiteBackOfficeTest.php` → OK\", \"acceptanceCriteria\": [\"`openBackOfficeCheckpoint(array $checkpoint, int $loginTimeoutMs = 25000, int $menuTimeoutMs = 15000): string` :\", \"`closeBackOfficeSession(int $timeoutMs = 5000): void` appelle `logout()` de la page Login une seule fois si une session est ouverte, sous le plafond donné (rétabli ensuite), et ne lève jamais.\", \"Les tests existants de `VisualTestsSuiteBackOfficeTest` passent sans changement de leurs assertions (journaux identiques).\"]}\n```", - "blockedBy": [ - 2 - ] - }, - { - "id": 4, - "subject": "Task 4 : livraison (coordinateur)", - "status": "pending", - "description": "Voir le plan, section « Task 4 : livraison (coordinateur) ».\n\n```json:metadata\n{\"files\": [], \"verifyCommand\": \"\", \"acceptanceCriteria\": [\"Relecture finale (`rtk proxy git diff dev...HEAD > /private/tmp/claude-501/-Users-jonathan-Documents-GitHub-PrestaFlow-app/2393cd17-28d4-44c5-96f2-fa24f981a8c8/scratchpad/lib-branch.diff`). Points à vérifier :\", \"Suite complète verte en 8.4 et en 8.1 (sorties redirigées).\", \"Push de `feat/bo-picker-snapshot`, PR vers `dev` (titre : « Capture du sélecteur visuel en back-office »), description terminée par la ligne d'attribution prévue. CI verte.\", \"Sur demande seulement, dans cet ordre :\"]}\n```", - "blockedBy": [ - 3 - ] - } - ], - "lastUpdated": "2026-10-06T13:19:01.564365+00:00" -} \ No newline at end of file + "planPath": "docs/superpowers/plans/2026-10-06-bo-picker-snapshot.md", + "tasks": [ + { + "id": 1, + "subject": "Task 1 : `PageSnapshot::captureCurrent()`", + "status": "completed", + "description": "Voir le plan, section « Task 1 : `PageSnapshot::captureCurrent()` ».\n\n```json:metadata\n{\"files\": [\"src/Visual/PageSnapshot.php\", \"tests/Unit/Visual/PageSnapshotTest.php\"], \"verifyCommand\": \"`php vendor/bin/phpunit tests/Unit/Visual/PageSnapshotTest.php` → OK (8 tests)\", \"acceptanceCriteria\": [\"`captureCurrent(object $page, int $timeoutMs = 15000): SnapshotResult` fait, dans l'ordre : stabilité, `SETTLE_ANIMATIONS`, statut, dimensions (hauteur plafonnée à `MAX_HEIGHT`), `scrollTo(0, 0)`, `ELEMENT_MAP`, capture JPEG qualité 80.\", \"Elle ne navigue pas et ne ferme pas le navigateur.\", \"`take()` crée le navigateur, navigue (même message d'erreur), appelle `captureCurrent($page, $timeoutMs)` et ferme le navigateur dans son `finally`.\", \"Les 6 tests existants de `PageSnapshotTest` passent sans modification de leurs assertions.\", \"`take()` et `captureCurrent()` donnent le même `SnapshotResult` sur la même page factice.\"]}\n```" + }, + { + "id": 2, + "subject": "Task 2 : plafonds de navigation et de connexion, sans changer le run", + "status": "completed", + "description": "Voir le plan, section « Task 2 : plafonds de navigation et de connexion, sans changer le run ».\n\n```json:metadata\n{\"files\": [\"src/Pages/CommonPage.php\", \"src/Pages/BackOfficePage.php\", \"src/Pages/v9/BackOffice/Login/Page.php\", \"tests/Unit/Pages/BackOfficeGoToMenuTest.php\", \"tests/Unit/Pages/BackOfficeLoginOutcomeTest.php\"], \"verifyCommand\": \"`php vendor/bin/phpunit tests/Unit/Pages/BackOfficeGoToMenuTest.php tests/Unit/Pages/BackOfficeLoginOutcomeTest.php` → OK\", \"acceptanceCriteria\": [\"`public ?int $navigationTimeout = null;` sur `CommonPage`, transmis comme 2e argument de `waitForNavigation()` par `goToUrl()`, `BackOfficePage::goToPage()` et `BackOfficePage::goToMenu()`. `null` = défaut chrome-php (30 s), donc comportement actuel.\", \"`public int $loginOutcomeTimeout = 60000;` et `public ?bool $loginOutcomeSeen = null;` sur `v9\\\\BackOffice\\\\Login\\\\Page` (hérités par v7 et v8).\", \"`login()` (avec `$waitForNavigation`) appelle `waitForLoginOutcome($this->loginOutcomeTimeout)` et range le booléen renvoyé dans `$loginOutcomeSeen`.\", \"Signatures de `login()`, `waitForLoginOutcome()`, `goToPage()`, `goToMenu()` et `goToUrl()` inchangées.\"]}\n```", + "blockedBy": [ + 1 + ] + }, + { + "id": 3, + "subject": "Task 3 : `openBackOfficeCheckpoint()`, `closeBackOfficeSession()` et `BackOfficeTimeoutException`", + "status": "completed", + "description": "Voir le plan, section « Task 3 : `openBackOfficeCheckpoint()`, `closeBackOfficeSession()` et `BackOfficeTimeoutException` ».\n\n```json:metadata\n{\"files\": [\"src/Exceptions/BackOfficeTimeoutException.php\", \"src/Tests/VisualTestsSuite.php\", \"tests/Unit/Visual/VisualTestsSuiteBackOfficeTest.php\"], \"verifyCommand\": \"`php vendor/bin/phpunit tests/Unit/Visual/VisualTestsSuiteBackOfficeTest.php` → OK\", \"acceptanceCriteria\": [\"`openBackOfficeCheckpoint(array $checkpoint, int $loginTimeoutMs = 25000, int $menuTimeoutMs = 15000): string` :\", \"`closeBackOfficeSession(int $timeoutMs = 5000): void` appelle `logout()` de la page Login une seule fois si une session est ouverte, sous le plafond donné (rétabli ensuite), et ne lève jamais.\", \"Les tests existants de `VisualTestsSuiteBackOfficeTest` passent sans changement de leurs assertions (journaux identiques).\"]}\n```", + "blockedBy": [ + 2 + ] + }, + { + "id": 4, + "subject": "Task 4 : livraison (coordinateur)", + "status": "in_progress", + "description": "Voir le plan, section « Task 4 : livraison (coordinateur) ».\n\n```json:metadata\n{\"files\": [], \"verifyCommand\": \"\", \"acceptanceCriteria\": [\"Relecture finale (`rtk proxy git diff dev...HEAD > /private/tmp/claude-501/-Users-jonathan-Documents-GitHub-PrestaFlow-app/2393cd17-28d4-44c5-96f2-fa24f981a8c8/scratchpad/lib-branch.diff`). Points à vérifier :\", \"Suite complète verte en 8.4 et en 8.1 (sorties redirigées).\", \"Push de `feat/bo-picker-snapshot`, PR vers `dev` (titre : « Capture du sélecteur visuel en back-office »), description terminée par la ligne d'attribution prévue. CI verte.\", \"Sur demande seulement, dans cet ordre :\"]}\n```", + "blockedBy": [ + 3 + ] + } + ], + "lastUpdated": "2026-10-06T13:51:03+00:00" +} diff --git a/docs/superpowers/specs/2026-10-06-bo-picker-snapshot-design.md b/docs/superpowers/specs/2026-10-06-bo-picker-snapshot-design.md index 2aee7ea..24705e8 100644 --- a/docs/superpowers/specs/2026-10-06-bo-picker-snapshot-design.md +++ b/docs/superpowers/specs/2026-10-06-bo-picker-snapshot-design.md @@ -23,7 +23,7 @@ connexion et le menu n'existent qu'à l'intérieur du run de `VisualTestsSuite` ### `PageSnapshot::captureCurrent()` - Nouvelle méthode publique : - `captureCurrent(\HeadlessChromium\Page $page, int $timeoutMs = 15000): SnapshotResult`. + `captureCurrent(object $page, int $timeoutMs = 15000): SnapshotResult` (typé `object` pour les doubles de test ; en pratique une `\HeadlessChromium\Page`). - Elle contient tout ce que `take()` fait après la navigation : - attente de stabilité ; - `PageScripts::SETTLE_ANIMATIONS` ; @@ -73,8 +73,10 @@ connexion et le menu n'existent qu'à l'intérieur du run de `VisualTestsSuite` `#header_logout` de la page courante (`BackOffice\Login\Page::logout()`) si une session est ouverte. Elle ne lève jamais : un échec de déconnexion ne doit pas faire échouer la capture. -- Aucune autre méthode du run ne change. `ensureBackOfficeLogin` et `goToMenu` - restent la seule implémentation de la connexion et du menu. +- Aucune autre méthode du run ne change de comportement. `ensureBackOfficeLogin` et + `goToMenu` restent la seule implémentation de la connexion et du menu ; + `ensureBackOfficeLogin` gagne seulement la cause en `previous` et un point + d’accroche privé avant l’envoi du formulaire, inactif pendant un run. ### Tests de la lib diff --git a/src/Tests/VisualTestsSuite.php b/src/Tests/VisualTestsSuite.php index 376c9c8..6842153 100644 --- a/src/Tests/VisualTestsSuite.php +++ b/src/Tests/VisualTestsSuite.php @@ -484,7 +484,9 @@ protected function ensureBackOfficeLogin(?object $login): void * qui capture ensuite la page avec PageSnapshot::captureCurrent(). * * Une ouverture par instance de suite : l'état de connexion (boLoggedIn, - * erreur, cause) est remis à zéro à chaque appel, qui se reconnecte donc. + * erreur, cause) est remis à zéro à chaque appel ; une session encore ouverte + * dans le navigateur est reprise telle quelle (appeler closeBackOfficeSession() + * avant un checkpoint auth: false). * * Budget : échéance globale = début + $loginTimeoutMs + $menuTimeoutMs * (40 s par défaut). Chaque étape plafonnée reçoit min(son plafond, reste