Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
38.64% |
17 / 44 |
|
60.00% |
3 / 5 |
CRAP | |
0.00% |
0 / 1 |
| DomSettler | |
38.64% |
17 / 44 |
|
60.00% |
3 / 5 |
52.05 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| needsSettling | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| maybeSettle | |
81.82% |
9 / 11 |
|
0.00% |
0 / 1 |
4.10 | |||
| settle | |
7.41% |
2 / 27 |
|
0.00% |
0 / 1 |
34.58 | |||
| cacheKeyFor | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Phpdftk\WptHarness; |
| 6 | |
| 7 | use Phpdftk\Filesystem\LocalFilesystem; |
| 8 | |
| 9 | /** |
| 10 | * Settles a WPT `class="reftest-wait"` fixture's JavaScript before |
| 11 | * the static PHP renderer sees it. |
| 12 | * |
| 13 | * ## What and why |
| 14 | * |
| 15 | * Many WPT reftests use the `reftest-wait` convention to delay the |
| 16 | * screenshot until a piece of test JavaScript has finished running. |
| 17 | * Typical work: load `@font-face` WOFFs via the FontFace API, measure |
| 18 | * the resulting layout via `getBoundingClientRect`, shift an element |
| 19 | * into a known pixel position via inline `style="left: …"`, then |
| 20 | * remove the `reftest-wait` class to signal "ready". |
| 21 | * |
| 22 | * Our PHP renderer is a static-document pipeline - there is no |
| 23 | * notion of time, no event loop, no `requestAnimationFrame`. Without |
| 24 | * JS, the test's setup work never runs, and the rendered output |
| 25 | * diverges from what the reference expected. |
| 26 | * |
| 27 | * The DomSettler bridges that gap by: |
| 28 | * |
| 29 | * 1. Detecting `class="reftest-wait"` on the test fixture. |
| 30 | * 2. Shelling out to {@see scripts/cross-browser/settle-dom.mjs}, |
| 31 | * which launches headless Chromium (via the same Playwright |
| 32 | * install the cross-browser oracle uses), loads the test, and |
| 33 | * waits for the class to clear. |
| 34 | * 3. Capturing the post-JS HTML via Playwright's `page.content()`. |
| 35 | * 4. Caching the settled HTML on disk, keyed by `sha256(testBytes |
| 36 | * + playwright version)`, so identical fixtures only pay the |
| 37 | * browser-launch cost once. |
| 38 | * 5. Returning the settled HTML for the renderer to consume. |
| 39 | * |
| 40 | * Fixtures without `class="reftest-wait"` skip settling entirely - |
| 41 | * the harness reads them as-is, exactly like before this class |
| 42 | * existed. |
| 43 | * |
| 44 | * ## CSS animations + transitions are paused at t=0 |
| 45 | * |
| 46 | * The settler script injects a stylesheet that zeroes |
| 47 | * `animation-duration` / `animation-delay` / `transition-duration` |
| 48 | * / `transition-delay` on every element BEFORE the test's own |
| 49 | * scripts run. This keeps the captured DOM aligned with our static- |
| 50 | * renderer's "no time" semantics: |
| 51 | * |
| 52 | * - CSS animations evaluate at their `0%` keyframe (initial |
| 53 | * value) and stay there - the settler never sees a mid- or |
| 54 | * end-state. |
| 55 | * - CSS transitions apply target values instantly, never tweens. |
| 56 | * |
| 57 | * Tests that depend on observing an *animated* end-state (rare in |
| 58 | * WPT reftests - most use animation only for visibility / pacing) |
| 59 | * will diverge from the unmodified browser behaviour. This is the |
| 60 | * intended trade-off: the settler models the same "t=0" semantics |
| 61 | * the renderer applies. |
| 62 | * |
| 63 | * ## When the settler is unavailable |
| 64 | * |
| 65 | * The settler depends on Playwright + Chromium being installed |
| 66 | * (the same prerequisites as the cross-browser oracle's |
| 67 | * `scripts/cross-browser/render.mjs`). When unavailable - missing |
| 68 | * `node`, missing Playwright, missing browser - the |
| 69 | * {@see maybeSettle()} method gracefully returns the original |
| 70 | * fixture bytes without trying to settle. The harness still |
| 71 | * renders the test, just from its pre-JS source. Authors who care |
| 72 | * about reftest-wait coverage configure Playwright per |
| 73 | * `scripts/bootstrap-cross-browser.sh`. |
| 74 | */ |
| 75 | final class DomSettler |
| 76 | { |
| 77 | public function __construct( |
| 78 | /** |
| 79 | * Path to {@see scripts/cross-browser/settle-dom.mjs}. |
| 80 | * Configurable so tests can stub the script and the |
| 81 | * harness can locate it relative to the repo root. |
| 82 | */ |
| 83 | private readonly string $scriptPath, |
| 84 | /** |
| 85 | * Cache directory for settled-HTML payloads. Keyed by |
| 86 | * `sha256(testBytes + playwrightVersion)`. Created lazily |
| 87 | * when the first settle attempt succeeds. |
| 88 | */ |
| 89 | private readonly string $cacheDir, |
| 90 | /** |
| 91 | * Root directory passed to the settler script as |
| 92 | * `--corpus-root=`. Mirrors how the PHP ResourceLoader |
| 93 | * resolves `/`-prefixed URLs against the corpus root. |
| 94 | */ |
| 95 | private readonly string $corpusRoot, |
| 96 | /** |
| 97 | * Node binary to invoke. Defaults to `node` on PATH. |
| 98 | */ |
| 99 | private readonly string $nodeBinary = 'node', |
| 100 | /** |
| 101 | * Timeout for the settler to wait for the `reftest-wait` |
| 102 | * class to clear, in milliseconds. |
| 103 | */ |
| 104 | private readonly int $timeoutMs = 5000, |
| 105 | ) {} |
| 106 | |
| 107 | /** |
| 108 | * Detect `class="reftest-wait"` on the test fixture's `<html>` |
| 109 | * element. Conservative regex match (the class attribute on |
| 110 | * the first `<html>` element) - false positives on tests that |
| 111 | * just *mention* the class string elsewhere are accepted; the |
| 112 | * worst case is one wasted settle round. |
| 113 | */ |
| 114 | public function needsSettling(string $fixtureBytes): bool |
| 115 | { |
| 116 | // Match `<html ... class="...reftest-wait...">` covering |
| 117 | // both single- and double-quoted attribute values plus the |
| 118 | // multi-class case. |
| 119 | return preg_match( |
| 120 | '/<html\b[^>]*\bclass\s*=\s*["\']\s*(?:[^"\']+\s+)?reftest-wait\b/i', |
| 121 | $fixtureBytes, |
| 122 | ) === 1; |
| 123 | } |
| 124 | |
| 125 | /** |
| 126 | * Returns the settled HTML for `$fixturePath` when settling is |
| 127 | * applicable and successful, or null when: |
| 128 | * |
| 129 | * - The fixture doesn't carry `class="reftest-wait"`. |
| 130 | * - The settler script isn't available or fails to run. |
| 131 | * - The browser launch / navigation errors out. |
| 132 | * |
| 133 | * Callers should fall back to reading the original fixture |
| 134 | * bytes on null. |
| 135 | */ |
| 136 | public function maybeSettle(string $fixturePath, string $fixtureBytes): ?string |
| 137 | { |
| 138 | if (!$this->needsSettling($fixtureBytes)) { |
| 139 | return null; |
| 140 | } |
| 141 | $cacheKey = $this->cacheKeyFor($fixtureBytes); |
| 142 | $cachePath = $this->cacheDir . DIRECTORY_SEPARATOR . $cacheKey . '.html'; |
| 143 | if (is_file($cachePath)) { |
| 144 | return LocalFilesystem::readFile($cachePath, 'settled-DOM cache'); |
| 145 | } |
| 146 | $settled = $this->settle($fixturePath); |
| 147 | if ($settled === null) { |
| 148 | return null; |
| 149 | } |
| 150 | LocalFilesystem::writeFile($cachePath, $settled, createDirectories: true); |
| 151 | return $settled; |
| 152 | } |
| 153 | |
| 154 | /** |
| 155 | * Shell out to the settler script and return the captured HTML |
| 156 | * on success, or null on any failure. |
| 157 | */ |
| 158 | private function settle(string $fixturePath): ?string |
| 159 | { |
| 160 | if (!is_file($this->scriptPath)) { |
| 161 | return null; |
| 162 | } |
| 163 | $command = [ |
| 164 | $this->nodeBinary, |
| 165 | $this->scriptPath, |
| 166 | $fixturePath, |
| 167 | '--corpus-root=' . $this->corpusRoot, |
| 168 | '--timeout=' . $this->timeoutMs, |
| 169 | ]; |
| 170 | $cmdLine = implode(' ', array_map('escapeshellarg', $command)); |
| 171 | $descriptors = [ |
| 172 | 0 => ['pipe', 'r'], |
| 173 | 1 => ['pipe', 'w'], |
| 174 | 2 => ['pipe', 'w'], |
| 175 | ]; |
| 176 | $process = proc_open($cmdLine, $descriptors, $pipes); |
| 177 | if (!is_resource($process)) { |
| 178 | return null; |
| 179 | } |
| 180 | fclose($pipes[0]); |
| 181 | $stdout = stream_get_contents($pipes[1]); |
| 182 | $stderr = stream_get_contents($pipes[2]); |
| 183 | fclose($pipes[1]); |
| 184 | fclose($pipes[2]); |
| 185 | $exitCode = proc_close($process); |
| 186 | if ($exitCode !== 0 || $stdout === false || $stdout === '') { |
| 187 | return null; |
| 188 | } |
| 189 | return $stdout; |
| 190 | } |
| 191 | |
| 192 | /** |
| 193 | * Build the cache key for `$fixtureBytes`. The settled output |
| 194 | * depends on the fixture content plus the Playwright / |
| 195 | * Chromium versions in the environment - a cache miss when |
| 196 | * those tools update is the intended behaviour. We can't read |
| 197 | * the Playwright version from PHP cheaply, so the script |
| 198 | * itself bakes its version into the output via the data-source |
| 199 | * marker; we just hash the source bytes here and rely on a |
| 200 | * manual cache flush after a Playwright bump. |
| 201 | */ |
| 202 | private function cacheKeyFor(string $fixtureBytes): string |
| 203 | { |
| 204 | return hash('sha256', $fixtureBytes); |
| 205 | } |
| 206 | } |