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: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,22 @@ The back-office area also uses `PRESTAFLOW_BO_URL` (admin URL), `PRESTAFLOW_BO_E

CSS transitions are frozen during back-office captures (`protected ?bool $freezeTransitions`; `null` = on for `bo`, off for `fo`). See `src/Tests/Suites/Visual/BackOffice.php`.

### Shop and back-office URLs in the suite file

A visual suite may pin its own URLs (the PrestaFlow app writes them from its editor). A non-empty value replaces `PRESTAFLOW_FO_URL` / `PRESTAFLOW_BO_URL` (or the URLs sent by the app) before the first checkpoint:

```php
protected ?string $shopUrl = 'http://localhost:8093/';
protected ?string $backOfficeUrl = 'admin123/'; // relative: appended to the effective shop URL
```

`null` or empty: the environment URL. Rules:

- Only `http(s)` URLs with a host; no whitespace; `$shopUrl` takes no query (`?`) or fragment (`#`).
- A relative back-office URL is appended to the effective shop URL: the shop path is kept, its query and fragment are dropped (`http://x.test/shop/` + `admin123/` gives `http://x.test/shop/admin123/`). `//host/admin` takes the shop URL scheme. A relative path must be safe: no `?`/`#`, no segment starting with a dot, no dot in the first segment unless it starts with `/` (`shop.test/admin` is refused, not guessed). It needs an `http(s)` shop URL, else it is refused.
- When only `$shopUrl` is set, a back-office URL that the library completed from the environment shop URL (relative `PRESTAFLOW_BO_URL`, or the default `admin-dev/`) follows the new shop URL; an absolute `PRESTAFLOW_BO_URL`, or one sent by the app, is kept.
- Credentials (`user:pass@`) are refused at launch, without echoing the URL: use `PRESTAFLOW_BASIC_USER` / `PRESTAFLOW_BASIC_PASS`. The same goes for every other refusal: the message names the property, never the value.

### Visual picker API

The visual picker of the PrestaFlow app captures a page and lists its elements, so that a user can pick a checkpoint selector. It uses the run's own code, under short ceilings.
Expand Down
66 changes: 66 additions & 0 deletions docs/superpowers/specs/2026-10-07-suite-urls-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# URL de la boutique et du back-office dans le fichier de suite visuelle : design (lib)

Date : 2026-10-07
Statut : validé, à implémenter

Partie lib de la spec de l'app (`docs/superpowers/specs/2026-10-07-visual-suite-urls-design.md`
du dépôt `PrestaFlow/app`), recopiée ici au départ de la branche `feat/suite-urls`.

## Problème

L'URL de la boutique et l'URL du back-office d'une suite visuelle viennent des globals
(`PRESTAFLOW_FO_URL` / `PRESTAFLOW_BO_URL` en CLI, ou celles que l'app passe au lancement).
L'éditeur de l'app permet de les choisir par suite, mais le fichier PHP n'en garde pas trace :
un run en CI avec la lib seule ignore l'URL choisie.

## Décisions

- `VisualTestsSuite` gagne :
- `protected ?string $shopUrl = null;`
- `protected ?string $backOfficeUrl = null;`
- Au lancement de la suite (avant le premier checkpoint, et avant l'import des pages, qui copient
les globals), une propriété chaîne non vide remplace l'URL correspondante des globals
(`FO.URL`, `BO.URL`). `null`, vide ou blanche : URL reçue.
- URL BO relative (ex. `admin123/`, sans schéma) : complétée par l'URL FO effective (propriété,
sinon globals) avec la règle de l'app (`App\Support\BackOfficeUrl::resolve`) : URL FO sans
requête ni fragment, puis le chemin relatif ; `//hôte/admin` prend le schéma de l'URL FO ;
toujours terminée par « / ». Le chemin d'une BO relative doit être sûr (règle de
`BackOfficeUrl::isValid` : pas de blanc, `?` ni `#`, pas de segment commençant par un point, pas de
point dans le premier segment sans « / » initial) ; sinon refusée, jamais devinée.
- Avec `$shopUrl` seule : une URL BO que `loadGlobals()` a complétée sur l'URL FO de l'environnement
(`PRESTAFLOW_BO_URL` relatif, ou défaut `admin-dev/`) est recalculée sur la nouvelle URL FO
(valeur brute mémorisée par `loadGlobals()`, remise à null par `setGlobals()` quand `BO` est fourni) ;
une BO absolue ou fournie par l'app reste telle quelle.
- Refusées au lancement (`InvalidArgumentException`), avec un message qui nomme la propriété mais
jamais la valeur :
- identifiants dans l'URL (`scheme://user:pass@…`, `//user@…`) : l'authentification HTTP passe
par `PRESTAFLOW_BASIC_USER` / `PRESTAFLOW_BASIC_PASS` ;
- schéma autre que `http` / `https` (FO sans schéma compris), ou hôte absent ;
- blanc interne ; `?` ou `#` dans `$shopUrl` ; chemin BO relatif non sûr ;
- BO relative (`admin123/`, `//hôte/admin`) sans URL FO http(s) pour la compléter.
- Les suites qui ne déclarent pas ces propriétés gardent exactement leur comportement.

## API

- `VisualTestsSuite::applySuiteUrls(): void` : publique, idempotente ; appelée par `init()` et
`openBackOfficeCheckpoint()` (sélecteur visuel de l'app).
- `VisualTestsSuite::resolveBackOfficeUrl(string $backOffice, string $frontOffice): string` :
statique publique.

## Tests

`tests/Unit/Visual/VisualTestsSuiteUrlsTest.php`, sans Chrome :

- suite sans propriété, propriétés vides : globals inchangées ;
- `$shopUrl` prioritaire, visible des pages à leur import, `resolveUrl()` la suit ;
- `$backOfficeUrl` absolue prioritaire ; relative complétée par l'URL FO du fichier, sinon des
globals ; relative au protocole ;
- BO relative sans aucune URL FO refusée ;
- idempotence ;
- `user:pass@` et schéma non `http(s)` refusés sans afficher la valeur ;
- `openBackOfficeCheckpoint()` applique les URL avant d'importer les pages.

## Livraison

PR vers `dev`, puis release `dev` → `main` en commits de merge, sur accord. L'app fait ensuite
le bump (`composer.lock` seul).
14 changes: 14 additions & 0 deletions src/Tests/TestsSuite.php
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,14 @@ public static function resetBrowser(): void
protected $draft = false;
protected $groups = 'all';

/**
* Valeur brute de l'URL BO quand loadGlobals() l'a complétée à partir de
* l'URL FO de l'environnement (PRESTAFLOW_BO_URL relative, ou défaut
* `admin-dev/`) ; null sinon (BO absolue, globals fournies). Permet à
* VisualTestsSuite::applySuiteUrls() de suivre une $shopUrl du fichier.
*/
protected ?string $backOfficeRelative = null;

public function __construct(bool $loadGlobals = true, bool $getBrowser = true)
{
if ($loadGlobals) {
Expand Down Expand Up @@ -1100,6 +1108,10 @@ public function setLocale(string $locale)
public function setGlobals(array $globals = [])
{
$this->globals = array_merge($this->globals, $globals);
if (isset($globals['BO'])) {
// URL BO fournie : plus de valeur brute à recompléter.
$this->backOfficeRelative = null;
}

$this->resolveVersion();
$this->setLocale($this->globals['LOCALE'] ?? 'en');
Expand Down Expand Up @@ -1174,7 +1186,9 @@ public function loadGlobals()
}

$backOfficeUrl = Env::get('PRESTAFLOW_BO_URL', $frontOfficeUrl . 'admin-dev/');
$this->backOfficeRelative = Env::has('PRESTAFLOW_BO_URL') ? null : 'admin-dev/';
if (!str_starts_with($backOfficeUrl, 'https://') && !str_starts_with($backOfficeUrl, 'http://')) {
$this->backOfficeRelative = (string) $backOfficeUrl;
$backOfficeUrl = $frontOfficeUrl . $backOfficeUrl;
}
if (!str_ends_with($backOfficeUrl, '/')) {
Expand Down
154 changes: 152 additions & 2 deletions src/Tests/VisualTestsSuite.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@

/**
* Suite de régression visuelle pilotée par données. Les sous-classes ne
* déclarent que $devices, $locales et $checkpoints (littéraux, édités par l'app).
* déclarent que $devices, $locales et $checkpoints (littéraux, édités par l'app),
* et au besoin $shopUrl / $backOfficeUrl : URL propres à la suite, prioritaires
* sur celles de l'environnement ou de l'app (applySuiteUrls()).
* Une exécution couvre UNE combinaison device × locale (globals DEVICE / LOCALE,
* ou env PRESTAFLOW_DEVICE / PRESTAFLOW_LOCALE en CLI).
*
Expand Down Expand Up @@ -52,6 +54,24 @@ abstract class VisualTestsSuite extends TestsSuite
/** Gel des transitions CSS pendant la capture ; null = actif en 'bo' seulement (références 'fo' inchangées). */
protected ?bool $freezeTransitions = null;

/**
* URL de la boutique propre à la suite (écrite par l'éditeur de l'app).
* Chaîne non vide : remplace l'URL FO reçue (globals FO.URL : app, ou
* PRESTAFLOW_FO_URL en CLI) avant le premier checkpoint. null ou vide :
* URL de l'environnement. http(s) seulement, sans identifiants (refusée au
* lancement, applySuiteUrls()).
*/
protected ?string $shopUrl = null;

/**
* URL du back-office propre à la suite. Chaîne non vide : remplace l'URL BO
* reçue (globals BO.URL, ou PRESTAFLOW_BO_URL). Absolue http(s), ou
* relative (ex. `admin123/`) complétée par l'URL FO effective
* (resolveBackOfficeUrl()). Sans identifiants : l'authentification HTTP
* passe par PRESTAFLOW_BASIC_USER / PRESTAFLOW_BASIC_PASS.
*/
protected ?string $backOfficeUrl = null;

/**
* État de la connexion BO (une seule tentative par exécution). Protégés et
* non privés : run() lie les étapes à la sous-classe concrète.
Expand Down Expand Up @@ -294,6 +314,8 @@ public function init()
if (!in_array($this->area, ['fo', 'bo'], true)) {
throw new \InvalidArgumentException(sprintf('%s : area « %s » inconnue (fo, bo)', static::class, $this->area));
}
// Avant l'import : les pages copient les globals à leur construction.
$this->applySuiteUrls();
$this->importVisualPage();
$backOffice = $this->area === 'bo';
$page = $this->pages[$backOffice ? 'backOfficePage' : 'frontOfficePage'] ?? null;
Expand Down Expand Up @@ -414,6 +436,132 @@ public function init()
return $this;
}

/**
* Applique $shopUrl et $backOfficeUrl aux globals FO.URL et BO.URL ; une
* propriété null ou vide laisse l'URL reçue. Appelée par init() et
* openBackOfficeCheckpoint(), avant l'import des pages : une page déjà
* importée garde les globals copiées à sa construction. Idempotente.
*
* Avec $shopUrl seule, une URL BO que loadGlobals() a complétée sur l'URL FO de
* l'environnement (PRESTAFLOW_BO_URL relative, ou défaut) suit la nouvelle URL FO ;
* une BO absolue, ou fournie par setGlobals() (app), reste telle quelle.
*
* @throws \InvalidArgumentException URL à identifiants (`user:pass@`), blanc interne, schéma
* autre que http(s), chemin BO relatif non sûr, ou BO relative
* sans URL FO http(s) ; le message ne cite jamais la valeur
*/
public function applySuiteUrls(): void
{
$shop = trim((string) $this->shopUrl);
$backOffice = trim((string) $this->backOfficeUrl);
if ($shop === '' && $backOffice === '') {
return;
}

$globals = $this->getGlobals();
if ($shop !== '') {
$this->assertSuiteUrl('shopUrl', $shop, false);
$globals['FO']['URL'] = str_ends_with($shop, '/') ? $shop : $shop.'/';
}
if ($backOffice !== '') {
$this->assertSuiteUrl('backOfficeUrl', $backOffice, true);
$resolved = self::resolveBackOfficeUrl($backOffice, trim((string) ($globals['FO']['URL'] ?? '')));
if (preg_match('#^https?://#i', $resolved) !== 1) {
throw new \InvalidArgumentException(sprintf('%s : $backOfficeUrl relative sans URL de la boutique http(s) pour la compléter', static::class));
}
$globals['BO']['URL'] = $resolved;
} elseif ($shop !== '' && $this->backOfficeRelative !== null) {
$globals['BO']['URL'] = self::resolveBackOfficeUrl($this->backOfficeRelative, $globals['FO']['URL']);
}
$this->globals = $globals;
}

/**
* URL BO effective, règle de l'app (App\Support\BackOfficeUrl::resolve) :
* absolue http(s) gardée ; relative au protocole (`//hôte/admin`) : schéma
* de l'URL FO ; relative (`admin123/`, `/admin123`) : URL FO sans requête
* ni fragment, puis le chemin. Toujours terminée par « / ».
*/
public static function resolveBackOfficeUrl(string $backOffice, string $frontOffice): string
{
$backOffice = trim($backOffice);
$frontOffice = trim($frontOffice);
if (str_starts_with($backOffice, '//')) {
if (preg_match('#^(https?)://#i', $frontOffice, $m) === 1) {
$backOffice = strtolower($m[1]).':'.$backOffice;
}
} elseif (preg_match('#^https?://#i', $backOffice) !== 1) {
$frontOffice = (string) preg_replace('/[?#].*\z/s', '', $frontOffice);
$backOffice = rtrim($frontOffice, '/').'/'.ltrim($backOffice, '/');
}

return str_ends_with($backOffice, '/') ? $backOffice : $backOffice.'/';
}

/** Refuse blancs, identifiants, schéma non http(s) et chemin relatif non sûr, sans citer l'URL (elle peut porter un mot de passe). */
private function assertSuiteUrl(string $property, string $url, bool $relativeAllowed): void
{
if (preg_match('/\s/u', $url) === 1) {
throw new \InvalidArgumentException(sprintf('%s : $%s refusée : espace ou blanc interdit dans l\'URL', static::class, $property));
}
if (preg_match('#^(?:[a-z][a-z0-9+.\-]*:)?//[^/?\#]*@#i', $url) === 1) {
throw new \InvalidArgumentException(sprintf(
'%s : $%s refusée : identifiants (user:pass@) interdits dans l\'URL ; authentification HTTP par PRESTAFLOW_BASIC_USER / PRESTAFLOW_BASIC_PASS',
static::class,
$property
));
}
$absolute = preg_match('#^https?://#i', $url) === 1;
if ($absolute) {
if ((string) parse_url($url, PHP_URL_HOST) === '') {
throw new \InvalidArgumentException(sprintf('%s : $%s refusée : URL http(s) avec un hôte attendue', static::class, $property));
}
if (!$relativeAllowed && preg_match('/[?#]/', $url) === 1) {
throw new \InvalidArgumentException(sprintf('%s : $%s refusée : requête (?) et fragment (#) interdits', static::class, $property));
}

return;
}
if ($relativeAllowed && str_starts_with($url, '//')) {
if ((string) parse_url($url, PHP_URL_HOST) === '') {
throw new \InvalidArgumentException(sprintf('%s : $%s refusée : URL http(s) avec un hôte attendue', static::class, $property));
}

return;
}
$otherScheme = preg_match('#^[a-z][a-z0-9+.\-]*:#i', $url) === 1;
if ($otherScheme || !$relativeAllowed) {
throw new \InvalidArgumentException(sprintf(
'%s : $%s refusée : URL http:// ou https:// attendue%s',
static::class,
$property,
$relativeAllowed ? ' (ou chemin relatif à la boutique, ex. admin123/)' : ''
));
}
if (!self::isSafeRelativePath($url)) {
throw new \InvalidArgumentException(sprintf(
'%s : $%s refusée : chemin relatif invalide (ex. admin123/ : sans espace, ni ? ou #, ni segment commençant par un point, ni point dans le premier segment sans « / » initial)',
static::class,
$property
));
}
}

/** Chemin relatif sûr : même règle que App\Support\BackOfficeUrl::isValid() hors URL absolue. */
private static function isSafeRelativePath(string $url): bool
{
if (preg_match('#^/?[A-Za-z0-9_~%-][A-Za-z0-9._~%-]*(/[A-Za-z0-9_~%-][A-Za-z0-9._~%-]*)*/?\z#', $url) !== 1) {
return false;
}
foreach (explode('/', trim($url, '/')) as $segment) {
if (str_starts_with(rawurldecode($segment), '.')) {
return false;
}
}

return str_starts_with($url, '/') || !str_contains(explode('/', $url)[0], '.');
}

/**
* Déroulé commun aux deux zones, une fois sur la page : stabilité, attente,
* scroll, capture. Protégé (et non privé) : appelé depuis les étapes, que
Expand Down Expand Up @@ -539,7 +687,9 @@ public function openBackOfficeCheckpoint(array $checkpoint, int $loginTimeoutMs
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.
// init() n'est pas appelé (il enregistrerait les étapes du run) : URL du fichier
// appliquées, puis pages importées ici.
$this->applySuiteUrls();
if (!isset($this->pages['backOfficePage'])) {
$this->importVisualPage();
}
Expand Down
Loading
Loading