Démarrage rapide
RenderLoop s'installe comme n'importe quelle bibliothèque Composer. Le
binaire est exposé dans vendor/bin/.
composer require linquant/render-loop
La boucle complète tient en trois commandes :
# 1. capturer l'état AVANT la modification
vendor/bin/render-loop -a capture -u "http://localhost:8000/ma-page.php" -t before
# 2. … modifier le code, puis s'assurer que le serveur sert le nouveau fichier …
# 3. re-vérifier : le diff doit contenir UNIQUEMENT le changement voulu
vendor/bin/render-loop -a recheck -u "http://localhost:8000/ma-page.php" -f json
Sans snapshot « before », il n'y a rien à differ. La référence utilisée
par recheck est la dernière capture de l'URL qui ne vient
pas elle-même d'un recheck : plusieurs rechecks successifs ne font donc
pas dériver la base de comparaison.
-d opcache.enable=0.
Prérequis
- PHP 8.2 ou supérieur.
- Extensions
ext-dom,ext-libxml,ext-jsonetext-curl. ext-curlest exigée par Composer à l'installation. À l'exécution, seule la capture par requête HTTP l'utilise ; la stratégie « push » n'appelle pas curl.- Dépendances installées automatiquement :
symfony/console,symfony/css-selectoretsebastian/diff.
Pour contribuer au projet : composer test (90 tests
PHPUnit) et composer phpstan (level 10) doivent être verts.
La CI couvre PHP 8.2, 8.3, 8.4 et 8.5.
Les 11 actions CLI
Le binaire est vendor/bin/render-loop (ou
bin/render-loop depuis les sources). Dans les exemples,
render-loop désigne ce binaire lorsque
vendor/bin est dans le PATH.
Les actions recheck, compare,
list, calibrate, mask-add,
capabilities et advise acceptent
-f json ; seuls recheck et
compare renvoient le résumé de diff. capture,
store, golden-record et
golden-verify écrivent toujours du texte, même avec le
flag.
| Action | Ce qu'elle fait | Options clés | Code retour |
|---|---|---|---|
capture |
Récupère la page par curl et l'enregistre comme snapshot. | -u, -t, --method, --header, --data, --ajax |
0, ou erreur |
store |
Enregistre le HTML lu sur l'entrée standard (page JS/SPA rendue ailleurs). | -u, -t |
0, erreur si l'entrée est vide |
recheck |
Re-capture l'URL et diffe contre la dernière baseline. | -u, --exit-code, --expect-selector |
0 même avec un diff ; non-zéro avec --exit-code ou assertion échouée |
compare |
Diff entre deux snapshots précis. | --from, --to, --expect-selector |
Idem recheck |
list |
Liste les snapshots, éventuellement filtrés par URL. | -u (optionnel) |
0 |
calibrate |
Capture N fois sans changement : ce qui diffère est du bruit, dont elle déduit les sélecteurs. | -u, --samples (min. 2), --apply |
0 |
mask-add |
Ajoute des sélecteurs de bruit à la config, sans calibration. | --selector (répétable) |
0 ; 1 si aucun sélecteur |
golden-record |
Épingle la sortie rendue actuelle des cas d'une suite. | --suite, --golden-dir |
0, ou erreur |
golden-verify |
Rejoue chaque cas et compare au golden. | --suite, --golden-dir |
Non-zéro si un cas a changé ou manque |
capabilities |
Manifeste machine de toutes les actions : options requises, forme de sortie, codes retour. | -f json |
0 |
advise |
Mode opératoire et pièges destinés à un agent IA. | -f json |
0 |
Options transverses
| Option | Effet |
|---|---|
-u, --url | URL de la page à capturer ou re-vérifier. |
-t, --tag | Étiquette du snapshot, par exemple before. |
-d, --dir | Dossier des snapshots. Défaut : .render-loop/snapshots. |
-c, --config | Config de masquage. À pointer vers un fichier de ton projet, pas celui de vendor/. |
-f, --format | table (défaut) ou json. |
--allow-host | Hôte supplémentaire autorisé pour la capture, répétable. localhost et 127.0.0.1 le sont par défaut. |
--cookie-name | Nom du cookie de session, PHPSESSID par défaut. |
--method | Force la méthode HTTP de capture. |
--header | En-tête HTTP supplémentaire, répétable. |
--data | Corps de requête ; implique POST. |
--ajax | Ajoute X-Requested-With: XMLHttpRequest pour capturer un endpoint de fragment. |
--max-hunks | Plafond de hunks dans le diff. Défaut CLI : 40. |
--exit-code | Sort en non-zéro quand le diff est non vide, comme git diff --exit-code. |
--expect-selector | Sélecteur que l'édition a le droit de toucher, répétable. Toute fuite fait échouer la commande. |
--from, --to | Snapshots de départ et d'arrivée pour compare. |
--samples | Nombre de captures de calibration, minimum 2. |
--apply | Écrit les sélecteurs découverts dans le fichier de config. |
--suite, --golden-dir | Nom de la suite golden et dossier du manifeste. Défaut : golden. |
Sortie JSON
recheck et compare renvoient un objet avec
deux clés : summary et diff. Le résumé est
fait pour être lu en premier.
{
"summary": {
"lines_added": 1,
"lines_removed": 1,
"hunks": 1,
"truncated": false,
"tags_touched": ["p"],
"changes": [
{
"kind": "text",
"selector": "article.product-detail > p.price",
"context": "Aeron Chair",
"before": "$1295.00",
"after": "$995.00"
}
]
},
"diff": "@@ -41,7 +41,7 @@ ..."
}
kindvauttext,addedouremoved.selectorest un chemin CSS au mieux :#id, puistag.classe, puistag:nth-of-type(n).contextest l'ancre lisible la plus proche (titre, label, en-tête de tableau),nullsi rien ne convient.truncatedpasse àtruequand le nombre de hunks dépasse--max-hunks.changesest un enrichissement : sur une insertion structurelle qui décale les chemins, il peut retomber à vide. Le diff unifié reste la référence.
Assertion de rayon d'impact
Avec --expect-selector, un bloc assertion
s'ajoute. RenderLoop masque temporairement les sélecteurs attendus,
re-normalise et re-diffe : ce qui reste est la fuite.
{
"assertion": {
"expected_selectors": [".price"],
"satisfied": false,
"inside_scope_changed": true,
"reason": "1 change(s) outside the expected scope",
"out_of_scope": {
"lines_added": 1,
"lines_removed": 1,
"hunks": 1,
"diff": "..."
}
}
}
satisfied: true signifie qu'aucun changement visible n'a
fui hors du sélecteur — pas que l'édition est correcte. Un sélecteur
faux ou absent donne satisfied: false avec la raison
« matched no element », jamais un faux vert.
Masquage et calibration
Avant comparaison, l'HTML est normalisé : script,
style et commentaires retirés, puis les sélecteurs de bruit
de la config suppriment les nœuds indésirables. Le masquage est
toujours par sélecteur, jamais par expression régulière sur le
contenu : un prix qui tombe à 0.00 doit rester
visible dans le diff.
# découvrir le bruit : capturer plusieurs fois sans rien changer
render-loop -a calibrate -u "http://localhost:8000/ma-page" --samples=4 --apply -c normalizer.json
# déclarer un bruit connu sans calibration
render-loop -a mask-add --selector='.chat-widget' --selector='.cookie-banner' -c normalizer.json
La config par défaut masque déjà DebugBar, les jetons CSRF courants et
quelques métadonnées. calibrate --apply et
mask-add sont idempotents : pas de doublon dans le fichier.
Golden master
Le golden master est un mode d'approval testing pour les pages legacy sans tests. Il fige la sortie rendue de cas représentatifs, puis prouve qu'un refactor ne l'a pas fait dériver.
# avant le refactor
render-loop -a golden-record --suite=product
# après le refactor
render-loop -a golden-verify --suite=product
Le manifeste golden/<suite>.json déclare les cas
(id, url) et le masque. La sortie normalisée
est écrite dans golden/<suite>/<cas>.html :
commit-la, le diff se relit dans git comme un fichier
.approved. golden-record auto-calibre le bruit
par cas.
Le golden ne couvre que les entrées déclarées et suppose un rendu déterministe. Capture contre des données figées pour que le diff reflète le code et non la donnée.
Utilisation programmatique
Le moteur est indépendant de tout framework. Il s'instancie en quelques lignes et s'intègre dans un contrôleur, une commande ou un test.
use RenderLoop\Model\SnapshotRepository;
use RenderLoop\Service\{HtmlCompareService, HtmlNormalizer, HtmlDiffer,
NormalizerConfig, CurlCaptureStrategy, PushedHtmlStrategy, UrlGuard};
$service = new HtmlCompareService(
new SnapshotRepository('/var/snapshots'),
new HtmlNormalizer(NormalizerConfig::default()),
new HtmlDiffer(),
);
// page rendue côté serveur, bornée aux hôtes autorisés (anti-SSRF)
$fetch = new CurlCaptureStrategy(new UrlGuard(['localhost']));
$service->capture('http://localhost/page.php', $fetch, 'before');
// … modifier le code …
$result = $service->recheck('http://localhost/page.php', $fetch);
if (!$result->diff->isIdentical()) {
echo $result->diff->unifiedDiff; // ce qui a changé
print_r($result->diff->summary()); // résumé compact, adapté à une IA
}
// page JS/SPA : pousser l'HTML déjà rendu par un navigateur headless
$service->capture('http://localhost/app', new PushedHtmlStrategy($renderedHtml), 'before');
Pages authentifiées et sécurité
La capture attache le cookie de session de l'utilisateur. Une URL libre
permettrait donc d'exfiltrer ce que voit cet utilisateur vers un hôte
tiers : UrlGuard n'accepte que http ou
https, et uniquement les hôtes d'une liste blanche.
- La valeur du cookie passe par
RENDERLOOP_COOKIE, jamais en argument (visible dansps). - Les redirections ne sont jamais suivies : une redirection cross-origin contournerait la garde.
- Un statut HTTP supérieur ou égal à 400 lève une exception.
- Le timeout de capture est de 15 secondes.
RENDERLOOP_COOKIE=<valeur> render-loop -a capture -u "http://localhost:8000/compte.php"
Limites assumées
- Rendu uniquement : une écriture en base, un export, un PDF ou une réponse d'API reste invisible.
- Golden master : seulement les entrées déclarées, et un rendu déterministe. Pour un calcul critique, extraire la fonction et la tester unitairement.
- changes au mieux : sur du markup sans id ni classe, les sélecteurs sont positionnels et une insertion structurelle peut vider le tableau. Le diff unifié reste fiable.
- Complément, pas substitut : RenderLoop complète les tests, il ne les remplace pas.