Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 53
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 / 53
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 / 33
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     *   oursAll: array<string, float>,
87     *   renderMicros: float,
88     * }
89     */
90    public function runOne(
91        string $testId,
92        string $testPath,
93        ?float $fuzzBudget = null,
94    ): array {
95        $budget = $fuzzBudget ?? $this->defaultFuzzBudget;
96        $start = hrtime(true);
97
98        // Render ours via the in-process PHP renderer, then rasterise.
99        $oursPdf = $this->renderOurs($testPath);
100        $ourPng = $this->rasteriser->rasterise($oursPdf);
101        @unlink($oursPdf);
102
103        $enginePngs = [];
104        $engineMissing = [];
105        foreach ($this->resolvedEngines as $engine) {
106            try {
107                $pdf = $this->oracle->render($engine, $testPath);
108            } catch (\Throwable $err) {
109                $engineMissing[] = $engine;
110                continue;
111            }
112            if ($pdf === null) {
113                $engineMissing[] = $engine;
114                continue;
115            }
116            try {
117                $enginePngs[$engine] = $this->rasteriser->rasterise($pdf);
118            } catch (\Throwable $err) {
119                $engineMissing[] = $engine;
120            }
121        }
122
123        $score = $this->scorer->score($ourPng, $enginePngs, $budget);
124        $renderMicros = (hrtime(true) - $start) / 1000.0;
125
126        return [
127            'testId' => $testId,
128            'verdict' => $score['verdict'],
129            'reason' => $score['reason'],
130            'consensus' => $score['consensus'],
131            'ourPng' => $ourPng,
132            'enginePngs' => $enginePngs,
133            'engineMissing' => $engineMissing,
134            'pairs' => $score['pairs'],
135            'ours' => $score['ours'],
136            'oursAll' => $score['oursAll'],
137            'renderMicros' => $renderMicros,
138        ];
139    }
140
141    /**
142     * Render a fixture through our PHP renderer to a temp PDF; returns
143     * the path. Caller unlinks. We don't go through `runOne()`'s
144     * cache because our renderer is in-process and cheap to re-run;
145     * caching the engine outputs is where the win is.
146     */
147    private function renderOurs(string $testPath): string
148    {
149        $html = file_get_contents($testPath);
150        if ($html === false) {
151            throw new \RuntimeException("could not read fixture: $testPath");
152        }
153        $opts = (new RendererOptions())->withBaseDir(dirname($testPath));
154        $renderer = new Renderer($opts);
155        $writer = new PdfWriter();
156        $renderer->renderInto($writer, $html);
157        $tmpPath = tempnam(sys_get_temp_dir(), 'xb_ours_') . '.pdf';
158        file_put_contents($tmpPath, $writer->toBytes());
159        return $tmpPath;
160    }
161
162    /**
163     * Clean up rasterised PNGs from a result. Call after consuming the
164     * artefacts; the engine PDFs in the oracle cache are NOT removed.
165     */
166    public function cleanupResult(array $result): void
167    {
168        foreach (array_merge([$result['ourPng']], array_values($result['enginePngs'])) as $path) {
169            if (is_string($path) && is_file($path)) {
170                @unlink($path);
171            }
172        }
173    }
174}