PrestaFlow : tester un tunnel de paiement Stripe
Décor
Un tunnel de paiement Stripe casse toutes les habitudes des scénarios PrestaFlow standard :
- Le formulaire de carte n’est pas dans le DOM de la boutique — c’est une
<iframe>servie parjs.stripe.com, cross-origin, immune auxsetValueclassiques. - Le paiement passe potentiellement par un 3DS — nouvelle iframe (Elements) ou redirection (Checkout) vers un challenge géré par la banque du client. La suite doit savoir naviguer là-dedans et revenir.
- La confirmation est en deux temps — Stripe redirige la boutique vers une URL de retour (
/order-confirmation?id_order=…), puis notifie de façon asynchrone, par webhook (payment_intent.succeeded), que le paiement est bien encaissé. Le premier est visible par Chrome, le second non.
Cette annexe couvre les deux intégrations Stripe qu’on rencontre sur PrestaShop :
- Stripe Elements (inline) — le module colle une iframe de carte dans le tunnel PS ; le client ne quitte pas la boutique.
- Stripe Checkout (hosted) — le module redirige vers
checkout.stripe.comoù Stripe prend en charge le formulaire ; retour ensuite sur PS.
Le mode test et les cartes magiques
Stripe fournit un mode test activable par une clé API sk_test_… / pk_test_… — configurez le module PS Stripe avec ces clés dans un .env de test ou un init-script Flashlight, jamais en prod.
Quatre numéros de carte à connaître (en mode test uniquement, avec n’importe quelle date d’expiration future, par exemple 12/34, et n’importe quel CVC à trois chiffres, par exemple 123) :
| Numéro | Comportement |
|---|---|
4242 4242 4242 4242 | Paiement accepté, pas de 3DS |
4000 0025 0000 3155 | Paiement accepté après authentification 3DS (challenge) |
4000 0000 0000 0002 | Paiement refusé par la banque (« Your card was declined ») |
4000 0000 0000 9995 | Fonds insuffisants |
On teste chaque comportement séparément — pas de « scénario global » qui empile les 4.
Écrire une Page pour Stripe Elements
Le formulaire de carte étant dans une iframe cross-origin, on ne peut pas y setValue : le document.querySelector() de la boutique ne voit pas son contenu. Deux outils réels de chrome-php (la version 1.16 livrée avec PrestaFlow 1.7.1) permettent de le contourner :
- la souris et le clavier :
mouse()->move()->click()puiskeyboard()->typeText()passent parInput.dispatch*Eventdu protocole Chrome DevTools (CDP). Chrome route ces événements vers l’élément qui a le focus, iframe cross-origin comprise. C’est ce qu’on utilise pour remplir la carte ; - le CDP brut via
getSession()->sendMessageSync(new Message(...)): on retrouve la frame Stripe (cibleiframedansTarget.getTargets, ou nœud dePage.getFrameTree) et on y exécute du JS avecRuntime.evaluate. C’est ce qu’on utilise pour cliquer dans le challenge 3DS.
Créez tests/prestaflow/Pages/v9/Support/StripeElements/Page.php. importPage() cherche la Page dans le dossier de la version majeure testée (v9 pour PrestaShop 9) ; si vous testez aussi PS 8, dupliquez-la dans v8/ en adaptant le namespace et la classe parente.
<?php
namespace Tests\Pages\v9\Support\StripeElements;
use HeadlessChromium\Communication\Message;
use PrestaFlow\Library\Pages\v9\FrontOffice\Page as BasePage;
class Page extends BasePage
{
public function defineSelectors()
{
return [
// iframe du champ carte (Card Element ou Payment Element) : à vérifier dans les DevTools
'cardIframe' => 'iframe[src*="elements-inner-card"], iframe[src*="elements-inner-payment"]',
];
}
public function fillCard(string $number, string $expiry = '1234', string $cvc = '123'): void
{
$iframe = $this->getSelector('cardIframe');
$this->waitVisible($iframe, 15000);
// Position du champ numéro dans le viewport : bord gauche de l'iframe, à mi-hauteur
$rect = $this->getPage()->evaluate(sprintf(
'(function(){var f=document.querySelector(%s);f.scrollIntoView({block:"center"});'
. 'var r=f.getBoundingClientRect();return {x:r.left+30,y:r.top+r.height/2};})()',
json_encode($iframe)
))->getReturnValue();
// Un vrai clic donne le focus au champ numéro, dans l'iframe
$this->getPage()->mouse()->move($rect['x'], $rect['y'])->click();
// La frappe va à l'élément focus ; Stripe passe seul au champ suivant
// quand un champ est complet (numéro → expiration → CVC)
$this->getPage()->keyboard()->typeText($number . $expiry . $cvc);
}
public function confirm3DS(int $timeoutMs = 15000): void
{
// Page de test Stripe : bouton « Complete » (sélecteur à vérifier)
$js = '(function(){var b=document.querySelector("#test-source-authorize-3ds");'
. 'if(!b)return false;b.click();return true;})()';
$this->waitUntil(
fn () => $this->evaluateInFrames('hooks.stripe.com', $js) === true,
$timeoutMs,
'Bouton de validation 3DS introuvable'
);
}
/**
* Exécute $js dans chaque frame dont l'URL contient $urlPart.
* Renvoie true dès qu'une exécution renvoie true.
*/
private function evaluateInFrames(string $urlPart, string $js): bool
{
$session = $this->getPage()->getSession();
$connection = $session->getConnection();
// 1. Iframe hors processus (isolation des sites, cas par défaut) :
// c'est une cible CDP à part, à laquelle on s'attache.
$targets = $connection->sendMessageSync(new Message('Target.getTargets'))
->getResultData('targetInfos') ?? [];
foreach ($targets as $target) {
if ($target['type'] === 'iframe' && str_contains($target['url'], $urlPart)) {
$frameSession = $connection->createSession($target['targetId']);
$result = $frameSession->sendMessageSync(new Message('Runtime.evaluate', [
'expression' => $js,
'returnByValue' => true,
]))->getResultData('result');
if (($result['value'] ?? null) === true) {
return true;
}
}
}
// 2. Iframe dans le processus de la page : on la trouve dans l'arbre
// des frames et on crée un contexte d'exécution dedans.
$tree = $session->sendMessageSync(new Message('Page.getFrameTree'))->getResultData('frameTree');
foreach ($this->findFrameIds($tree ?? [], $urlPart) as $frameId) {
$contextId = $session->sendMessageSync(new Message('Page.createIsolatedWorld', [
'frameId' => $frameId,
'worldName' => 'prestaflow',
]))->getResultData('executionContextId');
$result = $session->sendMessageSync(new Message('Runtime.evaluate', [
'expression' => $js,
'contextId' => $contextId,
'returnByValue' => true,
]))->getResultData('result');
if (($result['value'] ?? null) === true) {
return true;
}
}
return false;
}
private function findFrameIds(array $node, string $urlPart): array
{
$ids = [];
if (str_contains($node['frame']['url'] ?? '', $urlPart)) {
$ids[] = $node['frame']['id'];
}
foreach ($node['childFrames'] ?? [] as $child) {
$ids = [...$ids, ...$this->findFrameIds($child, $urlPart)];
}
return $ids;
}
}
Scénario 1 : paiement accepté (carte 4242)
Objectif : passer une commande de bout en bout avec Stripe Elements et la carte magique standard.
<?php
namespace Tests\Suites;
use PrestaFlow\Library\Expects\Expect;
use PrestaFlow\Library\Tests\TestsSuite;
class StripeCheckoutAccepted extends TestsSuite
{
public function init()
{
$this->importPage('FrontOffice\Login');
$this->importPage('FrontOffice\Product');
$this->importPage('FrontOffice\Cart');
$this->importPage('FrontOffice\Checkout');
$this->importPage('FrontOffice\OrderConfirmation');
$this->importPage('Support\StripeElements', domain: 'Tests');
extract($this->pages);
$this
->describe('Stripe Elements — paiement accepté carte 4242')
->it('log client', function () use ($frontOfficeLoginPage) {
$frontOfficeLoginPage->goToPage('login');
$frontOfficeLoginPage->login();
})
->it('ajoute un produit et va au checkout', function () use ($frontOfficeProductPage, $frontOfficeCartPage, $frontOfficeCheckoutPage) {
$frontOfficeProductPage->goToProductPath('1-1-hummingbird-printed-t-shirt.html');
$frontOfficeProductPage->addToCart(1);
$frontOfficeCartPage->goToCart();
$frontOfficeCartPage->proceedToCheckout();
$frontOfficeCheckoutPage->confirmAddresses();
$frontOfficeCheckoutPage->chooseShipping();
})
->it('choisit Stripe et remplit la carte', function () use ($frontOfficeCheckoutPage, $supportStripeElementsPage) {
$frontOfficeCheckoutPage->click('input[name="payment-option"][data-module-name="stripe_official"]');
$supportStripeElementsPage->fillCard('4242424242424242');
$frontOfficeCheckoutPage->click($frontOfficeCheckoutPage->getSelector('termsCheckbox'));
$frontOfficeCheckoutPage->click($frontOfficeCheckoutPage->getSelector('placeOrderButton'));
})
->it('atterrit sur la confirmation de commande', function () use ($frontOfficeOrderConfirmationPage) {
// Stripe.js confirme le paiement puis redirige : on attend le bloc de confirmation.
$frontOfficeOrderConfirmationPage->waitVisible(
$frontOfficeOrderConfirmationPage->getSelector('confirmationBlock'),
20000
);
Expect::that($frontOfficeOrderConfirmationPage->isConfirmed())->isTheSameAs(true);
Expect::that($frontOfficeOrderConfirmationPage->getOrderReference())->isNotEmpty();
// Réutilisée plus bas pour contrôler la commande en BO.
$this->store('orderReference', $frontOfficeOrderConfirmationPage->getOrderReference());
});
}
}
Scénario 2 : refus banque (carte 0002)
Même préambule, on change juste la carte et on vérifie le message d’erreur qui reste sur la page checkout. Le conteneur du message dépend du module, et son texte suit la langue du client : « Your card was declined. » en anglais.
->it('choisit Stripe et remplit une carte refusée', function () use ($frontOfficeCheckoutPage, $supportStripeElementsPage) {
$frontOfficeCheckoutPage->click('input[name="payment-option"][data-module-name="stripe_official"]');
$supportStripeElementsPage->fillCard('4000000000000002');
$frontOfficeCheckoutPage->click($frontOfficeCheckoutPage->getSelector('termsCheckbox'));
$frontOfficeCheckoutPage->click($frontOfficeCheckoutPage->getSelector('placeOrderButton'));
})
->it('reste sur checkout avec un message d\'erreur', function () use ($frontOfficeCheckoutPage) {
// Pas de rechargement : l'erreur Stripe s'affiche sans navigation.
// Sélecteur du message d'erreur : à adapter au module.
$frontOfficeCheckoutPage->waitVisible('.stripe-error', 15000);
Expect::that($frontOfficeCheckoutPage->getTextContent('.stripe-error'))
->contains('declined');
});
Scénario 3 : challenge 3DS (carte 3155)
Le placement de commande démarre, Stripe ouvre l’iframe de challenge, le scénario clique le bouton de validation de la page de test à l’intérieur, la commande passe.
->it('choisit Stripe et remplit une carte 3DS', function () use ($frontOfficeCheckoutPage, $supportStripeElementsPage) {
$frontOfficeCheckoutPage->click('input[name="payment-option"][data-module-name="stripe_official"]');
$supportStripeElementsPage->fillCard('4000002500003155');
$frontOfficeCheckoutPage->click($frontOfficeCheckoutPage->getSelector('termsCheckbox'));
$frontOfficeCheckoutPage->click($frontOfficeCheckoutPage->getSelector('placeOrderButton'));
})
->it('complète le challenge 3DS', function () use ($supportStripeElementsPage) {
$supportStripeElementsPage->confirm3DS();
})
->it('atterrit sur la confirmation', function () use ($frontOfficeOrderConfirmationPage) {
// Bump du timeout — le 3DS ajoute quelques secondes.
$frontOfficeOrderConfirmationPage->waitVisible(
$frontOfficeOrderConfirmationPage->getSelector('confirmationBlock'),
20000
);
Expect::that($frontOfficeOrderConfirmationPage->isConfirmed())->isTheSameAs(true);
});
Stripe Checkout (hosted) — la variante redirection
Si le module utilise Stripe Checkout (redirection vers checkout.stripe.com) plutôt qu’Elements, la Page ci-dessus ne sert pas — on est sur un domaine tiers, PrestaFlow y navigue comme sur n’importe quelle page. Le formulaire y est standard (pas d’iframe imbriquée), les mêmes cartes magiques fonctionnent.
->it('atterrit sur la page hébergée par Stripe', function () use ($frontOfficeCheckoutPage) {
$frontOfficeCheckoutPage->waitUntil(
fn () => str_contains($frontOfficeCheckoutPage->getPage()->getCurrentUrl(), 'checkout.stripe.com'),
15000,
'Pas de redirection vers checkout.stripe.com'
);
})
->it('remplit la carte sur Stripe et confirme', function () use ($frontOfficeCheckoutPage) {
$frontOfficeCheckoutPage->setValue('input#email', 'test@example.com');
$frontOfficeCheckoutPage->setValue('input#cardNumber', '4242424242424242');
$frontOfficeCheckoutPage->setValue('input#cardExpiry', '1234');
$frontOfficeCheckoutPage->setValue('input#cardCvc', '123');
$frontOfficeCheckoutPage->setValue('input#billingName', 'Jean Test');
$frontOfficeCheckoutPage->click('button[data-testid="hosted-payment-submit-button"]');
})
->it('revient sur PrestaShop en confirmation', function () use ($frontOfficeOrderConfirmationPage) {
$frontOfficeOrderConfirmationPage->waitVisible(
$frontOfficeOrderConfirmationPage->getSelector('confirmationBlock'),
20000
);
Expect::that($frontOfficeOrderConfirmationPage->isConfirmed())->isTheSameAs(true);
});
Vérifier le webhook payment_intent.succeeded
Le paiement en base côté Stripe et l’ordre côté PrestaShop passent en « paid » via le webhook payment_intent.succeeded — appel serveur-à-serveur, invisible depuis Chrome. Deux options :
- Attendre passivement — en mode test, Stripe envoie bien de vrais webhooks vers l’endpoint enregistré pour ce mode, à condition que Stripe puisse le joindre depuis Internet. Le scénario relit le statut de la commande en BO jusqu’à ce qu’il change, avec un délai maximal.
- Simuler activement — POSTer soi-même un payload
payment_intent.succeededvers le contrôleur du module et vérifier l’effet. C’est le pattern couvert par l’annexe tester les webhooks — même code, appliqué à Stripe.
À ajouter à la fin de la suite StripeCheckoutAccepted, qui a stocké orderReference à la confirmation, après avoir importé BackOffice\Login, BackOffice\Orders et BackOffice\OrderView :
->it('la commande passe en « Paiement accepté » après le webhook', function () use ($backOfficeLoginPage, $backOfficeOrdersPage, $backOfficeOrderViewPage) {
$backOfficeLoginPage->goToPage('index');
$backOfficeLoginPage->login();
// Le webhook arrive de façon asynchrone : on rouvre la commande jusqu'à ce que le statut change.
// Le libellé du statut suit la langue de l'employé BO.
$backOfficeOrdersPage->waitUntil(function () use ($backOfficeOrdersPage, $backOfficeOrderViewPage) {
$backOfficeOrdersPage->goTo();
$backOfficeOrdersPage->filterByReference($this->retrieve('orderReference'));
$backOfficeOrdersPage->openOrder(1);
return str_contains($backOfficeOrderViewPage->getCurrentStatus(), 'Paiement accepté');
}, 30000, 'Statut toujours différent de « Paiement accepté » : webhook non reçu ?');
Expect::that($backOfficeOrderViewPage->getCurrentStatus())->contains('Paiement accepté');
});
Interaction avec les autres annexes
- Tester les webhooks — la moitié serveur-à-serveur du paiement Stripe (le
payment_intent.succeededque Chrome ne voit pas). - Mocker les API tierces avec WireMock — pour éviter la dépendance à Stripe test mode (air-gap, coupures API), remplacer Stripe par un mock complet côté serveur.
- Fixtures et seed data — poser les clés
sk_test_…/pk_test_…dans la configuration du module au boot Flashlight, pour que la boutique de test soit toujours en mode test Stripe. - Popups intempestifs — Stripe.js peut afficher une bannière consent CMP la première fois. Le pattern d’auto-dismiss s’y applique.
Notes
Dans la Série PrestaFlow — article 35 sur 36