Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 52
0.00% covered (danger)
0.00%
0 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
CrossBrowserRunner
0.00% covered (danger)
0.00%
0 / 52
0.00% covered (danger)
0.00%
0 / 4
240
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 runOne
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
30
 renderOurs
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 cleanupResult
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\WptHarness;
6
7use Phpdftk\HtmlToPdf\Renderer;
8use Phpdftk\HtmlToPdf\RendererOptions;
9use Phpdftk\Pdf\Writer\PdfWriter;
10
11/**
12 * Orchestrator for the cross-browser PDF oracle. For each test fixture:
13 *
14 *   1. Render the test through ours.
15 *   2. Render the test through each available browser engine (Chromium,
16 *      Firefox, WebKit), backed by {@see BrowserOracle}'s file cache.
17 *   3. Rasterise every PDF to PNG via {@see Rasteriser}.
18 *   4. Hand the PNGs to {@see ConsensusScorer} for a verdict.
19 *
20 * The result includes the consensus verdict, per-engine PNG paths
21 * (for diagnostics), reasoning string, and timing data. CLI callers
22 * format it for humans; CI callers serialise it for downstream
23 * comparison.
24 */
25final class CrossBrowserRunner
26{
27    private const ALL_ENGINES = ['chromium', 'firefox', 'webkit'];
28
29    /** @var list<string> */
30    private readonly array $resolvedEngines;
31
32    public function __construct(
33        private readonly BrowserOracle $oracle = new BrowserOracle(),
34        private readonly Rasteriser $rasteriser = new Rasteriser(),
35        private readonly ConsensusScorer $scorer = new ConsensusScorer(),
36        /**
37         * Engines to attempt for each test. Order doesn't matter; an
38         * unavailable engine is skipped silently and noted in the
39         * result. Defaults to all three Phase-A engines; overridable
40         * via the `PHPDFTK_CROSS_BROWSER_ENGINES` env var (comma-
41         * separated). Useful for skipping Firefox-via-Docker on
42         * macOS hosts where Rosetta blocks Firefox's software
43         * renderer (Phase A2 finding) — set
44         * `PHPDFTK_CROSS_BROWSER_ENGINES=chromium,webkit` and the
45         * harness skips Firefox entirely instead of paying the 60-s
46         * Docker timeout per fixture.
47         *
48         * @var list<string>
49         */
50        ?array $engines = null,
51        /**
52         * Pass {@see ConsensusScorer::OURS_FUZZ_GEOMETRY} for
53         * pure-shape fixtures, {@see ConsensusScorer::OURS_FUZZ_TEXT}
54         * for text-bearing fixtures, or supply per-test overrides via
55         * the runner's per-call argument.
56         */
57        private readonly float $defaultFuzzBudget = ConsensusScorer::OURS_FUZZ_GEOMETRY,
58    ) {
59        $envOverride = getenv('PHPDFTK_CROSS_BROWSER_ENGINES');
60        if ($engines === null && is_string($envOverride) && $envOverride !== '') {
61            $engines = array_values(array_filter(
62                array_map('trim', explode(',', $envOverride)),
63                static fn(string $name) => in_array($name, self::ALL_ENGINES, true),
64            ));
65        }
66        $this->resolvedEngines = $engines ?? self::ALL_ENGINES;
67    }
68
69    /**
70     * Run the oracle on a single test fixture. Returns an associative
71     * result the CLI / CI layer can format.
72     *
73     * `$fuzzBudget` overrides {@see self::$defaultFuzzBudget} for this
74     * test only — pass null to use the default.
75     *
76     * @return array{
77     *   testId: string,
78     *   verdict: ConsensusVerdict,
79     *   reason: string,
80     *   consensus: list<string>,
81     *   ourPng: string,
82     *   enginePngs: array<string, string>,
83     *   engineMissing: list<string>,
84     *   pairs: array<string, array<string, float>>,
85     *   ours: array<string, float>,
86     *   renderMicros: float,
87     * }
88     */
89    public function runOne(
90        string $testId,
91        string $testPath,
92        ?float $fuzzBudget = null,
93    ): array {
94        $budget = $fuzzBudget ?? $this->defaultFuzzBudget;
95        $start = hrtime(true);
96
97        // Render ours via the in-process PHP renderer, then rasterise.
98        $oursPdf = $this->renderOurs($testPath);
99        $ourPng = $this->rasteriser->rasterise($oursPdf);
100        @unlink($oursPdf);
101
102        $enginePngs = [];
103        $engineMissing = [];
104        foreach ($this->resolvedEngines as $engine) {
105            try {
106                $pdf = $this->oracle->render($engine, $testPath);
107            } catch (\Throwable $err) {
108                $engineMissing[] = $engine;
109                continue;
110            }
111            if ($pdf === null) {
112                $engineMissing[] = $engine;
113                continue;
114            }
115            try {
116                $enginePngs[$engine] = $this->rasteriser->rasterise($pdf);
117            } catch (\Throwable $err) {
118                $engineMissing[] = $engine;
119            }
120        }
121
122        $score = $this->scorer->score($ourPng, $enginePngs, $budget);
123        $renderMicros = (hrtime(true) - $start) / 1000.0;
124
125        return [
126            'testId' => $testId,
127            'verdict' => $score['verdict'],
128            'reason' => $score['reason'],
129            'consensus' => $score['consensus'],
130            'ourPng' => $ourPng,
131            'enginePngs' => $enginePngs,
132            'engineMissing' => $engineMissing,
133            'pairs' => $score['pairs'],
134            'ours' => $score['ours'],
135            'renderMicros' => $renderMicros,
136        ];
137    }
138
139    /**
140     * Render a fixture through our PHP renderer to a temp PDF; returns
141     * the path. Caller unlinks. We don't go through `runOne()`'s
142     * cache because our renderer is in-process and cheap to re-run;
143     * caching the engine outputs is where the win is.
144     */
145    private function renderOurs(string $testPath): string
146    {
147        $html = file_get_contents($testPath);
148        if ($html === false) {
149            throw new \RuntimeException("could not read fixture: $testPath");
150        }
151        $opts = (new RendererOptions())->withBaseDir(dirname($testPath));
152        $renderer = new Renderer($opts);
153        $writer = new PdfWriter();
154        $renderer->renderInto($writer, $html);
155        $tmpPath = tempnam(sys_get_temp_dir(), 'xb_ours_') . '.pdf';
156        file_put_contents($tmpPath, $writer->toBytes());
157        return $tmpPath;
158    }
159
160    /**
161     * Clean up rasterised PNGs from a result. Call after consuming the
162     * artefacts; the engine PDFs in the oracle cache are NOT removed.
163     */
164    public function cleanupResult(array $result): void
165    {
166        foreach (array_merge([$result['ourPng']], array_values($result['enginePngs'])) as $path) {
167            if (is_string($path) && is_file($path)) {
168                @unlink($path);
169            }
170        }
171    }
172}