Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
38.64% covered (danger)
38.64%
17 / 44
60.00% covered (warning)
60.00%
3 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
DomSettler
38.64% covered (danger)
38.64%
17 / 44
60.00% covered (warning)
60.00%
3 / 5
52.05
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 needsSettling
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 maybeSettle
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
4.10
 settle
7.41% covered (danger)
7.41%
2 / 27
0.00% covered (danger)
0.00%
0 / 1
34.58
 cacheKeyFor
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\WptHarness;
6
7use 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 */
75final 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}