Documentation

Installation and CLI reference

Everything needed to install RenderLoop, run the loop and wire the JSON output into a script or an agent. Reference version: v0.4.0.

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
Do not want to install anything? The live demo runs the real engine on two HTML snippets, without storing anything.

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.

opcache trap. Right after an edit, PHP may serve a stale file for a second or two. A recheck that runs too early can show an empty diff even though the code changed. The demo in the repository starts its server with -d opcache.enable=0.

Requirements

  • PHP 8.2 or higher.
  • ext-dom, ext-libxml, ext-json and ext-curl extensions.
  • ext-curl is 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-selector and sebastian/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.

ActionWhat it doesKey optionsExit 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

OptionEffect
-u, --urlURL of the page to capture or re-check.
-t, --tagSnapshot label, for example before.
-d, --dirSnapshot directory. Default: .render-loop/snapshots.
-c, --configMasking config. Point it at a file in your project, not the one in vendor/.
-f, --formattable (default) or json.
--allow-hostAdditional host allowed for capture, repeatable. localhost and 127.0.0.1 are allowed by default.
--cookie-nameSession cookie name, PHPSESSID by default.
--methodForces the HTTP method used for capture.
--headerExtra HTTP header, repeatable.
--dataRequest body; implies POST.
--ajaxAdds X-Requested-With: XMLHttpRequest to capture a fragment endpoint.
--max-hunksMaximum hunks in the diff. CLI default: 40.
--exit-codeExits non-zero when the diff is non-empty, like git diff --exit-code.
--expect-selectorSelector the edit is allowed to touch, repeatable. Any leak fails the command.
--from, --toStart and end snapshots for compare.
--samplesNumber of calibration captures, minimum 2.
--applyWrites the discovered selectors into the config file.
--suite, --golden-dirGolden 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 @@ ..."
}
  • kind is text, added or removed.
  • selector is a best-effort CSS path: #id, then tag.class, then tag:nth-of-type(n).
  • context is the nearest readable anchor (heading, label, table header), null when nothing qualifies.
  • truncated becomes true when the number of hunks exceeds --max-hunks.
  • changes is 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 in ps).
  • 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.