diff --git a/.github/workflows/live-smoke.yml b/.github/workflows/live-smoke.yml index 3b9cc82..53044ff 100644 --- a/.github/workflows/live-smoke.yml +++ b/.github/workflows/live-smoke.yml @@ -121,10 +121,24 @@ jobs: php bin/prestaflow run src/Tests/Suites/Smoke/BackOfficeSmoke.php || status=1 exit $status + # Says what is on disk before the upload decides there is nothing to + # take. A red row on 2026-09-25 printed a screenshot path in the runner + # log and still produced an empty artifact, and `if-no-files-found: + # ignore` made the upload report success while carrying nothing — so the + # one run that needed evidence left none. Locally the capture writes a + # valid PNG, including on the flaky failures, so the gap is here rather + # than in the library. + - name: List what the run captured + if: failure() + run: | + echo "cwd: $(pwd)" + ls -laR prestaflow/screens/ 2>&1 || echo "prestaflow/screens/ does not exist" + - name: Upload failure screenshots if: failure() uses: actions/upload-artifact@v4 with: name: screenshots-${{ matrix.ps }}-${{ matrix.theme }} path: prestaflow/screens/ - if-no-files-found: ignore + # `warn`, not `ignore`: an upload that finds nothing has to say so. + if-no-files-found: warn diff --git a/src/Pages/CommonPage.php b/src/Pages/CommonPage.php index d6207c9..4c6905c 100644 --- a/src/Pages/CommonPage.php +++ b/src/Pages/CommonPage.php @@ -280,6 +280,18 @@ protected function themePageChains(array $pageNames): array { $chains = []; + // The area's shared block, least specific of all. A selector declared + // on the area base (FrontOfficePage's desktopLogo, userInfoLink, ...) + // is inherited by every page in the area, but its own chain -- + // "FrontOffice\Page" -- is dropped by the guard below, so there was + // nowhere to theme it once. The alternative was repeating it in all 31 + // page blocks. "_common" cannot collide with a page name: page segments + // come from class namespaces and never start with an underscore. + $area = $pageNames[0] ?? ''; + if (is_string($area) && $area !== '' && $area !== 'Page') { + $chains[] = [$area, '_common']; + } + // array_reverse puts the furthest ancestor first, which is the order we // want to merge in. foreach (array_reverse(array_values(class_parents($this) ?: [])) as $class) { diff --git a/src/Pages/v7/FrontOffice/Registration/Page.php b/src/Pages/v7/FrontOffice/Registration/Page.php index ff7f17e..d610797 100644 --- a/src/Pages/v7/FrontOffice/Registration/Page.php +++ b/src/Pages/v7/FrontOffice/Registration/Page.php @@ -4,6 +4,36 @@ use PrestaFlow\Library\Pages\v9\FrontOffice\Registration\Page as V9Page; +/** + * 1.7 has no /registration route: it was introduced in 8.0, and asking a + * 1.7.8 shop for it lands on pagenotfound. Account creation lives behind the + * login page's create_account flag, and the resulting page is body#authentication, + * not body#registration -- so the two selectors anchored on that id have to move + * with the route. + * + * Everything else the form needs is already identical: #field-firstname, + * #field-lastname, #field-email, #field-password, #field-birthday, + * [data-link-action="save-customer"] and the two required consent boxes are all + * present on 1.7.8.11, which is why only these three things are overridden. + */ class Page extends V9Page { + public function defineSelectors() + { + return [ + ...parent::defineSelectors(), + 'registrationForm' => 'body#authentication', + 'requiredConsentCheckbox' => 'body#authentication input[type="checkbox"][required]', + ]; + } + + public function goToRegistration(): void + { + // Built from the login URL rather than hardcoded, so a project that + // remapped "login" in its own Urls catalogue keeps its route. Passing + // the flag through goToPage()'s $params would drop it: substitution + // only replaces {placeholders} the template already carries, and the + // login template has none. + $this->goToUrl($this->getPageURL('login') . '?create_account=1'); + } } diff --git a/src/Tests/Suites/Smoke/FrontOfficeSmoke.php b/src/Tests/Suites/Smoke/FrontOfficeSmoke.php index b22de64..f1af80a 100644 --- a/src/Tests/Suites/Smoke/FrontOfficeSmoke.php +++ b/src/Tests/Suites/Smoke/FrontOfficeSmoke.php @@ -30,6 +30,10 @@ public function init() $this->importPage('FrontOffice\Product'); $this->importPage('FrontOffice\Cart'); $this->importPage('FrontOffice\Category'); + // A page with no selectors of its own: everything it resolves comes + // from the area base, which is exactly what the theme _common block + // has to reach. + $this->importPage('FrontOffice\Stores'); extract($this->pages); @@ -90,6 +94,44 @@ public function init() $frontOfficeCategoryPage->goToProduct(1); Expect::that($frontOfficeProductPage->getPrice() > 0)->equals(true); + }) + /* + * The header chrome is declared once on FrontOfficePage and inherited + * by all 31 front-office pages, so nothing page-specific ever asserted + * it. Measured on a live 9.2 shop, both selectors miss on hummingbird + * on every page -- a whole theme's worth of markup that no suite + * touched. Asserting them here puts the area-wide selectors under the + * same matrix as everything else, on a page that declares none of its + * own. + */ + ->it('the shared header chrome resolves on this theme', function () use ($frontOfficeStoresPage) { + $frontOfficeStoresPage->goToPage('stores'); + + Expect::that($frontOfficeStoresPage->elementIsVisible($frontOfficeStoresPage->getSelector('desktopLogo'), 5000))->equals(true); + Expect::that($frontOfficeStoresPage->elementIsVisible($frontOfficeStoresPage->getSelector('userInfoLink'), 5000))->equals(true); + }) + /* + * accountLink only exists once a session is open, so every anonymous + * probe reported it missing on BOTH themes -- the one shape that looks + * like "no divergence" and hides one. Measured against a logged-in + * session it misses on hummingbird and matches on Classic, which is why + * it needs the scenario below rather than another anonymous step. + * + * Registration, not EnsureTestAccount: the latter logs in with the + * FO_EMAIL / FO_PASSWD defaults, which match PrestaShop's demo customer + * (pub@prestashop.com / 123456789) and therefore only work on a shop + * that carries demo data. Its register-if-missing fallback cannot stand + * in, because 9.2 rejects that same password -- the shop answers "The + * minimum score must be: Strong" -- so on a shop without the fixture, + * such as a duplicated second shop, both branches fail. Registration + * creates its own account with a unique address and a policy-compliant + * password, so it needs nothing from the shop's fixtures. + */ + ->scenario(\PrestaFlow\Library\Scenarios\Registration::class) + ->it('the logged-in header chrome resolves on this theme', function () use ($frontOfficeStoresPage) { + $frontOfficeStoresPage->goToPage('stores'); + + Expect::that($frontOfficeStoresPage->elementIsVisible($frontOfficeStoresPage->getSelector('accountLink'), 5000))->equals(true); }); } } diff --git a/src/Themes/hummingbird.json b/src/Themes/hummingbird.json index ecc4c62..b84cf18 100644 --- a/src/Themes/hummingbird.json +++ b/src/Themes/hummingbird.json @@ -1,8 +1,12 @@ { "FrontOffice": { + "_common": { + "desktopLogo": ".header-bottom__logo", + "userInfoLink": ".ps-customersignin", + "accountLink": ".ps-customersignin a[href*=\"my-account\"]" + }, "Home": { "homePageSection": "#content.page-content--home", - "desktopLogo": ".header-bottom__logo", "allProductsLink": ".ps-featuredproducts .module-products__buttons a" }, "Product": { diff --git a/tests/Unit/Pages/ThemeInheritanceTest.php b/tests/Unit/Pages/ThemeInheritanceTest.php index ba1403e..b235589 100644 --- a/tests/Unit/Pages/ThemeInheritanceTest.php +++ b/tests/Unit/Pages/ThemeInheritanceTest.php @@ -241,5 +241,127 @@ public function testNestedArraysAreNeverMergedAsSelectors(): void $this->assertSame('.from-category', $selectors['productArticle']); $this->assertArrayNotHasKey('Nested', $selectors); } + + /* + * A selector declared on the AREA base (FrontOfficePage: desktopLogo, + * userInfoLink, ...) is inherited by every page in that area, but the + * catalog had nowhere to express it: the chain "FrontOffice\\Page" + * strips to a single usable segment and is dropped by trap 1, so the + * only way to theme such a selector was to repeat it in all 31 page + * blocks. Measured on a real 9.2 shop, desktopLogo and userInfoLink + * miss on hummingbird on every one of 18 front-office pages, which is + * exactly the shape that duplication would have to cover. + * + * "_common" is the reserved block for that. It cannot collide with a + * page name: page segments come from class namespaces and never start + * with an underscore. + */ + public function testACommonBlockAppliesToAPageWithNoBlockOfItsOwn(): void + { + $dir = $this->writeTheme('hummingbird', [ + 'FrontOffice' => [ + '_common' => ['desktopLogo' => '.header-bottom__logo'], + ], + ]); + + $page = new ProductPage(['THEME' => 'hummingbird'], ['desktopLogo' => '#_desktop_logo']); + $page->themeDirs = [$dir]; + + $this->assertSame('.header-bottom__logo', $page->getSelectors()['desktopLogo']); + } + + /** The common block is the least specific tier: any page block beats it. */ + public function testAPageBlockBeatsTheCommonBlock(): void + { + $dir = $this->writeTheme('hummingbird', [ + 'FrontOffice' => [ + '_common' => ['desktopLogo' => '.from-common'], + 'Product' => ['desktopLogo' => '.from-product'], + ], + ]); + + $page = new ProductPage(['THEME' => 'hummingbird'], ['desktopLogo' => '#_desktop_logo']); + $page->themeDirs = [$dir]; + + $this->assertSame('.from-product', $page->getSelectors()['desktopLogo']); + } + + /** ... and a parent PAGE block beats it too, not just the concrete one. */ + public function testAParentPageBlockBeatsTheCommonBlock(): void + { + $dir = $this->writeTheme('hummingbird', [ + 'FrontOffice' => [ + '_common' => ['productArticle' => '.from-common'], + 'Listing' => ['productArticle' => '.from-listing'], + ], + ]); + + $page = new CategoryPage(['THEME' => 'hummingbird'], ['productArticle' => '.base']); + $page->themeDirs = [$dir]; + + $this->assertSame('.from-listing', $page->getSelectors()['productArticle']); + } + + /** Keys the page block does not mention still come through. */ + public function testCommonAndPageBlocksAreMergedNotReplaced(): void + { + $dir = $this->writeTheme('hummingbird', [ + 'FrontOffice' => [ + '_common' => ['desktopLogo' => '.logo', 'userInfoLink' => '.user'], + 'Product' => ['desktopLogo' => '.product-logo'], + ], + ]); + + $page = new ProductPage( + ['THEME' => 'hummingbird'], + ['desktopLogo' => '#_desktop_logo', 'userInfoLink' => '#_desktop_user_info'] + ); + $page->themeDirs = [$dir]; + + $selectors = $page->getSelectors(); + + $this->assertSame('.product-logo', $selectors['desktopLogo']); + $this->assertSame('.user', $selectors['userInfoLink']); + } + + /** + * A common block belongs to its area. Without this the reserved key + * would become a global, and a BackOffice override would start + * rewriting FrontOffice selectors. + */ + public function testACommonBlockDoesNotLeakAcrossAreas(): void + { + $dir = $this->writeTheme('hummingbird', [ + 'BackOffice' => [ + '_common' => ['desktopLogo' => '.back-office-logo'], + ], + ]); + + $page = new ProductPage(['THEME' => 'hummingbird'], ['desktopLogo' => '#_desktop_logo']); + $page->themeDirs = [$dir]; + + $this->assertSame('#_desktop_logo', $page->getSelectors()['desktopLogo']); + } + + /** Trap 2 still applies inside the reserved block. */ + public function testNestedArraysInTheCommonBlockAreNotMergedAsSelectors(): void + { + $dir = $this->writeTheme('hummingbird', [ + 'FrontOffice' => [ + '_common' => [ + 'desktopLogo' => '.logo', + 'Nested' => ['desktopLogo' => '.too-deep'], + ], + ], + ]); + + $page = new ProductPage(['THEME' => 'hummingbird'], []); + $page->themeDirs = [$dir]; + + $selectors = $page->getSelectors(); + + $this->assertSame('.logo', $selectors['desktopLogo']); + $this->assertArrayNotHasKey('Nested', $selectors); + } } } diff --git a/tests/Unit/Pages/V7RegistrationOverridesTest.php b/tests/Unit/Pages/V7RegistrationOverridesTest.php new file mode 100644 index 0000000..8df3d00 --- /dev/null +++ b/tests/Unit/Pages/V7RegistrationOverridesTest.php @@ -0,0 +1,61 @@ + ['URL' => 'http://shop.test/'], 'THEME' => 'classic'], []); + } + + public function testTheTwoIdAnchoredSelectorsMoveToTheAuthenticationPage(): void + { + $selectors = $this->page(V7Registration::class)->selectors; + + $this->assertSame('body#authentication', $selectors['registrationForm']); + $this->assertSame( + 'body#authentication input[type="checkbox"][required]', + $selectors['requiredConsentCheckbox'] + ); + } + + /** THE fragile part: everything the parent declares must survive. */ + public function testEveryOtherFieldIsInheritedFromV9(): void + { + $v9 = $this->page(V9Registration::class)->selectors; + $v7 = $this->page(V7Registration::class)->selectors; + + $moved = ['registrationForm', 'requiredConsentCheckbox']; + + foreach ($v9 as $key => $value) { + if (in_array($key, $moved, true)) { + continue; + } + + $this->assertArrayHasKey($key, $v7, sprintf('v7 dropped the "%s" selector', $key)); + $this->assertSame($value, $v7[$key], sprintf('v7 changed "%s" without reason', $key)); + } + } + + /** The overrides are additions, not a replacement of the parent map. */ + public function testTheOverrideDoesNotShrinkTheSelectorMap(): void + { + $this->assertCount( + count($this->page(V9Registration::class)->selectors), + $this->page(V7Registration::class)->selectors + ); + } +}