Passed
Push — master ( eaf66d...5fa21f )
by Caen
13:40 queued 12s
created

TestableHtmlDocument::parseNodes()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 17
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 6
Bugs 0 Features 0
Metric Value
cc 3
eloc 8
c 6
b 0
f 0
nc 3
nop 1
dl 0
loc 17
rs 10
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;
0 ignored issues
show
introduced by
The trait Hyde\Testing\Support\Htm...ting\DumpsDocumentState requires some properties which are not provided by Hyde\Testing\Support\Htm...ng\TestableHtmlDocument: $tag, $text
Loading history...
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;
0 ignored issues
show
Bug introduced by
The property html is declared read-only in Hyde\Testing\Support\Htm...ng\TestableHtmlDocument.
Loading history...
37
        $this->nodes = $this->parseNodes($html);
0 ignored issues
show
Bug introduced by
The property nodes is declared read-only in Hyde\Testing\Support\Htm...ng\TestableHtmlDocument.
Loading history...
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);
0 ignored issues
show
Bug introduced by
The method saveHTML() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

174
        $htmlElement = new TestableHtmlElement($element->ownerDocument->/** @scrutinizer ignore-call */ saveHTML($element), $element, $parent);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
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