Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion .github/workflows/live-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
12 changes: 12 additions & 0 deletions src/Pages/CommonPage.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
30 changes: 30 additions & 0 deletions src/Pages/v7/FrontOffice/Registration/Page.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
}
42 changes: 42 additions & 0 deletions src/Tests/Suites/Smoke/FrontOfficeSmoke.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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);
});
}
}
6 changes: 5 additions & 1 deletion src/Themes/hummingbird.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
122 changes: 122 additions & 0 deletions tests/Unit/Pages/ThemeInheritanceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
61 changes: 61 additions & 0 deletions tests/Unit/Pages/V7RegistrationOverridesTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

namespace PrestaFlow\Tests\Unit\Pages;

use PHPUnit\Framework\TestCase;
use PrestaFlow\Library\Pages\v7\FrontOffice\Registration\Page as V7Registration;
use PrestaFlow\Library\Pages\v9\FrontOffice\Registration\Page as V9Registration;

/**
* 1.7 reaches account creation through the login page, and the page it lands on
* is body#authentication. The override that encodes this re-declares two
* selectors on top of the parent map, which is the fragile part: written as a
* plain array instead of a spread it would silently drop every other field the
* form needs, and the failure would surface as a missing input rather than as a
* missing override.
*/
final class V7RegistrationOverridesTest extends TestCase
{
private function page(string $class): object
{
return new $class('en', '1.7.8.11', ['FO' => ['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
);
}
}
Loading