Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
52.89% covered (warning)
52.89%
229 / 433
15.62% covered (danger)
15.62%
5 / 32
CRAP
0.00% covered (danger)
0.00%
0 / 1
HarnessRunner
52.89% covered (warning)
52.89%
229 / 433
15.62% covered (danger)
15.62%
5 / 32
3047.66
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
 harnessDefaultFont
52.94% covered (warning)
52.94%
9 / 17
0.00% covered (danger)
0.00%
0 / 1
20.42
 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
 renderDiffArtefacts
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
110
 renderOneArtefact
0.00% covered (danger)
0.00%
0 / 50
0.00% covered (danger)
0.00%
0 / 1
156
 runRendered
33.33% covered (danger)
33.33%
25 / 75
0.00% covered (danger)
0.00%
0 / 1
72.07
 compareAgainst
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
20
 fromVerdict
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 unsatisfiedReason
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 referenceRenderFailed
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 discard
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
12
 relativeToRoot
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 fuzzyFor
90.48% covered (success)
90.48%
19 / 21
0.00% covered (danger)
0.00%
0 / 1
10.09
 resolveTestFile
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 references
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
5
 locateReference
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 declaredReftestReferences
88.24% covered (warning)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
9.13
 resolveHref
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
5.03
 elementsNamed
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 attributeValue
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 renderToPng
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
2.01
 renderHtmlToPdf
77.27% covered (warning)
77.27%
17 / 22
0.00% covered (danger)
0.00%
0 / 1
6.42
 renderSvgToPdf
90.91% covered (success)
90.91%
20 / 22
0.00% covered (danger)
0.00%
0 / 1
5.02
 svgRootViewport
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
14.04
 parseSvgRootLength
70.00% covered (warning)
70.00%
14 / 20
0.00% covered (danger)
0.00%
0 / 1
17.56
 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 *
25 * @phpstan-import-type DiffResult from Scorer
26 */
27final class HarnessRunner
28{
29    /** @var list<string> File extensions recognised as test files. */
30    private const TEST_EXTENSIONS = ['html', 'xht', 'xhtml', 'htm', 'svg'];
31
32    /**
33     * How much of a fixture to scan for `<link rel=match>` and
34     * `<meta name=fuzzy>` declarations. WPT parses the whole
35     * document; a bounded read keeps a pathological fixture from
36     * stalling the harness. Verified against the full corpus: no
37     * fixture declares either past this offset.
38     */
39    private const MARKUP_SCAN_BYTES = 64 * 1024;
40
41    public function __construct(
42        private readonly Manifest $manifest,
43        private readonly Rasteriser $rasteriser,
44        private readonly Scorer $scorer,
45        private readonly string $wptRoot,
46        /**
47         * Optional DOM settler. When present, fixtures carrying
48         * `class="reftest-wait"` are shelled through Playwright to
49         * settle their JavaScript before the PHP renderer sees them.
50         * Null skips settling entirely (the legacy behaviour) and
51         * lets tests with unrun setup-JS reflect their pre-settled
52         * state, matching how the harness behaved before this hook
53         * was added.
54         */
55        private readonly ?DomSettler $domSettler = null,
56    ) {}
57
58    /**
59     * Bundled UA default font for text emission. Browsers render unstyled
60     * text in their default serif (Times), so the harness ships a real
61     * serif (DejaVu Serif) as the default `font-family` fallback — without
62     * it, text has zero advance/height and text-dependent reftests pass
63     * BLANK (test and reference both empty), silently hiding real
64     * text-layout gaps. Test AND reference render with the same font, so
65     * the comparison stays self-consistent; refs that hard-code Times pixel
66     * metrics use Ahem (loaded from the corpus) and are unaffected.
67     *
68     * Override the file via the `WPT_DEFAULT_FONT` env var (a .ttf/.otf/
69     * .woff path); set it to `none` to run font-less (the legacy mode).
70     * `false` = not yet resolved; `null` = resolved to "no font".
71     */
72    private \Phpdftk\FontParser\FontFaceData|null|false $harnessDefaultFont = false;
73
74    private function harnessDefaultFont(): ?\Phpdftk\FontParser\FontFaceData
75    {
76        if ($this->harnessDefaultFont !== false) {
77            return $this->harnessDefaultFont;
78        }
79        $env = getenv('WPT_DEFAULT_FONT');
80        if ($env === 'none') {
81            return $this->harnessDefaultFont = null;
82        }
83        $path = ($env !== false && $env !== '')
84            ? $env
85            : __DIR__ . '/../resources/fonts/DejaVuSerif.ttf';
86        if (!is_file($path)) {
87            return $this->harnessDefaultFont = null;
88        }
89        // Dispatch by container: .otf (CFF) / .woff / .woff2 have dedicated
90        // parsers; .ttf (TrueType glyf) is the default.
91        $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
92        try {
93            return $this->harnessDefaultFont = match ($ext) {
94                'otf' => (new \Phpdftk\FontParser\OpenTypeParser($path))->parse(),
95                'woff' => (new \Phpdftk\FontParser\WoffParser($path))->parse(),
96                default => (new \Phpdftk\FontParser\TrueTypeParser($path))->parse(),
97            };
98        } catch (\Throwable) {
99            return $this->harnessDefaultFont = null;
100        }
101    }
102
103    /**
104     * Run the harness corpus. Returns one {@see TestResult} per
105     * test that was either rendered or classified.
106     *
107     * `$filter` accepts the same glob syntax as the manifest rule
108     * files (`*` matches within a segment, `**` matches across) —
109     * tests whose ID doesn't match are excluded from the run.
110     *
111     * @return list<TestResult>
112     */
113    public function run(?string $filter = null): array
114    {
115        if (!is_dir($this->wptRoot)) {
116            return [];
117        }
118
119        $results = [];
120        foreach ($this->discoverTests($this->wptRoot) as $absolutePath) {
121            $testId = $this->testIdFromPath($absolutePath);
122            if ($testId === null) {
123                continue;
124            }
125            if ($filter !== null && !Manifest::matches($filter, $testId)) {
126                continue;
127            }
128            $results[] = $this->runOne($testId);
129        }
130        return $results;
131    }
132
133    /**
134     * Classify a single test ID without scanning the filesystem.
135     * Useful for `composer wpt classify <id>` and for tests of
136     * this class.
137     */
138    public function runOne(string $testId): TestResult
139    {
140        $verdict = $this->manifest->classify($testId);
141        if ($verdict !== null) {
142            // Out-of-scope or pending-substrate — no render needed.
143            return new TestResult(
144                testId: $testId,
145                status: $verdict['status'],
146                diffScore: 0.0,
147                reason: $verdict['reason'],
148                diffArtefactPath: null,
149                renderMicros: 0.0,
150            );
151        }
152
153        // In-scope: render the test through phpdftk, rasterise via
154        // Ghostscript, visually-diff against the WPT reference.
155        return $this->runRendered($testId);
156    }
157
158    /**
159     * Debug aid — write the visual-diff artefacts (the rendered PNG, the
160     * reference PNG, and the ImageMagick diff PNG) for every in-scope test
161     * matching `$filter`, up to `$limit`, into `$outDir`. Reuses the exact
162     * production render → rasterise → diff pipeline (same `Renderer`
163     * options, same `Rasteriser`, same `Scorer`) so the artefacts match
164     * what `run` scores — no divergence from a hand-rolled renderer.
165     *
166     * @return list<array{testId: string, score: float, passed: bool, relation: string, rendered: ?string, reference: ?string, diff: ?string, reason: ?string}>
167     */
168    public function renderDiffArtefacts(?string $filter, string $outDir, int $limit = 20): array
169    {
170        if (!is_dir($outDir) && !@mkdir($outDir, 0o777, true) && !is_dir($outDir)) {
171            throw new \RuntimeException("cannot create artefact dir: $outDir");
172        }
173        $rows = [];
174        foreach ($this->discoverTests($this->wptRoot) as $absolutePath) {
175            if (count($rows) >= $limit) {
176                break;
177            }
178            $testId = $this->testIdFromPath($absolutePath);
179            if ($testId === null) {
180                continue;
181            }
182            if ($filter !== null && !Manifest::matches($filter, $testId)) {
183                continue;
184            }
185            if ($this->manifest->classify($testId) !== null) {
186                // Out-of-scope / pending-substrate — nothing to render.
187                continue;
188            }
189            $rows[] = $this->renderOneArtefact($testId, $outDir);
190        }
191
192        return $rows;
193    }
194
195    /**
196     * @return array{testId: string, score: float, passed: bool, relation: string, rendered: ?string, reference: ?string, diff: ?string, reason: ?string}
197     */
198    private function renderOneArtefact(string $testId, string $outDir): array
199    {
200        $row = [
201            'testId' => $testId,
202            'score' => 1.0,
203            'passed' => false,
204            'relation' => ReferenceRelation::Match->value,
205            'rendered' => null,
206            'reference' => null,
207            'diff' => null,
208            'reason' => null,
209        ];
210        $safe = preg_replace('/[^A-Za-z0-9._-]/', '_', $testId) ?? $testId;
211        $base = rtrim($outDir, '/') . '/' . $safe;
212
213        $rootAbs = realpath($this->wptRoot);
214        $testPath = $rootAbs !== false ? $this->resolveTestFile($rootAbs, $testId) : null;
215        if ($testPath === null) {
216            $row['reason'] = 'test file not found';
217
218            return $row;
219        }
220        $references = $this->references($testPath);
221        if ($references === []) {
222            $row['reason'] = 'no -ref sibling / rel=match|mismatch reference';
223
224            return $row;
225        }
226        // Artefacts are a debugging aid, so show the first reference
227        // WPT would try rather than every one of them.
228        $reference = $references[0];
229        $refPath = $reference->path;
230        $row['relation'] = $reference->relation->value;
231        try {
232            $renderedPng = $this->renderToPng($testPath);
233        } catch (\Throwable $e) {
234            $row['reason'] = 'render failed: ' . $e->getMessage();
235
236            return $row;
237        }
238        try {
239            $refPng = str_ends_with(strtolower($refPath), '.png')
240                ? $refPath
241                : $this->renderToPng($refPath);
242        } catch (\Throwable $e) {
243            @unlink($renderedPng);
244            $row['reason'] = 'reference render failed: ' . $e->getMessage();
245
246            return $row;
247        }
248        $diff = $this->scorer->diff($renderedPng, $refPng, $this->fuzzyFor($testPath, $refPath));
249
250        @copy($renderedPng, $base . '-rendered.png');
251        @copy($refPng, $base . '-ref.png');
252        if ($diff['diffImage'] !== null) {
253            @copy($diff['diffImage'], $base . '-diff.png');
254            @unlink($diff['diffImage']);
255        }
256        @unlink($renderedPng);
257        if ($refPng !== $refPath) {
258            @unlink($refPng);
259        }
260
261        $row['score'] = $diff['score'];
262        $row['passed'] = $reference->relation->satisfiedBy($diff['passed']);
263        $row['rendered'] = is_file($base . '-rendered.png') ? $base . '-rendered.png' : null;
264        $row['reference'] = is_file($base . '-ref.png') ? $base . '-ref.png' : null;
265        $row['diff'] = is_file($base . '-diff.png') ? $base . '-diff.png' : null;
266
267        return $row;
268    }
269
270    /**
271     * Render an in-scope test, rasterise the resulting PDF, and
272     * visually-diff it against its WPT references. Tests that declare
273     * no reference and have no `*-ref.{png,html,xht,svg}` sibling are
274     * reported as Skipped — the harness can't know what "pass" means
275     * without one.
276     *
277     * The verdict over a multi-reference fixture is the one in
278     * `docs/writing-tests/reftests.md`:
279     *
280     *     If there are any match references, at least one must match,
281     *     and if there are any mismatch references, all must mismatch.
282     *
283     * Read on its own, `executors/base.py::run_test` looks like a
284     * plain disjunction — it walks a stack of references and returns
285     * PASS at the first satisfied one. The conjunction over the
286     * mismatches comes from the shape of the tree it is walking,
287     * which `wpttest.py::ReftestTest.from_manifest` builds: mismatch
288     * references are chained BEHIND the match references rather than
289     * listed beside them, so reaching a leaf means clearing one match
290     * AND every mismatch. Porting the executor without that tree
291     * would turn "must not look like this" into an alternative way to
292     * pass — the exact opposite of the assertion the fixture makes.
293     *
294     * One deliberate simplification: WPT compares each mismatch
295     * reference against the match reference that just succeeded (its
296     * chain is `ref != mismatch`), because that screenshot is already
297     * in its cache. This compares the *test* against each mismatch
298     * reference instead. It is the same assertion — the test and the
299     * matched reference were just shown to be equal — and it is the
300     * one the documentation states.
301     *
302     * (wptrunner also recurses into a reference\'s own `rel=match`
303     * links, so a reference can have references. The harness does not
304     * follow those chains yet.)
305     */
306    private function runRendered(string $testId): TestResult
307    {
308        $rootAbs = realpath($this->wptRoot);
309        if ($rootAbs === false) {
310            return new TestResult(
311                testId: $testId,
312                status: TestStatus::HarnessError,
313                diffScore: 0.0,
314                reason: "wpt root not accessible: $this->wptRoot",
315                diffArtefactPath: null,
316                renderMicros: 0.0,
317            );
318        }
319        $testPath = $this->resolveTestFile($rootAbs, $testId);
320        if ($testPath === null) {
321            return new TestResult(
322                testId: $testId,
323                status: TestStatus::HarnessError,
324                diffScore: 0.0,
325                reason: 'test file not found for ID',
326                diffArtefactPath: null,
327                renderMicros: 0.0,
328            );
329        }
330        $references = $this->references($testPath);
331        if ($references === []) {
332            return new TestResult(
333                testId: $testId,
334                status: TestStatus::Skipped,
335                diffScore: 0.0,
336                reason: 'no rel=match / rel=mismatch reference and no -ref.{png,html,xht,svg} sibling',
337                diffArtefactPath: null,
338                renderMicros: 0.0,
339            );
340        }
341
342        $start = hrtime(true);
343        try {
344            $renderedPng = $this->renderToPng($testPath);
345        } catch (\Throwable $e) {
346            return new TestResult(
347                testId: $testId,
348                status: TestStatus::Fail,
349                diffScore: 1.0,
350                reason: 'render failed: ' . $e->getMessage(),
351                diffArtefactPath: null,
352                renderMicros: (hrtime(true) - $start) / 1000.0,
353            );
354        }
355        $renderMicros = (hrtime(true) - $start) / 1000.0;
356
357        // "At least one match must match" and "all mismatches must
358        // mismatch" are two different quantifiers, so they are two
359        // separate walks.
360        $matches = array_values(array_filter(
361            $references,
362            static fn(ReftestReference $r) => $r->relation === ReferenceRelation::Match,
363        ));
364        $mismatches = array_values(array_filter(
365            $references,
366            static fn(ReftestReference $r) => $r->relation === ReferenceRelation::Mismatch,
367        ));
368
369        try {
370            $verdict = null;
371            foreach ($matches as $reference) {
372                $comparison = $this->compareAgainst($testPath, $renderedPng, $reference);
373                if ($comparison === null) {
374                    return $this->referenceRenderFailed($testId, $reference, $renderMicros, $verdict);
375                }
376                self::discard($verdict);
377                $verdict = $comparison;
378                if ($comparison['satisfied']) {
379                    break;
380                }
381            }
382            // No match reference held. Nothing downstream can rescue
383            // the test: a mismatch reference is an extra condition,
384            // never an alternative one.
385            if ($verdict !== null && !$verdict['satisfied']) {
386                return $this->fromVerdict($testId, $verdict, $renderMicros);
387            }
388
389            foreach ($mismatches as $reference) {
390                $comparison = $this->compareAgainst($testPath, $renderedPng, $reference);
391                if ($comparison === null) {
392                    return $this->referenceRenderFailed($testId, $reference, $renderMicros, $verdict);
393                }
394                if (!$comparison['satisfied']) {
395                    // Rendering the same as something the fixture says
396                    // it must differ from settles the whole test.
397                    self::discard($verdict);
398                    $verdict = $comparison;
399                    break;
400                }
401                // A satisfied mismatch only confirms a verdict the
402                // match walk already reached; keep it only when there
403                // was no match reference to reach one.
404                if ($verdict === null) {
405                    $verdict = $comparison;
406                } else {
407                    self::discard($comparison);
408                }
409            }
410
411            assert($verdict !== null);
412
413            return $this->fromVerdict($testId, $verdict, $renderMicros);
414        } finally {
415            @unlink($renderedPng);
416        }
417    }
418
419    /**
420     * Compare the already-rendered test frame against one reference.
421     * Returns null when the reference itself could not be rendered.
422     *
423     * @return array{diff: DiffResult, reference: ReftestReference, satisfied: bool}|null
424     */
425    private function compareAgainst(
426        string $testPath,
427        string $renderedPng,
428        ReftestReference $reference,
429    ): ?array {
430        try {
431            $refPng = str_ends_with(strtolower($reference->path), '.png')
432                ? $reference->path
433                : $this->renderToPng($reference->path);
434        } catch (\Throwable) {
435            return null;
436        }
437        $diff = $this->scorer->diff(
438            $renderedPng,
439            $refPng,
440            $this->fuzzyFor($testPath, $reference->path),
441        );
442        if ($refPng !== $reference->path) {
443            @unlink($refPng);
444        }
445
446        return [
447            'diff' => $diff,
448            'reference' => $reference,
449            // `$diff['passed']` is "the two frames are equal within
450            // whatever tolerance applies"; the relation decides what
451            // that means for the verdict.
452            'satisfied' => $reference->relation->satisfiedBy($diff['passed']),
453        ];
454    }
455
456    /**
457     * Turn the deciding comparison into the test's ledger row.
458     *
459     * @param array{diff: DiffResult, reference: ReftestReference, satisfied: bool} $verdict
460     */
461    private function fromVerdict(string $testId, array $verdict, float $renderMicros): TestResult
462    {
463        $diff = $verdict['diff'];
464
465        return new TestResult(
466            testId: $testId,
467            status: $verdict['satisfied'] ? TestStatus::Pass : TestStatus::Fail,
468            diffScore: $diff['score'],
469            reason: $verdict['satisfied'] ? null : self::unsatisfiedReason($verdict, $this->relativeToRoot(
470                $verdict['reference']->path,
471            )),
472            diffArtefactPath: $diff['diffImage'] ?? null,
473            renderMicros: $renderMicros,
474            // A mismatch reference that the test DID match is a
475            // failure whose two frames are, by definition, equal —
476            // and can therefore be blank. That is not a blank pass,
477            // it is a caught one, so the flag stays off unless the
478            // verdict it is attached to is a pass.
479            bothRendersSolid: $verdict['satisfied'] && $diff['bothSolid'],
480        );
481    }
482
483    /**
484     * Why an unsatisfied comparison failed. A match reference already
485     * carries the scorer's own reason (usually null — the score says
486     * it); a mismatch reference has to say out loud that matching *is*
487     * the failure, or the ledger reads as a test that failed with a
488     * perfect score.
489     *
490     * @param array{diff: DiffResult, reference: ReftestReference, satisfied: bool} $verdict
491     */
492    private static function unsatisfiedReason(array $verdict, string $referenceId): ?string
493    {
494        if ($verdict['reference']->relation === ReferenceRelation::Match) {
495            return $verdict['diff']['reason'];
496        }
497
498        return sprintf(
499            'rendered the same as its rel=mismatch reference (%s), which it must not match',
500            $referenceId,
501        );
502    }
503
504    /**
505     * A reference the harness could not render at all. That is a
506     * harness fault, not a renderer one, so it lands in its own
507     * bucket rather than counting against the pass rate.
508     *
509     * @param array{diff: DiffResult, reference: ReftestReference, satisfied: bool}|null $pending
510     */
511    private function referenceRenderFailed(
512        string $testId,
513        ReftestReference $reference,
514        float $renderMicros,
515        ?array $pending,
516    ): TestResult {
517        self::discard($pending);
518
519        return new TestResult(
520            testId: $testId,
521            status: TestStatus::HarnessError,
522            diffScore: 1.0,
523            reason: 'reference render failed for ' . $this->relativeToRoot($reference->path),
524            diffArtefactPath: null,
525            renderMicros: $renderMicros,
526        );
527    }
528
529    /**
530     * Drop the diff artefact of a comparison the runner is no longer
531     * going to report.
532     *
533     * @param array{diff: DiffResult, reference: ReftestReference, satisfied: bool}|null $comparison
534     */
535    private static function discard(?array $comparison): void
536    {
537        if ($comparison !== null && $comparison['diff']['diffImage'] !== null) {
538            @unlink($comparison['diff']['diffImage']);
539        }
540    }
541
542    /**
543     * A corpus path as it reads in a test ID — relative to the WPT
544     * root — for failure messages.
545     */
546    private function relativeToRoot(string $path): string
547    {
548        $rootAbs = realpath($this->wptRoot);
549        if ($rootAbs !== false && str_starts_with($path, $rootAbs . '/')) {
550            return substr($path, strlen($rootAbs) + 1);
551        }
552
553        return $path;
554    }
555
556    /**
557     * Resolve the `<meta name="fuzzy">` tolerance that applies when
558     * this test is compared against `$referencePath`.
559     *
560     * WPT selects fuzzy nodes exactly the way it selects reference
561     * links — `.//{http://www.w3.org/1999/xhtml}meta[@name='fuzzy']` —
562     * so a namespace prefix and unquoted attribute values are both
563     * normal, and 237 corpus fixtures do write `name=fuzzy` unquoted.
564     * A matcher that demands `name="fuzzy"` in quotes, and demands it
565     * before `content`, drops every one of those fixtures back onto
566     * the harness default threshold without saying so.
567     *
568     * A declaration may be keyed to one specific reference
569     * (`content="ref.html:0-5;0-100"` — WPT's `parse_ref_keyed_meta`,
570     * splitting on the last colon). A keyed declaration applies only
571     * to the reference it names; an unkeyed one is the default for
572     * every reference.
573     *
574     * @internal Exposed for {@see \Phpdftk\WptHarness\Tests\FuzzyMetaResolutionTest}.
575     */
576    public function fuzzyFor(string $testPath, string $referencePath): ?FuzzyTolerance
577    {
578        $head = @file_get_contents($testPath, false, null, 0, self::MARKUP_SCAN_BYTES);
579        if ($head === false || $head === '') {
580            return null;
581        }
582        $unkeyed = null;
583        foreach (self::elementsNamed($head, 'meta') as $tag) {
584            if (self::attributeValue($tag, 'name') !== 'fuzzy') {
585                continue;
586            }
587            $content = self::attributeValue($tag, 'content');
588            if ($content === null) {
589                continue;
590            }
591            $colon = strrpos($content, ':');
592            if ($colon !== false) {
593                $keyedRef = $this->resolveHref($testPath, substr($content, 0, $colon));
594                if ($keyedRef !== null) {
595                    if ($keyedRef === $referencePath) {
596                        $keyed = FuzzyTolerance::parse(substr($content, $colon + 1));
597                        if ($keyed !== null) {
598                            return $keyed;
599                        }
600                    }
601                    // Keyed at a different reference — not ours.
602                    continue;
603                }
604            }
605            $unkeyed ??= FuzzyTolerance::parse($content);
606        }
607        return $unkeyed;
608    }
609
610    private function resolveTestFile(string $rootAbs, string $testId): ?string
611    {
612        foreach (self::TEST_EXTENSIONS as $ext) {
613            $candidate = $rootAbs . '/' . $testId . '.' . $ext;
614            if (is_file($candidate)) {
615                return $candidate;
616            }
617        }
618        return null;
619    }
620
621    /**
622     * The full reference list for a WPT reftest, in WPT's own order.
623     *
624     * WPT's manifest generator (`tools/manifest/sourcefile.py`)
625     * builds it from two `findall`s and one map:
626     *
627     *     match_links    = root.findall(".//{…xhtml}link[@rel='match']")
628     *     mismatch_links = root.findall(".//{…xhtml}link[@rel='mismatch']")
629     *     reftest_nodes  = match_links + mismatch_links
630     *     rel_map = {"match": "==", "mismatch": "!="}
631     *
632     * Four things follow, and this method honours all four:
633     *
634     *  1. The lookup is by **XHTML namespace**, not by literal tag
635     *     spelling. SVG and XML fixtures declare their reference as
636     *     `<html:link rel="match" href="…"/>` with `xmlns:html` bound
637     *     to the XHTML namespace — still an XHTML `link`, still a
638     *     reference. A matcher keyed on the literal string `<link`
639     *     misses every one of them (266 fixtures in the current
640     *     corpus, 112 of them under `css/css-masking/`), which drops
641     *     them out of scope entirely instead of scoring them.
642     *  2. The declared link is what makes a file a reftest, so when a
643     *     test declares one it **outranks** the legacy `<stem>-ref.*`
644     *     filename sibling. 35 fixtures in the corpus carry both and
645     *     disagree (e.g. `block-in-inline-remove-006.xht` declares
646     *     `…-nosplit-ref.xht` while a `…-ref.xht` sibling also
647     *     exists); WPT scores the declared one. `rel=mismatch` counts
648     *     for this too — it is every bit as much a reftest link as
649     *     `rel=match`, so a fixture declaring only mismatch
650     *     references is scored against those, not against a `-ref`
651     *     sibling it never pointed at (28 corpus fixtures).
652     *  3. `rel="mismatch"` is the negative relation: the test must
653     *     render *differently* from that reference. It is carried
654     *     here as {@see ReferenceRelation::Mismatch} rather than
655     *     dropped, which is what used to leave 375 real reftests
656     *     (286 css, 56 html, 4 svg, 29 mathml) unscored.
657     *  4. Every match link precedes every mismatch link regardless of
658     *     document order, because WPT concatenates the two `findall`
659     *     results in that order. Within a relation, document order is
660     *     the order the references get tried.
661     *
662     * The `<stem>-ref.*` sibling remains the fallback for a fixture
663     * that declares no reftest link at all: it is a CSS-WG-era
664     * convention that predates `rel=match`, and 53 corpus fixtures
665     * still rely on it alone. PNG wins among siblings since it
666     * short-circuits a re-render. There is deliberately no
667     * `<stem>-notref.*` counterpart. WPT classifies those files as
668     * references (`sourcefile.py::name_is_reference`, which is why
669     * they are skipped as tests) but never links a test to one by
670     * filename, so inventing that edge could only manufacture passes
671     * — and it would buy almost nothing anyway: 5 in-scope fixtures
672     * corpus-wide have a `-notref` sibling and no `rel=mismatch`.
673     *
674     * @return list<ReftestReference>
675     *
676     * @internal Exposed for {@see \Phpdftk\WptHarness\Tests\ReferenceResolutionTest}.
677     */
678    public function references(string $testPath): array
679    {
680        $declared = $this->declaredReftestReferences($testPath);
681        if ($declared !== []) {
682            return $declared;
683        }
684        $info = pathinfo($testPath);
685        $dir = $info['dirname'] ?? '.';
686        $stem = $info['filename'] ?? '';
687        $candidates = [
688            $dir . '/' . $stem . '-ref.png',
689            $dir . '/' . $stem . '-ref.html',
690            $dir . '/' . $stem . '-ref.xht',
691            $dir . '/' . $stem . '-ref.svg',
692        ];
693        foreach ($candidates as $cand) {
694            $real = realpath($cand);
695            if ($real !== false && is_file($real)) {
696                return [new ReftestReference($real, ReferenceRelation::Match)];
697            }
698        }
699
700        return [];
701    }
702
703    /**
704     * The first `rel=match` reference, or null when the fixture
705     * declares none and has no `-ref` sibling.
706     *
707     * Kept for callers that want "the document this test is supposed
708     * to look like" and have no use for the negative relation — the
709     * gallery builder renders it as the reference panel. Scoring goes
710     * through {@see self::references()}, which is the whole list.
711     *
712     * @internal Exposed for {@see \Phpdftk\WptHarness\Tests\ReferenceResolutionTest}.
713     */
714    public function locateReference(string $testPath): ?string
715    {
716        foreach ($this->references($testPath) as $reference) {
717            if ($reference->relation === ReferenceRelation::Match) {
718                return $reference->path;
719            }
720        }
721
722        return null;
723    }
724
725    /**
726     * Resolve every `<link rel="match">` / `<link rel="mismatch">` in
727     * a test file to an on-disk path, matches first.
728     *
729     * Tags are scanned in document order within each relation. A
730     * declaration whose href doesn't resolve to a file is dropped
731     * rather than allowed to poison the lookup — WPT has a served URL
732     * for every reference, we only have the checkout.
733     *
734     * Accepts any namespace prefix on the element name (`html:link`,
735     * `x:link`, …) and unquoted attribute values (`rel=match`), both
736     * of which occur in the corpus.
737     *
738     * Read is bounded to {@see self::MARKUP_SCAN_BYTES} so a
739     * malformed test can't stall the harness. Verified against the
740     * full corpus: zero fixtures declare their reference beyond that
741     * offset.
742     *
743     * @return list<ReftestReference>
744     */
745    private function declaredReftestReferences(string $testPath): array
746    {
747        $head = @file_get_contents($testPath, false, null, 0, self::MARKUP_SCAN_BYTES);
748        if ($head === false || $head === '') {
749            return [];
750        }
751        $tags = self::elementsNamed($head, 'link');
752        $references = [];
753        foreach ([ReferenceRelation::Match, ReferenceRelation::Mismatch] as $relation) {
754            $rel = $relation === ReferenceRelation::Match ? 'match' : 'mismatch';
755            foreach ($tags as $tag) {
756                if (self::attributeValue($tag, 'rel') !== $rel) {
757                    continue;
758                }
759                $href = self::attributeValue($tag, 'href');
760                if ($href === null) {
761                    continue;
762                }
763                $resolved = $this->resolveHref($testPath, $href);
764                if ($resolved !== null) {
765                    $references[] = new ReftestReference($resolved, $relation);
766                }
767            }
768        }
769
770        return $references;
771    }
772
773    /**
774     * Resolve a reftest href to an absolute on-disk path. Root-relative
775     * hrefs resolve against the corpus root (wptserve's document root);
776     * everything else against the referring file's directory.
777     *
778     * Fragments and query strings are stripped: they are meaningful to
779     * the browser but never part of the filename on disk.
780     */
781    private function resolveHref(string $fromPath, string $href): ?string
782    {
783        // WPT strips ASCII whitespace from the href before joining
784        // (`item.attrib["href"].strip(space_chars)`).
785        $href = trim($href, " \t\n\r\f\x0B");
786        $href = (string) preg_replace('~[#?].*$~s', '', $href);
787        if ($href === '') {
788            return null;
789        }
790        $resolved = str_starts_with($href, '/')
791            ? rtrim($this->wptRoot, '/') . $href
792            : dirname($fromPath) . '/' . $href;
793        $real = realpath($resolved);
794        return ($real !== false && is_file($real)) ? $real : null;
795    }
796
797    /**
798     * Yield every start tag named `$name`, allowing an optional XML
799     * namespace prefix (`html:link`). `[^>]` bounds each match to a
800     * single tag so attributes can never be read across a tag
801     * boundary.
802     *
803     * @return list<string>
804     */
805    private static function elementsNamed(string $markup, string $name): array
806    {
807        $pattern = '~<(?:[A-Za-z_][\w.\-]*:)?' . preg_quote($name, '~') . '\b[^>]*>~i';
808        if (preg_match_all($pattern, $markup, $matches) < 1) {
809            return [];
810        }
811        /** @var list<string> $tags */
812        $tags = $matches[0];
813        return $tags;
814    }
815
816    /**
817     * Read one attribute out of a start tag. Handles double-quoted,
818     * single-quoted and unquoted values. The lookbehind stops
819     * `data-href` from being read as `href`.
820     */
821    private static function attributeValue(string $tag, string $name): ?string
822    {
823        $pattern = '~(?<![\w:.\-])' . preg_quote($name, '~')
824            . '\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s"\'=<>`]+))~i';
825        if (preg_match($pattern, $tag, $m, PREG_UNMATCHED_AS_NULL) !== 1) {
826            return null;
827        }
828        return $m[1] ?? $m[2] ?? $m[3] ?? null;
829    }
830
831    /**
832     * Render a single test file through the phpdftk pipeline and
833     * rasterise the first page of the resulting PDF.
834     */
835    private function renderToPng(string $path): string
836    {
837        $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
838        $pdfBytes = $ext === 'svg'
839            ? self::renderSvgToPdf($path)
840            : $this->renderHtmlToPdf($path);
841        $pdfPath = tempnam(sys_get_temp_dir(), 'wpt_pdf_') . '.pdf';
842        file_put_contents($pdfPath, $pdfBytes);
843        try {
844            return $this->rasteriser->rasterise($pdfPath, 0);
845        } finally {
846            @unlink($pdfPath);
847        }
848    }
849
850    private function renderHtmlToPdf(string $path): string
851    {
852        if (!class_exists('Phpdftk\\HtmlToPdf\\Renderer')) {
853            throw new \RuntimeException('phpdftk/html-to-pdf not installed');
854        }
855        $html = file_get_contents($path);
856        if ($html === false) {
857            throw new \RuntimeException("could not read test file: $path");
858        }
859        // Settle reftest-wait fixtures through Playwright so the
860        // PHP renderer sees the post-JS DOM (inline-style shifts
861        // from getBoundingClientRect, fonts marked loaded via
862        // FontFace API, etc.). When no settler is configured, the
863        // settler isn't available on this host, or settling fails,
864        // fall back to the pre-JS source - the harness still
865        // produces a result, just one that reflects an unrun test.
866        if ($this->domSettler !== null) {
867            $settled = $this->domSettler->maybeSettle($path, $html);
868            if ($settled !== null) {
869                $html = $settled;
870            }
871        }
872        // Sandbox to the WPT corpus root so refs in `reference/`
873        // subdirs can resolve `../support/img.png` siblings of the
874        // test directory. baseDir alone is too tight — the default
875        // ResourceLoader sandbox is the same as baseDir, which
876        // rejects any `..` walk.
877        $options = (new \Phpdftk\HtmlToPdf\RendererOptions())
878            ->withBaseDir(dirname($path))
879            ->withSandboxRoot($this->wptRoot)
880            // WPT test corpus is browser-targeted, so the vast
881            // majority of `@media` rules gate on `screen` rather
882            // than `print`. Match both so author CSS applies the
883            // way the test fixtures (and their references) expect.
884            ->withMatchingMediaTypes(['print', 'screen']);
885        $defaultFont = $this->harnessDefaultFont();
886        if ($defaultFont !== null) {
887            $options = $options->withDefaultFont($defaultFont);
888        }
889        $result = (new \Phpdftk\HtmlToPdf\Renderer($options))->render($html);
890        return $result->writer->toBytes();
891    }
892
893    public static function renderSvgToPdf(string $path): string
894    {
895        if (!class_exists('Phpdftk\\SvgToPdf\\SvgRenderer')
896            || !class_exists('Phpdftk\\Svg\\Parser')
897            || !class_exists('Phpdftk\\Pdf\\Writer\\PdfWriter')
898        ) {
899            throw new \RuntimeException('svg-to-pdf renderer stack not installed');
900        }
901        $svgSource = file_get_contents($path);
902        if ($svgSource === false) {
903            throw new \RuntimeException("could not read test file: $path");
904        }
905        $writer = new \Phpdftk\Pdf\Writer\PdfWriter();
906        $pageWidth = 612.0;
907        $pageHeight = 792.0;
908        $page = $writer->addPage($pageWidth, $pageHeight);
909        $svgDoc = (new \Phpdftk\Svg\Parser())->parse($svgSource);
910        $renderer = new \Phpdftk\SvgToPdf\SvgRenderer($page, $writer);
911        // SVG 2 §8.2 — `width` / `height` on the outermost `<svg>` are
912        // `auto`, which resolves to `100%`. For a STANDALONE document the
913        // viewport is the window, which here is the page: pass it as the
914        // destination so a sizeless root fills the page instead of
915        // collapsing to the renderer's finite-size fallback. Without this
916        // a `<svg>` with no width/height/viewBox renders blank, and its
917        // reftest passes only because the test side is equally blank.
918        // Only a root that supplies NO intrinsic size takes the page as
919        // its viewport; one declaring `width="200" height="200"` (or a
920        // `viewBox`, which gives a ratio) keeps its natural size, exactly
921        // as a browser shows a standalone SVG.
922        [$viewportWidth, $viewportHeight] = self::svgRootViewport($svgDoc, $pageWidth, $pageHeight);
923        // `draw()` anchors the destination rect by its BOTTOM-left in
924        // PDF user space, while a standalone document's viewport
925        // starts at the TOP-left of the canvas. They only coincide
926        // when the viewport is as tall as the page, so a shorter
927        // viewport has to be lifted by the difference or it renders
928        // flush with the bottom margin instead of the top.
929        $renderer->draw(
930            $svgDoc,
931            x: 0,
932            y: $pageHeight - $viewportHeight,
933            width: $viewportWidth,
934            height: $viewportHeight,
935        );
936        return $writer->toBytes();
937    }
938
939    /**
940     * SVG 2 §8.2 — the viewport a STANDALONE outermost `<svg>` paints
941     * into, in CSS px.
942     *
943     * The `width` / `height` attributes are the viewport; a `viewBox`
944     * is a coordinate system mapped INTO that viewport, never the
945     * viewport itself. `<svg viewBox="0 0 3 3" width="200"
946     * height="200">` is a 200×200 box in which one user unit is 66⅔
947     * px — passing no destination would instead render the whole
948     * document 3pt wide.
949     *
950     * When only one axis is fixed, a `viewBox` supplies the ratio for
951     * the other. When neither axis is fixed, `width` / `height` are
952     * `auto` → `100%` of the window, which for a standalone render is
953     * the page. A `viewBox`-only root keeps its viewBox extent as its
954     * natural size.
955     *
956     * @return array{0: float, 1: float}
957     */
958    public static function svgRootViewport(
959        \Phpdftk\Svg\SvgDocument $svg,
960        float $pageWidth,
961        float $pageHeight,
962    ): array {
963        $viewBox = $svg->viewBox();
964        $w = self::parseSvgRootLength($svg->widthAttribute());
965        $h = self::parseSvgRootLength($svg->heightAttribute());
966        $ratio = $viewBox !== null && $viewBox[2] > 0.0 && $viewBox[3] > 0.0
967            ? $viewBox[2] / $viewBox[3]
968            : null;
969        if ($w !== null && $h === null && $ratio !== null) {
970            $h = $w / $ratio;
971        } elseif ($h !== null && $w === null && $ratio !== null) {
972            $w = $h * $ratio;
973        }
974        if ($w !== null && $h !== null) {
975            // Mirror `SvgRenderer::resolveSourceRect`'s near-integral
976            // snap (crbug.com/1392140) when there is no viewBox, so the
977            // destination it derives from the same attributes and the
978            // one passed here stay identical and the scale stays 1.
979            return $viewBox === null
980                ? [(float) round($w), (float) round($h)]
981                : [$w, $h];
982        }
983        if ($viewBox !== null) {
984            return [$viewBox[2], $viewBox[3]];
985        }
986        return [$pageWidth, $pageHeight];
987    }
988
989    /**
990     * A root `width` / `height` attribute as a fixed CSS-px length, or
991     * null when it is absent, a percentage, or otherwise not a
992     * parseable absolute length (all of which are `auto`-like and need
993     * a viewport to resolve).
994     */
995    private static function parseSvgRootLength(?string $raw): ?float
996    {
997        if ($raw === null) {
998            return null;
999        }
1000        $trimmed = trim($raw);
1001        if ($trimmed === '' || str_contains($trimmed, '%')) {
1002            return null;
1003        }
1004        if (preg_match(
1005            '/^([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)\s*(px|pt|pc|cm|mm|q|in)?$/i',
1006            $trimmed,
1007            $m,
1008        ) !== 1) {
1009            return null;
1010        }
1011        $value = (float) $m[1] * match (strtolower($m[2] ?? '')) {
1012            'cm' => 96.0 / 2.54,
1013            'mm' => 96.0 / 25.4,
1014            'q' => 96.0 / 101.6,
1015            'in' => 96.0,
1016            'pt' => 96.0 / 72.0,
1017            'pc' => 16.0,
1018            default => 1.0,
1019        };
1020        return $value > 0.0 ? $value : null;
1021    }
1022
1023    public function manifest(): Manifest
1024    {
1025        return $this->manifest;
1026    }
1027
1028    public function rasteriser(): Rasteriser
1029    {
1030        return $this->rasteriser;
1031    }
1032
1033    public function scorer(): Scorer
1034    {
1035        return $this->scorer;
1036    }
1037
1038    public function wptRoot(): string
1039    {
1040        return $this->wptRoot;
1041    }
1042
1043    /**
1044     * Recursively yield absolute paths to test files under the
1045     * given root. References (`*-ref.*`) are skipped — they're
1046     * targets for the diff scorer, not tests.
1047     *
1048     * @return iterable<string>
1049     */
1050    private function discoverTests(string $root): iterable
1051    {
1052        $iterator = new \RecursiveIteratorIterator(
1053            new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS),
1054        );
1055        foreach ($iterator as $info) {
1056            assert($info instanceof \SplFileInfo);
1057            if (!$info->isFile()) {
1058                continue;
1059            }
1060            $extension = strtolower($info->getExtension());
1061            if (!in_array($extension, self::TEST_EXTENSIONS, true)) {
1062                continue;
1063            }
1064            $stem = $info->getBasename('.' . $info->getExtension());
1065            // `*-ref.html`, `*-ref.xht`, `*-notref.html` are
1066            // expected-rendering / negative-match siblings, not
1067            // tests themselves.
1068            if (str_ends_with($stem, '-ref') || str_ends_with($stem, '-notref')) {
1069                continue;
1070            }
1071            yield $info->getPathname();
1072        }
1073    }
1074
1075    /**
1076     * Convert an absolute test-file path into a stable test ID —
1077     * the relative path under `$wptRoot`, minus the file
1078     * extension. POSIX-style separators so manifest globs match
1079     * cross-platform.
1080     */
1081    private function testIdFromPath(string $absolutePath): ?string
1082    {
1083        $rootAbs = realpath($this->wptRoot);
1084        $pathAbs = realpath($absolutePath);
1085        if ($rootAbs === false || $pathAbs === false) {
1086            return null;
1087        }
1088        if (!str_starts_with($pathAbs, $rootAbs)) {
1089            return null;
1090        }
1091        $relative = substr($pathAbs, strlen($rootAbs));
1092        $relative = ltrim($relative, '/\\');
1093        // Normalise to POSIX separators.
1094        $relative = str_replace('\\', '/', $relative);
1095        // Strip the extension.
1096        $dotPos = strrpos($relative, '.');
1097        if ($dotPos !== false) {
1098            $relative = substr($relative, 0, $dotPos);
1099        }
1100        return $relative;
1101    }
1102}