Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
54.80% covered (warning)
54.80%
97 / 177
31.25% covered (danger)
31.25%
5 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
BrowserOracle
54.80% covered (warning)
54.80%
97 / 177
31.25% covered (danger)
31.25%
5 / 16
455.10
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
 render
88.24% covered (warning)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
7.08
 isAvailable
22.22% covered (danger)
22.22%
2 / 9
0.00% covered (danger)
0.00%
0 / 1
30.05
 daemonReady
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
5
 daemonUrlFor
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 firefoxAvailable
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 macFirefoxPath
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
42
 cachedPath
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 clearCache
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 dispatch
6.52% covered (danger)
6.52%
3 / 46
0.00% covered (danger)
0.00%
0 / 1
60.28
 dispatchDaemon
92.68% covered (success)
92.68%
38 / 41
0.00% covered (danger)
0.00%
0 / 1
10.04
 translateFixturePath
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
5.58
 cacheKey
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 binaryAvailable
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 dockerDaemonRunning
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 ensureCacheDir
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\WptHarness;
6
7/**
8 * Shell wrapper around `scripts/cross-browser/render.mjs` (Chromium and
9 * WebKit) and `scripts/cross-browser/render-docker.sh` (Firefox via the
10 * Docker image) for the cross-browser PDF oracle.
11 *
12 * The oracle is a slow, side-effecting operation — each render boots a
13 * browser engine and emits a real PDF. We cache aggressively: keyed on
14 * the SHA-256 of the test bytes plus engine identifier plus a "cache
15 * generation" string (so bumping Playwright / WebKit / Firefox versions
16 * invalidates everything in one move).
17 *
18 * On a cache hit we return the cached PDF immediately. On a miss we
19 * shell out to the right CLI, store the PDF, and return its path.
20 *
21 * Engine availability is checked lazily — if `node` isn't on `PATH`
22 * (Chromium) or `docker` isn't running (Firefox) or the Swift binary
23 * isn't built (WebKit), the engine is reported as unavailable and the
24 * runner skips it instead of failing the whole test.
25 */
26final class BrowserOracle
27{
28    /**
29     * Engine → port offset for the daemon-base URL. The full URL is
30     * `${daemonBase}${offset}/render`, so `--daemon-base=http://127.0.0.1:910`
31     * routes to 9101 / 9102 / 9103. Keep in sync with the port
32     * declarations in compose.yaml.
33     */
34    private const ENGINE_PORTS = [
35        'chromium' => 1,
36        'firefox' => 2,
37        'webkit' => 3,
38    ];
39
40    public function __construct(
41        /**
42         * Absolute path to the repo's `scripts/cross-browser/` directory.
43         * Defaults to a relative path resolved from this file's location.
44         */
45        private readonly string $scriptDir = __DIR__ . '/../../../scripts/cross-browser',
46        /**
47         * Cache directory for rendered PDFs. Gitignored. Lives under
48         * `var/wpt/browser-cache/` by default; per-engine subdirs keep
49         * the listing legible.
50         */
51        private readonly string $cacheDir = __DIR__ . '/../../../var/wpt/browser-cache',
52        /**
53         * Single-string knob to invalidate every cache entry at once.
54         * Bumped when any browser version moves. Combined with the
55         * test-bytes hash to form the cache key.
56         */
57        private readonly string $cacheGeneration = 'pw-1.49.1-ff-latest-wk-26.0',
58        private readonly string $nodeBinary = 'node',
59        private readonly string $dockerBinary = 'docker',
60        /**
61         * Base URL of the engine daemon cluster. When set, every render
62         * goes through HTTP POST to `${daemonBase}${portOffset}/render`
63         * instead of forking `node render.mjs`. Null = legacy one-shot
64         * fork mode (used by dev hosts that don't want to bring up the
65         * compose stack).
66         *
67         * Example: `--daemon-base=http://127.0.0.1:910` → chromium
68         * runs on :9101, firefox on :9102, webkit on :9103.
69         */
70        private readonly ?string $daemonBase = null,
71        /**
72         * When daemon-mode is on, fixture paths arrive as absolute
73         * host paths (e.g. `/Users/x/repo/vendor-data/wpt/foo.html`)
74         * but the daemon needs the in-container view (`/wpt/foo.html`).
75         * We rewrite the prefix via `hostWptRoot` → `daemonWptRoot`.
76         * Auto-resolved from `vendor-data/wpt` when null.
77         */
78        private readonly ?string $hostWptRoot = null,
79        private readonly string $daemonWptRoot = '/wpt',
80    ) {}
81
82    /**
83     * Render `$testPath` through `$engine` and return the path to the
84     * resulting PDF. The path is owned by the cache — the caller MUST
85     * NOT unlink it. Returns null when the engine isn't available on
86     * this host; raises on a hard render failure (engine present but
87     * the PDF generation went wrong).
88     */
89    public function render(string $engine, string $testPath): ?string
90    {
91        if (!is_file($testPath)) {
92            throw new \RuntimeException("test fixture not found: $testPath");
93        }
94        if (!$this->isAvailable($engine)) {
95            return null;
96        }
97        $cached = $this->cachedPath($engine, $testPath);
98        if (is_file($cached)) {
99            return $cached;
100        }
101        $this->ensureCacheDir($engine);
102        $tmp = $cached . '.tmp.' . bin2hex(random_bytes(4));
103        try {
104            $this->dispatch($engine, $testPath, $tmp);
105            if (!is_file($tmp) || filesize($tmp) === 0) {
106                throw new \RuntimeException("$engine produced empty PDF for $testPath");
107            }
108            // Atomic publish: rename is atomic on POSIX so concurrent
109            // callers don't race on a half-written cache file.
110            rename($tmp, $cached);
111            return $cached;
112        } catch (\Throwable $err) {
113            @unlink($tmp);
114            throw $err;
115        }
116    }
117
118    /**
119     * Available-engine probe. Each engine has a different signal:
120     *
121     *  - `chromium` — node + render.mjs + Playwright's bundled Chromium.
122     *    We check node exists; the Playwright install is a hard
123     *    runtime error if it's missing, which is sensible.
124     *  - `firefox`  — on macOS hosts, the system Firefox.app is used
125     *    directly (`--screenshot` path in render.mjs because
126     *    `--print-to-pdf` hangs the SWGL compositor on arm64). On
127     *    everything else, the docker daemon must be reachable so
128     *    render-docker.sh can drop into the Linux container.
129     *  - `webkit`   — `webkit-render` binary at the configured path
130     *    (defaults to `/usr/local/bin/webkit-render`; override with
131     *    the `WEBKIT_CLI` env in render.mjs).
132     */
133    public function isAvailable(string $engine): bool
134    {
135        if ($this->daemonBase !== null) {
136            return $this->daemonReady($engine);
137        }
138        return match ($engine) {
139            'chromium' => $this->binaryAvailable($this->nodeBinary),
140            'firefox' => $this->firefoxAvailable(),
141            'webkit' => is_file(
142                getenv('WEBKIT_CLI') ?: '/usr/local/bin/webkit-render',
143            ),
144            default => false,
145        };
146    }
147
148    /**
149     * Probe `${daemonBase}${port}/status` and treat a 200 with
150     * `ready: true` as available. Short timeout — if the daemon isn't
151     * up we want the sweep to skip the engine, not stall.
152     */
153    private function daemonReady(string $engine): bool
154    {
155        $url = $this->daemonUrlFor($engine, '/status');
156        if ($url === null) {
157            return false;
158        }
159        $ch = curl_init($url);
160        curl_setopt_array($ch, [
161            CURLOPT_RETURNTRANSFER => true,
162            CURLOPT_TIMEOUT_MS => 2000,
163            CURLOPT_CONNECTTIMEOUT_MS => 500,
164            CURLOPT_FAILONERROR => false,
165        ]);
166        $body = curl_exec($ch);
167        $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
168        curl_close($ch);
169        if ($code !== 200 || !is_string($body)) {
170            return false;
171        }
172        $decoded = json_decode($body, true);
173        return is_array($decoded) && ($decoded['ready'] ?? false) === true;
174    }
175
176    private function daemonUrlFor(string $engine, string $path): ?string
177    {
178        if ($this->daemonBase === null) {
179            return null;
180        }
181        $offset = self::ENGINE_PORTS[$engine] ?? null;
182        if ($offset === null) {
183            return null;
184        }
185        return rtrim($this->daemonBase, '/') . $offset . $path;
186    }
187
188    private function firefoxAvailable(): bool
189    {
190        if ($this->macFirefoxPath() !== null) {
191            return $this->binaryAvailable($this->nodeBinary);
192        }
193        return $this->binaryAvailable($this->dockerBinary)
194            && $this->dockerDaemonRunning();
195    }
196
197    private function macFirefoxPath(): ?string
198    {
199        if (PHP_OS_FAMILY !== 'Darwin') {
200            return null;
201        }
202        $envBin = getenv('FIREFOX_CLI');
203        if (is_string($envBin) && $envBin !== '' && is_file($envBin)) {
204            return $envBin;
205        }
206        $app = '/Applications/Firefox.app/Contents/MacOS/firefox';
207        return is_file($app) ? $app : null;
208    }
209
210    /**
211     * Return the cache key + final path for `(engine, testPath)`. Public
212     * so callers (e.g. CI) can pre-warm the cache.
213     */
214    public function cachedPath(string $engine, string $testPath): string
215    {
216        $key = hash('sha256', (string) file_get_contents($testPath))
217            . '-' . $this->cacheGeneration
218            . '-' . $engine;
219        return $this->cacheDir . '/' . $engine . '/' . substr($key, 0, 64) . '.pdf';
220    }
221
222    /**
223     * For diagnostics: clear every cached PDF for one engine. CI uses
224     * this when bumping browser versions.
225     */
226    public function clearCache(string $engine): void
227    {
228        $dir = $this->cacheDir . '/' . $engine;
229        if (!is_dir($dir)) {
230            return;
231        }
232        foreach (glob($dir . '/*.pdf') ?: [] as $file) {
233            @unlink($file);
234        }
235    }
236
237    private function dispatch(string $engine, string $testPath, string $outPath): void
238    {
239        if ($this->daemonBase !== null) {
240            $this->dispatchDaemon($engine, $testPath, $outPath);
241            return;
242        }
243        switch ($engine) {
244            case 'chromium':
245                $cmd = sprintf(
246                    '%s %s chromium %s --output=%s 2>&1',
247                    escapeshellcmd($this->nodeBinary),
248                    escapeshellarg($this->scriptDir . '/render.mjs'),
249                    escapeshellarg($testPath),
250                    escapeshellarg($outPath),
251                );
252                break;
253            case 'webkit':
254                $cmd = sprintf(
255                    '%s %s webkit %s --output=%s 2>&1',
256                    escapeshellcmd($this->nodeBinary),
257                    escapeshellarg($this->scriptDir . '/render.mjs'),
258                    escapeshellarg($testPath),
259                    escapeshellarg($outPath),
260                );
261                break;
262            case 'firefox':
263                $macFf = $this->macFirefoxPath();
264                if ($macFf !== null) {
265                    // macOS hosts run Firefox natively through render.mjs;
266                    // render-docker.sh is the Linux fallback.
267                    $env = 'FIREFOX_CLI=' . escapeshellarg($macFf) . ' ';
268                    $cmd = $env . sprintf(
269                        '%s %s firefox %s --output=%s 2>&1',
270                        escapeshellcmd($this->nodeBinary),
271                        escapeshellarg($this->scriptDir . '/render.mjs'),
272                        escapeshellarg($testPath),
273                        escapeshellarg($outPath),
274                    );
275                    break;
276                }
277                $cmd = sprintf(
278                    '%s %s %s %s 2>&1',
279                    escapeshellcmd($this->scriptDir . '/render-docker.sh'),
280                    'firefox',
281                    escapeshellarg($testPath),
282                    escapeshellarg($outPath),
283                );
284                break;
285            default:
286                throw new \RuntimeException("unknown engine: $engine");
287        }
288        exec($cmd, $output, $status);
289        if ($status !== 0) {
290            $err = implode("\n", $output);
291            throw new \RuntimeException("$engine render failed (exit $status): $err");
292        }
293    }
294
295    /**
296     * Post the fixture to the engine daemon and write the returned
297     * PDF bytes to `$outPath`. The daemon expects fixture paths under
298     * its own WPT root (mounted as `/wpt/` in the canonical compose
299     * setup); we translate the host path before sending.
300     *
301     * The daemon also writes its own atomic cache copy keyed on
302     * `cache_key`. The calling render() then renames `$outPath` into
303     * the same final path — both writes land on the same file with
304     * identical bytes, which is harmless duplication that we accept
305     * to keep both sides of the cache symmetric.
306     */
307    private function dispatchDaemon(string $engine, string $testPath, string $outPath): void
308    {
309        $url = $this->daemonUrlFor($engine, '/render');
310        if ($url === null) {
311            throw new \RuntimeException("daemon URL unavailable for engine $engine");
312        }
313        $payload = json_encode([
314            'fixture' => $this->translateFixturePath($testPath),
315            'cache_key' => $this->cacheKey($engine, $testPath),
316            'viewport' => ['width' => 816, 'height' => 1056],
317            'timeout_ms' => 60000,
318        ], JSON_UNESCAPED_SLASHES);
319        if ($payload === false) {
320            throw new \RuntimeException("failed to encode daemon request for $testPath");
321        }
322        $ch = curl_init($url);
323        curl_setopt_array($ch, [
324            CURLOPT_RETURNTRANSFER => true,
325            CURLOPT_POST => true,
326            CURLOPT_HTTPHEADER => ['content-type: application/json'],
327            CURLOPT_POSTFIELDS => $payload,
328            CURLOPT_TIMEOUT => 90, // > daemon's 60s render budget, < forever
329            CURLOPT_CONNECTTIMEOUT => 5,
330        ]);
331        $body = curl_exec($ch);
332        $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
333        $err = curl_error($ch);
334        curl_close($ch);
335        if ($body === false) {
336            throw new \RuntimeException("$engine daemon request failed: $err");
337        }
338        $decoded = json_decode((string) $body, true);
339        if (!is_array($decoded)) {
340            throw new \RuntimeException(
341                "$engine daemon returned non-JSON (HTTP $code): " . substr((string) $body, 0, 200),
342            );
343        }
344        if ($code !== 200) {
345            $msg = $decoded['error'] ?? 'unknown daemon error';
346            throw new \RuntimeException("$engine daemon HTTP $code$msg");
347        }
348        $b64 = $decoded['pdf_bytes_base64'] ?? null;
349        if (!is_string($b64) || $b64 === '') {
350            throw new \RuntimeException("$engine daemon returned no PDF bytes");
351        }
352        $bytes = base64_decode($b64, strict: true);
353        if ($bytes === false || $bytes === '') {
354            throw new \RuntimeException("$engine daemon returned malformed base64");
355        }
356        file_put_contents($outPath, $bytes);
357    }
358
359    /**
360     * Translate a host fixture path into the daemon-visible path. The
361     * daemon sees the WPT corpus at `daemonWptRoot` (default `/wpt`);
362     * the host sees it wherever `hostWptRoot` resolves to (default
363     * `vendor-data/wpt` relative to the repo).
364     */
365    private function translateFixturePath(string $hostPath): string
366    {
367        $hostRoot = $this->hostWptRoot ?? realpath(__DIR__ . '/../../../vendor-data/wpt');
368        if (!is_string($hostRoot) || $hostRoot === '') {
369            return $hostPath;
370        }
371        $real = realpath($hostPath) ?: $hostPath;
372        if (str_starts_with($real, $hostRoot . '/')) {
373            return $this->daemonWptRoot . substr($real, strlen($hostRoot));
374        }
375        return $hostPath;
376    }
377
378    /**
379     * First-64-hex-chars of the cache key (matches the substring
380     * pulled in `cachedPath()`). Daemons receive this as `cache_key`
381     * in the request body and use it directly as the cache filename
382     * stem.
383     */
384    private function cacheKey(string $engine, string $testPath): string
385    {
386        $key = hash('sha256', (string) file_get_contents($testPath))
387            . '-' . $this->cacheGeneration
388            . '-' . $engine;
389        return substr($key, 0, 64);
390    }
391
392    private function binaryAvailable(string $binary): bool
393    {
394        $cmd = sprintf('command -v %s >/dev/null 2>&1', escapeshellcmd($binary));
395        exec($cmd, $_, $status);
396        return $status === 0;
397    }
398
399    private function dockerDaemonRunning(): bool
400    {
401        $cmd = sprintf('%s info >/dev/null 2>&1', escapeshellcmd($this->dockerBinary));
402        exec($cmd, $_, $status);
403        return $status === 0;
404    }
405
406    private function ensureCacheDir(string $engine): void
407    {
408        $dir = $this->cacheDir . '/' . $engine;
409        if (!is_dir($dir)) {
410            @mkdir($dir, 0775, recursive: true);
411        }
412    }
413}