Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
55.09% covered (warning)
55.09%
119 / 216
17.65% covered (danger)
17.65%
3 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
HarnessRunner
55.09% covered (warning)
55.09%
119 / 216
17.65% covered (danger)
17.65%
3 / 17
473.54
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
 run
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
6.03
 runOne
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 runRendered
36.76% covered (danger)
36.76%
25 / 68
0.00% covered (danger)
0.00%
0 / 1
29.48
 parseFuzzyMeta
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
56
 resolveTestFile
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 locateReference
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 locateLinkRelMatchReference
50.00% covered (danger)
50.00%
9 / 18
0.00% covered (danger)
0.00%
0 / 1
19.12
 renderToPng
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
2.01
 renderHtmlToPdf
76.19% covered (warning)
76.19%
16 / 21
0.00% covered (danger)
0.00%
0 / 1
5.34
 renderSvgToPdf
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
30
 manifest
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 rasteriser
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 scorer
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 wptRoot
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 discoverTests
85.71% covered (warning)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
6.10
 testIdFromPath
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
5.09
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\WptHarness;
6
7/**
8 * End-to-end WPT test runner — walks the corpus under `$wptRoot`,
9 * classifies each test via {@see Manifest}, renders the in-scope
10 * tests through `phpdftk/html-to-pdf` (or `phpdftk/svg-to-pdf` for
11 * SVG tests), rasterises via {@see Rasteriser}, diffs via
12 * {@see Scorer}, and emits a per-test {@see TestResult} ledger.
13 *
14 * Phase 4A.1 implements the walker + classification path. The
15 * actual rendering / rasterisation / scoring lands in 4A.2 + 4A.3;
16 * until those ship, in-scope tests are marked `Skipped` with a
17 * reason explaining the missing substrate so the dashboard can
18 * communicate progress accurately.
19 *
20 * Test discovery: recursive glob for HTML / XHT / SVG files under
21 * `$wptRoot`. Files matching `*-ref.*` are treated as reference
22 * renderings (skipped — they're the expected output for some
23 * other test, not a test themselves).
24 */
25final class HarnessRunner
26{
27    /** @var list<string> File extensions recognised as test files. */
28    private const TEST_EXTENSIONS = ['html', 'xht', 'xhtml', 'htm', 'svg'];
29
30    public function __construct(
31        private readonly Manifest $manifest,
32        private readonly Rasteriser $rasteriser,
33        private readonly Scorer $scorer,
34        private readonly string $wptRoot,
35        /**
36         * Optional DOM settler. When present, fixtures carrying
37         * `class="reftest-wait"` are shelled through Playwright to
38         * settle their JavaScript before the PHP renderer sees them.
39         * Null skips settling entirely (the legacy behaviour) and
40         * lets tests with unrun setup-JS reflect their pre-settled
41         * state, matching how the harness behaved before this hook
42         * was added.
43         */
44        private readonly ?DomSettler $domSettler = null,
45    ) {}
46
47    /**
48     * Run the harness corpus. Returns one {@see TestResult} per
49     * test that was either rendered or classified.
50     *
51     * `$filter` accepts the same glob syntax as the manifest rule
52     * files (`*` matches within a segment, `**` matches across) —
53     * tests whose ID doesn't match are excluded from the run.
54     *
55     * @return list<TestResult>
56     */
57    public function run(?string $filter = null): array
58    {
59        if (!is_dir($this->wptRoot)) {
60            return [];
61        }
62
63        $results = [];
64        foreach ($this->discoverTests($this->wptRoot) as $absolutePath) {
65            $testId = $this->testIdFromPath($absolutePath);
66            if ($testId === null) {
67                continue;
68            }
69            if ($filter !== null && !Manifest::matches($filter, $testId)) {
70                continue;
71            }
72            $results[] = $this->runOne($testId);
73        }
74        return $results;
75    }
76
77    /**
78     * Classify a single test ID without scanning the filesystem.
79     * Useful for `composer wpt classify <id>` and for tests of
80     * this class.
81     */
82    public function runOne(string $testId): TestResult
83    {
84        $verdict = $this->manifest->classify($testId);
85        if ($verdict !== null) {
86            // Out-of-scope or pending-substrate — no render needed.
87            return new TestResult(
88                testId: $testId,
89                status: $verdict['status'],
90                diffScore: 0.0,
91                reason: $verdict['reason'],
92                diffArtefactPath: null,
93                renderMicros: 0.0,
94            );
95        }
96
97        // In-scope: render the test through phpdftk, rasterise via
98        // Ghostscript, visually-diff against the WPT reference.
99        return $this->runRendered($testId);
100    }
101
102    /**
103     * Render an in-scope test, rasterise the resulting PDF, and
104     * visually-diff against its WPT reference. Tests without a
105     * `*-ref.{png,html,xht,svg}` sibling are reported as Skipped
106     * — the harness can't know what "pass" means without one.
107     */
108    private function runRendered(string $testId): TestResult
109    {
110        $rootAbs = realpath($this->wptRoot);
111        if ($rootAbs === false) {
112            return new TestResult(
113                testId: $testId,
114                status: TestStatus::HarnessError,
115                diffScore: 0.0,
116                reason: "wpt root not accessible: $this->wptRoot",
117                diffArtefactPath: null,
118                renderMicros: 0.0,
119            );
120        }
121        $testPath = $this->resolveTestFile($rootAbs, $testId);
122        if ($testPath === null) {
123            return new TestResult(
124                testId: $testId,
125                status: TestStatus::HarnessError,
126                diffScore: 0.0,
127                reason: 'test file not found for ID',
128                diffArtefactPath: null,
129                renderMicros: 0.0,
130            );
131        }
132        $refPath = $this->locateReference($testPath);
133        if ($refPath === null) {
134            return new TestResult(
135                testId: $testId,
136                status: TestStatus::Skipped,
137                diffScore: 0.0,
138                reason: 'no -ref.{png,html,xht,svg} sibling found',
139                diffArtefactPath: null,
140                renderMicros: 0.0,
141            );
142        }
143
144        $start = hrtime(true);
145        try {
146            $renderedPng = $this->renderToPng($testPath);
147        } catch (\Throwable $e) {
148            return new TestResult(
149                testId: $testId,
150                status: TestStatus::Fail,
151                diffScore: 1.0,
152                reason: 'render failed: ' . $e->getMessage(),
153                diffArtefactPath: null,
154                renderMicros: (hrtime(true) - $start) / 1000.0,
155            );
156        }
157        $renderMicros = (hrtime(true) - $start) / 1000.0;
158
159        try {
160            $refPng = str_ends_with(strtolower($refPath), '.png')
161                ? $refPath
162                : $this->renderToPng($refPath);
163        } catch (\Throwable $e) {
164            @unlink($renderedPng);
165            return new TestResult(
166                testId: $testId,
167                status: TestStatus::HarnessError,
168                diffScore: 1.0,
169                reason: 'reference render failed: ' . $e->getMessage(),
170                diffArtefactPath: null,
171                renderMicros: $renderMicros,
172            );
173        }
174
175        $fuzzy = $this->parseFuzzyMeta($testPath);
176        $diff = $this->scorer->diff($renderedPng, $refPng, $fuzzy['maxPixels']);
177        @unlink($renderedPng);
178        if ($refPng !== $refPath) {
179            @unlink($refPng);
180        }
181
182        return new TestResult(
183            testId: $testId,
184            status: $diff['passed'] ? TestStatus::Pass : TestStatus::Fail,
185            diffScore: $diff['score'],
186            reason: $diff['reason'],
187            diffArtefactPath: $diff['diffImage'] ?? null,
188            renderMicros: $renderMicros,
189        );
190    }
191
192    /**
193     * Parse the WPT `<meta name="fuzzy">` annotation from a test file.
194     *
195     * Format (CSS-WG convention):
196     *   maxDifference=A-B; totalPixels=C-D
197     *   maxDifference=A-B;totalPixels=C-D
198     *   A-B;C-D                              (positional shorthand)
199     *
200     * Both ranges are inclusive bounds; the *upper* bound is the one
201     * the renderer must respect. We surface the upper-bound pixel
202     * count to the Scorer so tests with relaxed tolerances (e.g.
203     * `totalPixels=0-127500`) pass when our renderer is within
204     * spec-allowed difference but not pixel-perfect.
205     *
206     * Returns `['maxPixels' => null]` when no annotation is present;
207     * `null` tells the Scorer to use its default threshold.
208     *
209     * @return array{maxPixels: int|null}
210     */
211    private function parseFuzzyMeta(string $testPath): array
212    {
213        $head = @file_get_contents($testPath, false, null, 0, 64 * 1024);
214        if ($head === false || $head === '') {
215            return ['maxPixels' => null];
216        }
217        if (preg_match(
218            '~<meta\s+[^>]*?name\s*=\s*["\']fuzzy["\']\s+[^>]*?content\s*=\s*["\']([^"\']+)["\']~i',
219            $head,
220            $m,
221        ) !== 1) {
222            return ['maxPixels' => null];
223        }
224        $content = trim($m[1]);
225        // Look for `totalPixels=<lo>-<hi>` first; fall back to the last
226        // semicolon-separated range in positional shorthand.
227        if (preg_match('~totalPixels\s*=\s*\d+\s*-\s*(\d+)~i', $content, $m2) === 1) {
228            return ['maxPixels' => (int) $m2[1]];
229        }
230        $parts = array_map('trim', explode(';', $content));
231        if (count($parts) >= 2 && preg_match('~^\d+\s*-\s*(\d+)$~', $parts[1], $m3) === 1) {
232            return ['maxPixels' => (int) $m3[1]];
233        }
234        return ['maxPixels' => null];
235    }
236
237    private function resolveTestFile(string $rootAbs, string $testId): ?string
238    {
239        foreach (self::TEST_EXTENSIONS as $ext) {
240            $candidate = $rootAbs . '/' . $testId . '.' . $ext;
241            if (is_file($candidate)) {
242                return $candidate;
243            }
244        }
245        return null;
246    }
247
248    /**
249     * Locate the reference rendering for a WPT reftest. WPT supports
250     * two conventions:
251     *
252     *  1. **Filename**: a sibling `<stem>-ref.{png,html,xht,svg}`
253     *     file. Simple and unambiguous; PNG wins when both exist
254     *     since it short-circuits a re-render.
255     *
256     *  2. **`<link rel="match" href="…">`** inside the test's
257     *     `<head>`. The actual WPT corpus uses this for the
258     *     majority of tests — the reference often lives in a
259     *     sibling directory, sometimes with a name unrelated to
260     *     the test stem.
261     *
262     * `rel="mismatch"` is the negative variant and is skipped here
263     * — the harness doesn't yet implement "must not match"
264     * semantics (a follow-up).
265     */
266    private function locateReference(string $testPath): ?string
267    {
268        $info = pathinfo($testPath);
269        $dir = $info['dirname'] ?? '.';
270        $stem = $info['filename'] ?? '';
271        $candidates = [
272            $dir . '/' . $stem . '-ref.png',
273            $dir . '/' . $stem . '-ref.html',
274            $dir . '/' . $stem . '-ref.xht',
275            $dir . '/' . $stem . '-ref.svg',
276        ];
277        foreach ($candidates as $cand) {
278            if (is_file($cand)) {
279                return $cand;
280            }
281        }
282        return $this->locateLinkRelMatchReference($testPath);
283    }
284
285    /**
286     * Parse `<link rel="match" href="…">` from the head of a
287     * `.html` / `.xht` / `.svg` test file and resolve the href to
288     * an on-disk path relative to the test. Returns the first
289     * matching reference; `rel="mismatch"` is intentionally
290     * ignored.
291     *
292     * Read is bounded to the first 64 KB so a malformed test
293     * can't stall the harness — WPT's `<head>` always sits in
294     * the first few hundred bytes anyway.
295     */
296    private function locateLinkRelMatchReference(string $testPath): ?string
297    {
298        $head = @file_get_contents($testPath, false, null, 0, 64 * 1024);
299        if ($head === false || $head === '') {
300            return null;
301        }
302        // Match either attribute order (rel-first or href-first) by trying
303        // two patterns rather than alternation — keeps PHPStan happy and
304        // makes the failure mode obvious.
305        $relFirst = '~<link\s+[^>]*?rel\s*=\s*["\']match["\']\s+[^>]*?href\s*=\s*["\']([^"\']+)["\']~i';
306        $hrefFirst = '~<link\s+[^>]*?href\s*=\s*["\']([^"\']+)["\']\s+[^>]*?rel\s*=\s*["\']match["\']~i';
307        $href = null;
308        if (preg_match($relFirst, $head, $matches) === 1) {
309            $href = $matches[1];
310        } elseif (preg_match($hrefFirst, $head, $matches) === 1) {
311            $href = $matches[1];
312        }
313        if ($href === null) {
314            return null;
315        }
316        $dir = dirname($testPath);
317        $resolved = str_starts_with($href, '/')
318            ? $this->wptRoot . $href
319            : $dir . '/' . $href;
320        $real = realpath($resolved);
321        return ($real !== false && is_file($real)) ? $real : null;
322    }
323
324    /**
325     * Render a single test file through the phpdftk pipeline and
326     * rasterise the first page of the resulting PDF.
327     */
328    private function renderToPng(string $path): string
329    {
330        $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
331        $pdfBytes = $ext === 'svg'
332            ? $this->renderSvgToPdf($path)
333            : $this->renderHtmlToPdf($path);
334        $pdfPath = tempnam(sys_get_temp_dir(), 'wpt_pdf_') . '.pdf';
335        file_put_contents($pdfPath, $pdfBytes);
336        try {
337            return $this->rasteriser->rasterise($pdfPath, 0);
338        } finally {
339            @unlink($pdfPath);
340        }
341    }
342
343    private function renderHtmlToPdf(string $path): string
344    {
345        if (!class_exists('Phpdftk\\HtmlToPdf\\Renderer')) {
346            throw new \RuntimeException('phpdftk/html-to-pdf not installed');
347        }
348        $html = file_get_contents($path);
349        if ($html === false) {
350            throw new \RuntimeException("could not read test file: $path");
351        }
352        // Settle reftest-wait fixtures through Playwright so the
353        // PHP renderer sees the post-JS DOM (inline-style shifts
354        // from getBoundingClientRect, fonts marked loaded via
355        // FontFace API, etc.). When no settler is configured, the
356        // settler isn't available on this host, or settling fails,
357        // fall back to the pre-JS source - the harness still
358        // produces a result, just one that reflects an unrun test.
359        if ($this->domSettler !== null) {
360            $settled = $this->domSettler->maybeSettle($path, $html);
361            if ($settled !== null) {
362                $html = $settled;
363            }
364        }
365        // Sandbox to the WPT corpus root so refs in `reference/`
366        // subdirs can resolve `../support/img.png` siblings of the
367        // test directory. baseDir alone is too tight — the default
368        // ResourceLoader sandbox is the same as baseDir, which
369        // rejects any `..` walk.
370        $renderer = new \Phpdftk\HtmlToPdf\Renderer(
371            (new \Phpdftk\HtmlToPdf\RendererOptions())
372                ->withBaseDir(dirname($path))
373                ->withSandboxRoot($this->wptRoot)
374                // WPT test corpus is browser-targeted, so the vast
375                // majority of `@media` rules gate on `screen` rather
376                // than `print`. Match both so author CSS applies the
377                // way the test fixtures (and their references) expect.
378                ->withMatchingMediaTypes(['print', 'screen']),
379        );
380        $result = $renderer->render($html);
381        return $result->writer->toBytes();
382    }
383
384    private function renderSvgToPdf(string $path): string
385    {
386        if (!class_exists('Phpdftk\\SvgToPdf\\SvgRenderer')
387            || !class_exists('Phpdftk\\Svg\\Parser')
388            || !class_exists('Phpdftk\\Pdf\\Writer\\PdfWriter')
389        ) {
390            throw new \RuntimeException('svg-to-pdf renderer stack not installed');
391        }
392        $svgSource = file_get_contents($path);
393        if ($svgSource === false) {
394            throw new \RuntimeException("could not read test file: $path");
395        }
396        $writer = new \Phpdftk\Pdf\Writer\PdfWriter();
397        $page = $writer->addPage();
398        $svgDoc = (new \Phpdftk\Svg\Parser())->parse($svgSource);
399        $renderer = new \Phpdftk\SvgToPdf\SvgRenderer($page, $writer);
400        $renderer->draw($svgDoc, x: 0, y: 0);
401        return $writer->toBytes();
402    }
403
404    public function manifest(): Manifest
405    {
406        return $this->manifest;
407    }
408
409    public function rasteriser(): Rasteriser
410    {
411        return $this->rasteriser;
412    }
413
414    public function scorer(): Scorer
415    {
416        return $this->scorer;
417    }
418
419    public function wptRoot(): string
420    {
421        return $this->wptRoot;
422    }
423
424    /**
425     * Recursively yield absolute paths to test files under the
426     * given root. References (`*-ref.*`) are skipped — they're
427     * targets for the diff scorer, not tests.
428     *
429     * @return iterable<string>
430     */
431    private function discoverTests(string $root): iterable
432    {
433        $iterator = new \RecursiveIteratorIterator(
434            new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS),
435        );
436        foreach ($iterator as $info) {
437            assert($info instanceof \SplFileInfo);
438            if (!$info->isFile()) {
439                continue;
440            }
441            $extension = strtolower($info->getExtension());
442            if (!in_array($extension, self::TEST_EXTENSIONS, true)) {
443                continue;
444            }
445            $stem = $info->getBasename('.' . $info->getExtension());
446            // `*-ref.html`, `*-ref.xht`, `*-notref.html` are
447            // expected-rendering / negative-match siblings, not
448            // tests themselves.
449            if (str_ends_with($stem, '-ref') || str_ends_with($stem, '-notref')) {
450                continue;
451            }
452            yield $info->getPathname();
453        }
454    }
455
456    /**
457     * Convert an absolute test-file path into a stable test ID —
458     * the relative path under `$wptRoot`, minus the file
459     * extension. POSIX-style separators so manifest globs match
460     * cross-platform.
461     */
462    private function testIdFromPath(string $absolutePath): ?string
463    {
464        $rootAbs = realpath($this->wptRoot);
465        $pathAbs = realpath($absolutePath);
466        if ($rootAbs === false || $pathAbs === false) {
467            return null;
468        }
469        if (!str_starts_with($pathAbs, $rootAbs)) {
470            return null;
471        }
472        $relative = substr($pathAbs, strlen($rootAbs));
473        $relative = ltrim($relative, '/\\');
474        // Normalise to POSIX separators.
475        $relative = str_replace('\\', '/', $relative);
476        // Strip the extension.
477        $dotPos = strrpos($relative, '.');
478        if ($dotPos !== false) {
479            $relative = substr($relative, 0, $dotPos);
480        }
481        return $relative;
482    }
483}