Passed
Push — master ( 5fa21f...f08728 )
by Caen
03:34 queued 15s
created

TestableHtmlDocument   A

Complexity

Total Complexity 22

Size/Duplication

Total Lines 167
Duplicated Lines 0 %

Importance

Changes 87
Bugs 0 Features 0
Metric Value
eloc 51
c 87
b 0
f 0
dl 0
loc 167
rs 10
wmc 22
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Hyde\Testing\Support\HtmlTesting;
6
7
use DOMElement;
8
use DOMDocument;
9
use InvalidArgumentException;
10
use Illuminate\Support\Collection;
11
use Illuminate\Testing\Assert as PHPUnit;
12
13
use function trim;
14
use function substr;
15
use function explode;
16
use function array_map;
17
use function array_shift;
18
use function str_starts_with;
19
20
/**
21
 * A wrapper for an HTML document, parsed into an assertable and queryable object, with an abstract syntax tree.
22
 */
23
class TestableHtmlDocument
24
{
25
    use HtmlTestingAssertions;
26
    use DumpsDocumentState;
27
28
    public readonly string $html;
29
30
    /** @var \Illuminate\Support\Collection<\Hyde\Testing\Support\HtmlTesting\TestableHtmlElement> The document's element nodes. */
31
    public readonly Collection $nodes;
32
    protected DOMDocument $document;
33
34
    public function __construct(string $html)
35
    {
36
        $this->html = $html;
37
        $this->nodes = $this->parseNodes($html);
38
    }
39
40
    public function getRootElement(): TestableHtmlElement
41
    {
42
        return $this->nodes->first();
43
    }
44
45
    public function element(string $element): ?TestableHtmlElement
46
    {
47
        return match (true) {
48
            str_starts_with($element, '#') => $this->getElementById(substr($element, 1)),
49
            str_contains($element, '>') => $this->query($element),
50
            default => throw new InvalidArgumentException("The selector syntax '$element' is not supported."),
51
        };
52
    }
53
54
    public function getElementById(string $id): ?TestableHtmlElement
55
    {
56
        return $this->nodes->first(fn (TestableHtmlElement $node) => $node->element->getAttribute('id') === $id);
57
    }
58
59
    /**
60
     * Select an element from the document using a CSS selector.
61
     *
62
     * Note that this means all subsequent assertions will be scoped to the selected element.
63
     * Use {@see self::tapElement()} to execute a callback on the selected element while retaining the method chains.
64
     */
65
    public function getElementUsingQuery(string $selector): TestableHtmlElement
66
    {
67
        $element = $this->query($selector);
68
69
        if (! $element) {
70
            PHPUnit::fail("No element matching the selector '$selector' was found in the HTML.");
71
        }
72
73
        return $element;
74
    }
75
76
    /**
77
     * Get a collection of elements with the given class name.
78
     *
79
     * @return \Illuminate\Support\Collection<\Hyde\Testing\Support\HtmlTesting\TestableHtmlElement>
80
     */
81
    public function getElementsByClass(string $class): Collection
82
    {
83
        $matchingNodes = collect();
84
85
        $traverse = function (TestableHtmlElement $node) use (&$traverse, $class, &$matchingNodes): void {
86
            if (in_array($class, $node->classes, true)) {
87
                $matchingNodes->push($node);
88
            }
89
90
            foreach ($node->nodes as $childNode) {
91
                $traverse($childNode);
92
            }
93
        };
94
95
        $traverse($this->getRootElement());
96
97
        return $matchingNodes;
98
    }
99
100
    /**
101
     * Execute a testing callback on an element matching the given CSS selector or ID.
102
     *
103
     * This is useful for fluent assertions while retaining the method chains of this class.
104
     * Use {@see self::element()} to scope subsequent assertions to the selected element.
105
     */
106
    public function tapElement(string $selector, callable $callback): static
107
    {
108
        $callback($this->element($selector));
109
110
        return $this;
111
    }
112
113
    /** @note Use this sparingly, as you generally should not care about the exact HTML structure. */
114
    public function assertStructureLooksLike($expected): static
115
    {
116
        return $this->doAssert(fn () => PHPUnit::assertSame($expected, $this->getStructure(), 'The HTML structure does not look like expected.'));
117
    }
118
119
    /** A better alternative to assertStructureLooksLike, as it only cares about the visible text. */
120
    public function assertLooksLike($expected): static
121
    {
122
        return $this->doAssert(fn () => PHPUnit::assertSame($expected, $this->getTextRepresentation(), 'The HTML text does not look like expected.'));
123
    }
124
125
    /**
126
     * Using CSS style selectors, this method allows for querying the document's nodes.
127
     * Note that the first element in the DOM is skipped, so you don't need to start with `html` or `body`.
128
     * The first matching selector will be returned, or null if no match was found.
129
     *
130
     * @example $this->query('head > title')
131
     */
132
    public function query(string $selector): ?TestableHtmlElement
133
    {
134
        $selectors = array_map('trim', explode('>', trim($selector, '> ')));
135
136
        $nodes = $this->nodes;
137
138
        // While we have any selectors left, we continue to narrow down the nodes
139
        while ($selector = array_shift($selectors)) {
140
            $node = $nodes->first();
141
142
            if ($node === null) {
143
                return null;
144
            }
145
146
            $nodes = $this->queryCursorNode($selector, $node);
147
        }
148
149
        return $nodes->first();
150
    }
151
152
    protected function parseNodes(string $html): Collection
153
    {
154
        $nodes = new Collection();
155
        $dom = new DOMDocument();
156
157
        $dom->loadHTML($html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD | LIBXML_NOERROR | LIBXML_NOWARNING | LIBXML_NONET | LIBXML_NOXMLDECL | LIBXML_COMPACT | LIBXML_PARSEHUGE);
158
159
        // Initiate recursive parsing from the root element
160
        foreach ($dom->childNodes as $childNode) {
161
            if ($childNode instanceof DOMElement) {
162
                $nodes->push($this->parseNodeRecursive($childNode));
163
            }
164
        }
165
166
        $this->document = $dom;
167
168
        return $nodes;
169
    }
170
171
    protected function parseNodeRecursive(DOMElement $element, ?TestableHtmlElement $parent = null): TestableHtmlElement
172
    {
173
        // Initialize a new TestableHtmlElement for this DOMElement
174
        $htmlElement = new TestableHtmlElement($element->ownerDocument->saveHTML($element), $element, $parent);
175
176
        // Iterate through child nodes and recursively parse them
177
        foreach ($element->childNodes as $childNode) {
178
            if ($childNode instanceof DOMElement) {
179
                $htmlElement->nodes->push($this->parseNodeRecursive($childNode, $htmlElement));
180
            }
181
        }
182
183
        return $htmlElement;
184
    }
185
186
    protected function queryCursorNode(string $selector, TestableHtmlElement $node): Collection
187
    {
188
        // Scope the node's child nodes to the selector
189
        return $node->nodes->filter(fn (TestableHtmlElement $node) => $node->tag === $selector);
190
    }
191
}
192