Skip to content

Commit a34f636

Browse files
committed
Implement a basic framework for output tests
Reference files can be updated via the helper script `bin/update-reference-output.php`. When running the tests, the output of all failed test cases is saved to the local `tmp` directory, so the differences to the reference rendering can be easily checked manually.
1 parent d5e9f60 commit a34f636

File tree

6 files changed

+315
-1
lines changed

6 files changed

+315
-1
lines changed

Diff for: .github/workflows/test.yml

+5
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ jobs:
3939
with:
4040
dependency-versions: ${{ matrix.package-release }}
4141

42+
- name: Install Ghostscript
43+
run: |
44+
sudo apt-get update
45+
sudo apt-get install ghostscript
46+
4247
- name: Run unit tests
4348
run: vendor/bin/phpunit
4449

Diff for: .gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ composer.lock
44
phpunit.xml
55
vendor
66
build
7+
tmp
78
.idea
89
.project
910
.phpunit.result.cache

Diff for: bin/update-reference-output.php

+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
use Dompdf\Tests\OutputTest\Dataset;
3+
use Dompdf\Tests\OutputTest\OutputTest;
4+
5+
/**
6+
* Usage:
7+
* * `php bin/update-reference-output.php` to update the reference files for all
8+
* test cases
9+
* * `php bin/update-reference-output.php <name-prefix>` to update the reference
10+
* files for all test cases with a path starting with the specified prefix
11+
* (paths considered relative to the parent `OutputTest` directory)
12+
*/
13+
require __DIR__ . "/../vendor/autoload.php";
14+
15+
$pathTest = $argv[1] ?? "";
16+
$datasets = OutputTest::datasets();
17+
$include = $pathTest !== ""
18+
? function (Dataset $set) use ($pathTest) {
19+
return substr($set->name, 0, strlen($pathTest)) === $pathTest;
20+
} : function () {
21+
return true;
22+
};
23+
24+
foreach ($datasets as $dataset) {
25+
if (!$include($dataset)) {
26+
continue;
27+
}
28+
29+
echo "Updating " . $dataset->name . PHP_EOL;
30+
$dataset->updateReferenceFile();
31+
}

Diff for: composer.json

+3-1
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,13 @@
3232
"phenx/php-svg-lib": ">=0.3.3 <1.0.0"
3333
},
3434
"require-dev": {
35+
"ext-gd": "*",
3536
"ext-json": "*",
3637
"ext-zip": "*",
3738
"phpunit/phpunit": "^7.5 || ^8 || ^9",
3839
"squizlabs/php_codesniffer": "^3.5",
39-
"mockery/mockery": "^1.3"
40+
"mockery/mockery": "^1.3",
41+
"symfony/process": "^4.4 || ^5.4 || ^6.2"
4042
},
4143
"suggest": {
4244
"ext-gd": "Needed to process images",

Diff for: tests/OutputTest/Dataset.php

+56
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<?php
2+
namespace Dompdf\Tests\OutputTest;
3+
4+
use Dompdf\Options;
5+
use Dompdf\Dompdf;
6+
use SplFileInfo;
7+
8+
final class Dataset
9+
{
10+
/**
11+
* @var string
12+
*/
13+
public $name;
14+
15+
/**
16+
* @var SplFileInfo
17+
*/
18+
public $file;
19+
20+
/**
21+
* @param string $name The name of the data set.
22+
* @param SplFileInfo $file The HTML source file.
23+
*/
24+
public function __construct(string $name, SplFileInfo $file)
25+
{
26+
$this->name = $name;
27+
$this->file = $file;
28+
}
29+
30+
public function referenceFile(): SplFileInfo
31+
{
32+
$path = $this->file->getPath();
33+
$name = $this->file->getBasename("." . $this->file->getExtension());
34+
return new SplFileInfo("$path/$name.pdf");
35+
}
36+
37+
public function render(string $backend = "cpdf"): Dompdf
38+
{
39+
$options = new Options();
40+
$options->setPdfBackend($backend);
41+
42+
$pdf = new Dompdf($options);
43+
$pdf->loadHtmlFile($this->file->getPathname());
44+
$pdf->setBasePath($this->file->getPath());
45+
$pdf->render();
46+
47+
return $pdf;
48+
}
49+
50+
public function updateReferenceFile(): void
51+
{
52+
$pdf = $this->render();
53+
$file = $this->referenceFile();
54+
file_put_contents($file->getPathname(), $pdf->output());
55+
}
56+
}

Diff for: tests/OutputTest/OutputTest.php

+219
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
<?php
2+
namespace Dompdf\Tests\OutputTest;
3+
4+
use CallbackFilterIterator;
5+
use Dompdf\Tests\TestCase;
6+
use FilesystemIterator;
7+
use Iterator;
8+
use PHPUnit\Framework\AssertionFailedError;
9+
use RecursiveDirectoryIterator;
10+
use RecursiveIteratorIterator;
11+
use RuntimeException;
12+
use SplFileInfo;
13+
use Symfony\Component\Process\Process;
14+
15+
final class OutputTest extends TestCase
16+
{
17+
private const DATASET_DIRECTORY = __DIR__ . "/../_files/OutputTest";
18+
private const FAILED_OUTPUT_DIRECTORY = __DIR__ . "/../../tmp/failed-output-tests";
19+
20+
private static function datasetName(SplFileInfo $file): string
21+
{
22+
$prefixLength = strlen(self::DATASET_DIRECTORY);
23+
$path = substr($file->getPath(), $prefixLength + 1);
24+
$name = $file->getBasename("." . $file->getExtension());
25+
return "$path/$name";
26+
}
27+
28+
/**
29+
* @return Iterator<Dataset>
30+
*/
31+
public static function datasets(): Iterator
32+
{
33+
$flags = FilesystemIterator::KEY_AS_FILENAME
34+
| FilesystemIterator::CURRENT_AS_FILEINFO
35+
| FilesystemIterator::SKIP_DOTS;
36+
$filter = function (SplFileInfo $file) {
37+
return $file->getExtension() === "html";
38+
};
39+
$dir = new RecursiveDirectoryIterator(self::DATASET_DIRECTORY, $flags);
40+
$files = new CallbackFilterIterator(new RecursiveIteratorIterator($dir), $filter);
41+
42+
foreach ($files as $file) {
43+
$name = self::datasetName($file);
44+
yield new Dataset($name, $file);
45+
}
46+
}
47+
48+
public static function outputTestProvider(): Iterator
49+
{
50+
foreach (self::datasets() as $dataset) {
51+
yield $dataset->name => [$dataset];
52+
}
53+
}
54+
55+
protected function setUp(): void
56+
{
57+
$process = new Process(["gs", "-v"]);
58+
$exitCode = $process->run();
59+
60+
if ($exitCode === 127) {
61+
$this->markTestSkipped(
62+
"Output tests need Ghostscript to be available. If you are " .
63+
"on a Debian-based system, you can use `sudo apt install ghostscript`"
64+
);
65+
}
66+
}
67+
68+
/**
69+
* @dataProvider outputTestProvider
70+
*/
71+
public function testOutputMatchesReferenceRendering(Dataset $dataset): void
72+
{
73+
$document = $dataset->render();
74+
$referenceFile = $dataset->referenceFile()->getPathname();
75+
$actualOutputFile = tempnam(sys_get_temp_dir(), "dompdf_test_");
76+
77+
file_put_contents($actualOutputFile, $document->output());
78+
79+
try {
80+
$this->assertOutputMatches($referenceFile, $actualOutputFile);
81+
} catch (AssertionFailedError $e) {
82+
$path = $this->saveFailedOutput($dataset);
83+
throw new AssertionFailedError(
84+
$e->getMessage() . "\nOutput written to $path for review."
85+
);
86+
} finally {
87+
unlink($actualOutputFile);
88+
}
89+
}
90+
91+
private function assertOutputMatches(
92+
string $referenceFile,
93+
string $actualOutputFile
94+
): void {
95+
$command = function ($file) {
96+
return [
97+
"gs",
98+
"-q", "-dBATCH", "-dNOPAUSE", "-sstdout=%stderr",
99+
"-sDEVICE=png16m", "-dGraphicsAlphaBits=4",
100+
"-sOutputFile=-", $file
101+
];
102+
};
103+
$process1 = new Process($command($referenceFile));
104+
$process2 = new Process($command($actualOutputFile));
105+
106+
foreach ([$process1, $process2] as $process) {
107+
$process->mustRun();
108+
$error = $process->getErrorOutput();
109+
110+
// The `-sstdout=%stderr` setting moves all non-device output to
111+
// STDERR. Since we only expect image data, consider any other
112+
// output a failure
113+
if ($error !== "") {
114+
throw new RuntimeException("Unexpected Ghostscript output: `$error`");
115+
}
116+
}
117+
118+
$referenceImages = $this->outputToImageData($process1->getOutput());
119+
$actualImages = $this->outputToImageData($process2->getOutput());
120+
121+
$expectedCount = count($referenceImages);
122+
$actualCount = count($actualImages);
123+
$failureMessage = "Output does not match reference rendering. Expected $expectedCount pages, got $actualCount.";
124+
$this->assertCount($expectedCount, $actualImages, $failureMessage);
125+
126+
foreach ($referenceImages as $i => $referenceData) {
127+
$actualData = $actualImages[$i];
128+
129+
$matches = $this->compareImages($referenceData, $actualData);
130+
$page = $i + 1;
131+
$failureMessage = "Output does not match reference rendering. Difference on page $page.";
132+
$this->assertTrue($matches, $failureMessage);
133+
}
134+
}
135+
136+
/**
137+
* Parse the Ghostscript command output, consisting of the concatenated PNG
138+
* image data, one image for each page.
139+
*
140+
* @param string $output The Ghostscript command output.
141+
*
142+
* @return string[] A list of the PNG images contained in the output.
143+
*/
144+
private function outputToImageData(string $output): array
145+
{
146+
$pngSignature = "\x89\x50\x4E\x47\x0D\x0A\x1A\x0A";
147+
$elements = explode($pngSignature, $output);
148+
149+
if (count($elements) <= 1 || $elements[0] !== "") {
150+
throw new RuntimeException("Unexpected Ghostscript output: `$output`");
151+
}
152+
153+
return array_map(function ($data) use ($pngSignature) {
154+
return $pngSignature . $data;
155+
}, array_slice($elements, 1));
156+
}
157+
158+
private function compareImages(string $referenceData, string $imageData): bool
159+
{
160+
if (extension_loaded('imagick')) {
161+
$image1 = new \Imagick();
162+
$image1->readImageBlob($referenceData);
163+
$image2 = new \Imagick();
164+
$image2->readImageBlob($imageData);
165+
$width1 = $image1->getImageWidth();
166+
$height1 = $image1->getImageHeight();
167+
$width2 = $image2->getImageWidth();
168+
$height2 = $image2->getImageHeight();
169+
170+
if ($width1 !== $width2 || $height1 !== $height2) {
171+
return false;
172+
}
173+
174+
[, $error] = $image1->compareImages($image2, \Imagick::METRIC_MEANSQUAREERROR);
175+
return $error === 0.0;
176+
} else {
177+
$image1 = imagecreatefromstring($referenceData);
178+
$image2 = imagecreatefromstring($imageData);
179+
$width = imagesx($image1);
180+
$height = imagesy($image1);
181+
$width2 = imagesx($image2);
182+
$height2 = imagesy($image2);
183+
184+
if ($width !== $width2 || $height !== $height2) {
185+
return false;
186+
}
187+
188+
for ($x = 0; $x < $width; $x++) {
189+
for ($y = 0; $y < $height; $y++) {
190+
$color1 = imagecolorat($image1, $x, $y);
191+
$color2 = imagecolorat($image2, $x, $y);
192+
193+
if ($color1 !== $color2) {
194+
return false;
195+
}
196+
}
197+
}
198+
199+
return true;
200+
}
201+
}
202+
203+
private function saveFailedOutput(Dataset $dataset): string
204+
{
205+
$name = $dataset->name;
206+
$basePath = self::FAILED_OUTPUT_DIRECTORY . "/$name";
207+
$directory = dirname($basePath);
208+
209+
if (!file_exists($directory)) {
210+
mkdir($directory, 0777, true);
211+
}
212+
213+
$pdf = $dataset->render();
214+
$failPath = "$basePath.fail.pdf";
215+
file_put_contents($failPath, $pdf->output());
216+
217+
return realpath($failPath);
218+
}
219+
}

0 commit comments

Comments
 (0)