Skip to content

Commit 5fa21fd

Browse files
authored
Merge pull request #1613 from hydephp/improved-view-testing
Internal: Create new HTML testing framework
2 parents eaf66d2 + 439024f commit 5fa21fd

File tree

9 files changed

+1041
-1
lines changed

9 files changed

+1041
-1
lines changed

packages/framework/tests/Unit/HtmlTestingSupportMetaTest.php

Lines changed: 453 additions & 0 deletions
Large diffs are not rendered by default.

packages/testing/composer.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@
2121
"illuminate/support": "^10.0",
2222
"laravel/dusk": "^7.11.3",
2323
"mockery/mockery": "^1.4.4",
24-
"pestphp/pest": "^v2.1.0"
24+
"pestphp/pest": "^v2.1.0",
25+
"ext-dom": "*",
26+
"ext-libxml": "*"
2527
},
2628
"autoload": {
2729
"psr-4": {
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Hyde\Testing\Support\HtmlTesting;
6+
7+
use Hyde\Hyde;
8+
use JetBrains\PhpStorm\NoReturn;
9+
use Illuminate\Support\Collection;
10+
11+
use function e;
12+
use function dd;
13+
use function trim;
14+
use function sprintf;
15+
use function ucfirst;
16+
use function implode;
17+
use function explode;
18+
use function is_array;
19+
use function in_array;
20+
use function array_map;
21+
use function microtime;
22+
use function is_numeric;
23+
use function array_keys;
24+
use function str_repeat;
25+
use function array_filter;
26+
use function base64_encode;
27+
use function number_format;
28+
use function memory_get_usage;
29+
use function file_put_contents;
30+
31+
/** @internal Single use trait for {@see \Hyde\Testing\Support\HtmlTesting\TestableHtmlDocument} */
32+
trait DumpsDocumentState
33+
{
34+
public function getStructure(): string
35+
{
36+
// Create a structure map of the document, containing only the tag names and their children
37+
38+
$structure = '';
39+
40+
$this->nodes->each(function (TestableHtmlElement $node) use (&$structure): void {
41+
$structure .= $this->createStructureMapEntry($node, 0);
42+
});
43+
44+
return trim($structure);
45+
}
46+
47+
public function getTextRepresentation(): string
48+
{
49+
$text = '';
50+
51+
$this->nodes->each(function (TestableHtmlElement $node) use (&$text): void {
52+
$text .= $this->createTextMapEntry($node);
53+
});
54+
55+
$text = explode("\n", $text);
56+
$text = array_map('trim', $text);
57+
$text = array_filter($text, 'trim');
58+
$text = array_filter($text, fn (string $line): bool => in_array($line, ['(Inline style content)', '(Inline script content)']) === false);
59+
60+
return trim(implode("\n", $text));
61+
}
62+
63+
public function dump(bool $writeHtml = true): string
64+
{
65+
$timeStart = microtime(true);
66+
memory_get_usage(true);
67+
68+
$html = $this->createAstInspectionDump();
69+
70+
$timeEnd = number_format((microtime(true) - $timeStart) * 1000, 2);
71+
$memoryUsage = number_format(memory_get_usage(true) / 1024 / 1024, 2);
72+
73+
$html = str_replace('{{ $footer }}', sprintf("\n<footer><p><small>Generated in %s ms, using %s MB of memory.</small></p></footer>", $timeEnd, $memoryUsage), $html);
74+
75+
if ($writeHtml) {
76+
file_put_contents(Hyde::path('document-dump.html'), $html);
77+
}
78+
79+
return $html;
80+
}
81+
82+
#[NoReturn]
83+
public function dd(bool $writeHtml = true): void
84+
{
85+
$this->dump($writeHtml);
86+
87+
dd($this->nodes);
88+
}
89+
90+
protected function createStructureMapEntry(TestableHtmlElement $node, int $level): string
91+
{
92+
return sprintf("\n%s%s%s", str_repeat(' ', $level), $node->tag, $node->nodes->map(function (TestableHtmlElement $node) use ($level): string {
93+
return $this->createStructureMapEntry($node, $level + 1);
94+
})->implode(''));
95+
}
96+
97+
protected function createTextMapEntry(TestableHtmlElement $node, bool $addNewline = true): string
98+
{
99+
$childEntries = $node->nodes->map(function (TestableHtmlElement $node): string {
100+
$isInline = in_array($node->tag, ['a', 'abbr', 'acronym', 'b', 'bdo', 'big', 'br', 'button', 'cite', 'code', 'dfn', 'em', 'i', 'img', 'input', 'kbd', 'label', 'map', 'object', 'q', 'samp', 'script', 'select', 'small', 'span', 'strong', 'sub', 'sup', 'textarea', 'time', 'tt', 'var']);
101+
102+
return $this->createTextMapEntry($node, ! $isInline);
103+
})->implode('');
104+
105+
return sprintf('%s%s%s', $addNewline ? "\n" : ' ', $node->text, $childEntries);
106+
}
107+
108+
protected function createAstInspectionDump(): string
109+
{
110+
$html = '<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Document Dump</title><style>body { font-family: sans-serif; } .node { margin-left: 1em; }</style></head><body><h1>Document Dump</h1>';
111+
112+
$html .= '<h2>Abstract Syntax Tree Node Inspection</h2>';
113+
$openAllButton = '<script>function openAll() {document.querySelectorAll(\'details\').forEach((el) => el.open = true);}</script><a href="javascript:openAll();" onclick="this.remove();">Open all</a>';
114+
$html .= sprintf("\n<details open><summary><strong>Document</strong> <small>$openAllButton</small></summary>\n<ul>%s</ul></details>\n", $this->nodes->map(function (TestableHtmlElement $node): string {
115+
return $this->createDumpNodeMapEntry($node);
116+
})->implode(''));
117+
118+
$html .= sprintf('<section style="display: flex; flex-direction: row; flex-wrap: wrap; gap: 1em;"><div>
119+
<h2>Document Preview</h2><iframe src="data:text/html;base64,%s" width="960px" height="600px"></iframe></div><div>
120+
<h2>Raw HTML</h2><textarea cols="120" rows="30" readonly style="width: 960px; height: 600px; white-space: pre; font-family: monospace;">%s</textarea>
121+
</div></section>', base64_encode($this->html), e($this->html));
122+
123+
$html .= sprintf('<h3>Node Structure</h3><div style="max-width: 1440px; overflow-x: auto; border: 1px solid #333; padding: 0.5rem 1rem;"><pre><code>%s</code></pre></div>', $this->getStructure());
124+
$html .= sprintf('<h3>Text Representation</h3><div style="max-width: 1440px; overflow-x: auto; border: 1px solid #333; padding: 0.5rem 1rem;"><pre><code>%s</code></pre></div>', $this->getTextRepresentation());
125+
126+
return $html.'<hr>'.'{{ $footer }}'.'</body></html>';
127+
}
128+
129+
protected function createDumpNodeMapEntry(TestableHtmlElement $node): string
130+
{
131+
$data = $node->toArray();
132+
133+
$list = sprintf("\n <ul class=\"node\">\n%s </ul>\n", implode('', array_map(function (string|iterable $value, string $key): string {
134+
if ($value instanceof Collection) {
135+
if ($value->isEmpty()) {
136+
return sprintf(" <li><strong>%s</strong>: <span>None</span></li>\n", ucfirst($key));
137+
}
138+
139+
return sprintf(" <li><strong>%s</strong>: <ul>%s</ul></li>\n", ucfirst($key), $value->map(function (TestableHtmlElement $node): string {
140+
return $this->createDumpNodeMapEntry($node);
141+
})->implode(''));
142+
}
143+
144+
if (is_array($value)) {
145+
if (! is_numeric(array_key_first($value))) {
146+
$value = array_map(function (string $value, string $key): string {
147+
return sprintf('%s: %s', $key, str_contains($value, ' ') ? sprintf('"%s"', $value) : $value);
148+
}, $value, array_keys($value));
149+
}
150+
$value = implode(', ', $value);
151+
}
152+
153+
return sprintf(" <li><strong>%s</strong>: <span>%s</span></li>\n", ucfirst($key), $value);
154+
}, $data, array_keys($data))));
155+
156+
if ($node->text) {
157+
$title = sprintf('<%s>%s</%s>', $node->tag, e($node->text), $node->tag);
158+
} else {
159+
$title = sprintf('<%s>', $node->tag);
160+
}
161+
162+
return sprintf(" <li><%s><summary><strong>%s</strong></summary>%s </details></li>\n", $node->tag === 'html' ? 'details open' : 'details', e($title), $list);
163+
}
164+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Hyde\Testing\Support\HtmlTesting;
6+
7+
use Illuminate\Testing\Assert as PHPUnit;
8+
9+
trait HtmlTestingAssertions
10+
{
11+
public function complete(): void
12+
{
13+
// Just an empty helper so we get easier Git diffs when adding new assertions.
14+
}
15+
16+
public function assertSee(string $value): static
17+
{
18+
return $this->doAssert(fn () => PHPUnit::assertStringContainsString($value, $this->html, "The string '$value' was not found in the HTML."));
19+
}
20+
21+
public function assertDontSee(string $value): static
22+
{
23+
return $this->doAssert(fn () => PHPUnit::assertStringNotContainsString($value, $this->html, "The string '$value' was found in the HTML."));
24+
}
25+
26+
public function assertSeeEscaped(string $value): static
27+
{
28+
return $this->doAssert(fn () => PHPUnit::assertStringContainsString(e($value), $this->html, "The escaped string '$value' was not found in the HTML."));
29+
}
30+
31+
public function assertDontSeeEscaped(string $value): static
32+
{
33+
return $this->doAssert(fn () => PHPUnit::assertStringNotContainsString(e($value), $this->html, "The escaped string '$value' was found in the HTML."));
34+
}
35+
36+
protected function doAssert(callable $assertion): static
37+
{
38+
$assertion();
39+
40+
return $this;
41+
}
42+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# HTML Testing Support
2+
3+
This domain provides a set of classes and methods to help with testing HTML output.
4+
5+
Like all testing helpers in HydePHP, this code is internal and should not be depended on outside HydePHP.

0 commit comments

Comments
 (0)