Quickstart
RenderLoop installs like any Composer library. The binary is exposed in
vendor/bin/.
composer require linquant/render-loop
The full loop fits in three commands:
# 1. capture the state BEFORE the change
vendor/bin/render-loop -a capture -u "http://localhost:8000/my-page.php" -t before
# 2. … change the code, then make sure the server serves the new file …
# 3. re-check: the diff must contain ONLY the intended change
vendor/bin/render-loop -a recheck -u "http://localhost:8000/my-page.php" -f json
Without a “before” snapshot there is nothing to diff. The reference
used by recheck is the most recent capture of the URL that
is not itself a recheck, so repeated rechecks do not make the baseline
drift.
-d opcache.enable=0.
Requirements
- PHP 8.2 or higher.
ext-dom,ext-libxml,ext-jsonandext-curlextensions.ext-curlis required by Composer at install time. At runtime, only the HTTP capture uses it; the “push” strategy does not call curl.- Dependencies installed automatically:
symfony/console,symfony/css-selectorandsebastian/diff.
To contribute: composer test (90 PHPUnit tests) and
composer phpstan (level 10) must both be green. CI covers
PHP 8.2, 8.3, 8.4 and 8.5.
The 11 CLI actions
The binary is vendor/bin/render-loop (or
bin/render-loop from the sources). In the examples,
render-loop refers to that binary when
vendor/bin is on the PATH.
The actions recheck, compare,
list, calibrate, mask-add,
capabilities and advise accept
-f json; only recheck and
compare return the diff summary. capture,
store, golden-record and
golden-verify always write text, even with the flag.
| Action | What it does | Key options | Exit code |
|---|---|---|---|
capture |
Fetches the page with curl and stores it as a snapshot. | -u, -t, --method, --header, --data, --ajax |
0, or error |
store |
Stores HTML read from standard input (a JS/SPA page rendered elsewhere). | -u, -t |
0, error on empty input |
recheck |
Re-captures the URL and diffs it against the latest baseline. | -u, --exit-code, --expect-selector |
0 even with a diff; non-zero with --exit-code or a failed assertion |
compare |
Diffs two precise snapshots. | --from, --to, --expect-selector |
Same as recheck |
list |
Lists snapshots, optionally filtered by URL. | -u (optional) |
0 |
calibrate |
Captures N times without changes: whatever differs is noise, and it derives the selectors. | -u, --samples (min. 2), --apply |
0 |
mask-add |
Adds noise selectors to the config without calibrating. | --selector (repeatable) |
0; 1 if no selector |
golden-record |
Pins the current rendered output of a suite's cases. | --suite, --golden-dir |
0, or error |
golden-verify |
Replays every case and compares it to the golden. | --suite, --golden-dir |
Non-zero if a case changed or is missing |
capabilities |
Machine manifest of every action: required options, output shape, exit codes. | -f json |
0 |
advise |
Modus operandi and pitfalls for an AI agent. | -f json |
0 |
Global options
| Option | Effect |
|---|---|
-u, --url | URL of the page to capture or re-check. |
-t, --tag | Snapshot label, for example before. |
-d, --dir | Snapshot directory. Default: .render-loop/snapshots. |
-c, --config | Masking config. Point it at a file in your project, not the one in vendor/. |
-f, --format | table (default) or json. |
--allow-host | Additional host allowed for capture, repeatable. localhost and 127.0.0.1 are allowed by default. |
--cookie-name | Session cookie name, PHPSESSID by default. |
--method | Forces the HTTP method used for capture. |
--header | Extra HTTP header, repeatable. |
--data | Request body; implies POST. |
--ajax | Adds X-Requested-With: XMLHttpRequest to capture a fragment endpoint. |
--max-hunks | Maximum hunks in the diff. CLI default: 40. |
--exit-code | Exits non-zero when the diff is non-empty, like git diff --exit-code. |
--expect-selector | Selector the edit is allowed to touch, repeatable. Any leak fails the command. |
--from, --to | Start and end snapshots for compare. |
--samples | Number of calibration captures, minimum 2. |
--apply | Writes the discovered selectors into the config file. |
--suite, --golden-dir | Golden suite name and manifest directory. Default: golden. |
JSON output
recheck and compare return an object with two
keys: summary and diff. The summary is meant
to be read first.
{
"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": "€1,295.00",
"after": "€995.00"
}
]
},
"diff": "@@ -41,7 +41,7 @@ ..."
}
kindistext,addedorremoved.selectoris a best-effort CSS path:#id, thentag.class, thentag:nth-of-type(n).contextis the nearest readable anchor (heading, label, table header),nullwhen nothing qualifies.truncatedbecomestruewhen the number of hunks exceeds--max-hunks.changesis an enrichment: on a structural insertion that shifts positional paths, it can fall back to empty. The unified diff remains the reference.
Blast-radius assertion
With --expect-selector, an assertion block is
added. RenderLoop temporarily masks the expected selectors,
re-normalizes and re-diffs: whatever remains is the leak.
{
"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 means no visible change leaked outside the
selector — not that the edit is correct. A wrong or missing selector
returns satisfied: false with the reason “matched no
element”, never a false pass.
Masking and calibration
Before comparison, HTML is normalized: script,
style and comments are removed, then the noise selectors
from the config delete the unwanted nodes. Masking is
always selector-based, never a regex on content: a
price dropping to 0.00 must stay visible in the diff.
# discover noise: capture several times without changing anything
render-loop -a calibrate -u "http://localhost:8000/my-page" --samples=4 --apply -c normalizer.json
# declare known noise without calibration
render-loop -a mask-add --selector='.chat-widget' --selector='.cookie-banner' -c normalizer.json
The default config already masks DebugBar, common CSRF tokens and some
metadata. calibrate --apply and mask-add are
idempotent: no duplicate in the file.
Golden master
Golden master is an approval-testing mode for legacy pages without tests. It pins the rendered output of representative cases, then proves a refactor did not make it drift.
# before the refactor
render-loop -a golden-record --suite=product
# after the refactor
render-loop -a golden-verify --suite=product
The golden/<suite>.json manifest declares the cases
(id, url) and the mask. Normalized output is
written to golden/<suite>/<case>.html: commit
it, the diff reads back in git like an .approved file.
golden-record auto-calibrates noise per case.
The golden covers only the declared inputs and assumes deterministic rendering. Capture against frozen data so the diff reflects code, not data.
Programmatic usage
The engine is framework-agnostic. It is instantiated in a few lines and fits into a controller, a command or a 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(),
);
// server-rendered page, bounded to the allowed hosts (anti-SSRF)
$fetch = new CurlCaptureStrategy(new UrlGuard(['localhost']));
$service->capture('http://localhost/page.php', $fetch, 'before');
// … change the code …
$result = $service->recheck('http://localhost/page.php', $fetch);
if (!$result->diff->isIdentical()) {
echo $result->diff->unifiedDiff; // what changed
print_r($result->diff->summary()); // compact summary, suited to an AI
}
// JS/SPA page: push HTML already rendered by a headless browser
$service->capture('http://localhost/app', new PushedHtmlStrategy($renderedHtml), 'before');
Authenticated pages and security
Capture attaches the user's session cookie. A free-form URL would
therefore let a caller exfiltrate what that user sees to a third-party
host: UrlGuard only accepts http or
https, and only hosts on an allow-list.
- The cookie value goes through
RENDERLOOP_COOKIE, never as an argument (visible inps). - Redirects are never followed: a cross-origin redirect would bypass the guard.
- An HTTP status of 400 or higher raises an exception.
- The capture timeout is 15 seconds.
RENDERLOOP_COOKIE=<value> render-loop -a capture -u "http://localhost:8000/account.php"
Assumed limits
- Rendering only: a database write, an export, a PDF or an API response stays invisible.
- Golden master: only the declared inputs, and deterministic rendering. For a critical computation, extract the function and unit-test it.
- Best-effort changes: on markup without ids or classes, selectors are positional and a structural insertion can empty the array. The unified diff remains reliable.
- Complement, not substitute: RenderLoop complements tests, it does not replace them.