• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

nette / tester / 22278866689

22 Feb 2026 02:19PM UTC coverage: 81.835%. First build
22278866689

Pull #467

github

web-flow
Merge 8859839ce into 29a5403e0
Pull Request #467: Feat/structural metrics v2

79 of 107 new or added lines in 18 files covered. (73.83%)

1757 of 2147 relevant lines covered (81.84%)

0.82 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

82.56
/src/Framework/DomQuery.php
1
<?php
2

3
/**
4
 * This file is part of the Nette Tester.
5
 * Copyright (c) 2009 David Grudl (https://davidgrudl.com)
6
 */
7

8
declare(strict_types=1);
9

10
namespace Tester;
11

12
use Dom;
13
use const PHP_VERSION_ID, PREG_SET_ORDER;
14

15

16
/**
17
 * Simplifies querying and traversing HTML documents using CSS selectors.
18
 */
19
class DomQuery extends \SimpleXMLElement
20
{
21
        /**
22
         * Creates a DomQuery object from an HTML string.
23
         */
24
        public static function fromHtml(string $html): self
1✔
25
        {
26
                $old = libxml_use_internal_errors(use_errors: true);
1✔
27
                libxml_clear_errors();
1✔
28

29
                if (PHP_VERSION_ID < 80400) {
1✔
30
                        if (!str_contains($html, '<')) {
×
31
                                $html = '<body>' . $html;
×
32
                        }
33

34
                        $html = @mb_convert_encoding($html, 'HTML', 'UTF-8'); // @ - deprecated
×
35

36
                        // parse these elements as void
37
                        $html = preg_replace('#<(keygen|source|track|wbr)(?=\s|>)((?:"[^"]*"|\'[^\']*\'|[^"\'>])*+)(?<!/)>#', '<$1$2 />', $html);
×
38

39
                        // fix parsing of </ inside scripts
40
                        $html = preg_replace_callback(
×
41
                                '#(<script(?=\s|>)(?:"[^"]*"|\'[^\']*\'|[^"\'>])*+>)(.*?)(</script>)#s',
×
42
                                fn(array $m): string => $m[1] . str_replace('</', '<\/', $m[2]) . $m[3],
×
43
                                $html,
44
                        );
45

46
                        $dom = new \DOMDocument;
×
47
                        $dom->loadHTML($html);
×
48
                } else {
49
                        if (!preg_match('~<!DOCTYPE~i', $html)) {
1✔
50
                                $html = '<!DOCTYPE html>' . $html;
1✔
51
                        }
52
                        $dom = Dom\HTMLDocument::createFromString($html, Dom\HTML_NO_DEFAULT_NS, 'UTF-8');
1✔
53
                }
54

55
                $errors = libxml_get_errors();
1✔
56
                libxml_use_internal_errors($old);
1✔
57

58
                foreach ($errors as $error) {
1✔
59
                        if (!preg_match('#Tag \S+ invalid#', $error->message)) {
1✔
60
                                trigger_error(__METHOD__ . ": $error->message on line $error->line.", E_USER_WARNING);
1✔
61
                        }
62
                }
63

64
                return simplexml_import_dom($dom, self::class);
1✔
65
        }
66

67

68
        /**
69
         * Creates a DomQuery object from an XML string.
70
         */
71
        public static function fromXml(string $xml): self
1✔
72
        {
73
                return simplexml_load_string($xml, self::class);
1✔
74
        }
75

76

77
        /**
78
         * Returns array of elements matching CSS selector.
79
         * @return list<self>
80
         */
81
        public function find(string $selector): array
1✔
82
        {
83
                if (PHP_VERSION_ID < 80400) {
1✔
NEW
84
                        return (str_starts_with($selector, ':scope')
×
85
                                ? $this->xpath('self::' . self::css2xpath(substr($selector, 6)))
×
NEW
86
                                : $this->xpath('descendant::' . self::css2xpath($selector))) ?: [];
×
87
                }
88

89
                return array_map(
1✔
90
                        fn($el) => simplexml_import_dom($el, self::class),
1✔
91
                        iterator_to_array(Dom\import_simplexml($this)->querySelectorAll($selector)),
1✔
92
                );
93
        }
94

95

96
        /**
97
         * Checks if any descendant matches CSS selector.
98
         */
99
        public function has(string $selector): bool
1✔
100
        {
101
                return PHP_VERSION_ID < 80400
1✔
102
                        ? (bool) $this->find($selector)
×
103
                        : (bool) Dom\import_simplexml($this)->querySelector($selector);
1✔
104
        }
105

106

107
        /**
108
         * Checks if element matches CSS selector.
109
         */
110
        public function matches(string $selector): bool
1✔
111
        {
112
                return PHP_VERSION_ID < 80400
1✔
113
                        ? (bool) $this->xpath('self::' . self::css2xpath($selector))
×
114
                        : Dom\import_simplexml($this)->matches($selector);
1✔
115
        }
116

117

118
        /**
119
         * Returns closest ancestor matching CSS selector.
120
         */
121
        public function closest(string $selector): ?self
1✔
122
        {
123
                if (PHP_VERSION_ID < 80400) {
1✔
124
                        throw new \LogicException('Requires PHP 8.4 or newer.');
×
125
                }
126
                $el = Dom\import_simplexml($this)->closest($selector);
1✔
127
                return $el ? simplexml_import_dom($el, self::class) : null;
1✔
128
        }
129

130

131
        /**
132
         * Converts a CSS selector into an XPath expression.
133
         */
134
        public static function css2xpath(string $css): string
1✔
135
        {
136
                $xpath = '*';
1✔
137
                preg_match_all(<<<'XX'
1✔
138
                        /
1✔
139
                                ([#.:]?)([a-z][a-z0-9_-]*)|               # id, class, pseudoclass (1,2)
140
                                \[
141
                                        ([a-z0-9_-]+)
142
                                        (?:
143
                                                ([~*^$]?)=(
144
                                                        "[^"]*"|
145
                                                        '[^']*'|
146
                                                        [^\]]+
147
                                                )
148
                                        )?
149
                                \]|                                       # [attr=val] (3,4,5)
150
                                \s*([>,+~])\s*|                           # > , + ~ (6)
151
                                (\s+)|                                    # whitespace (7)
152
                                (\*)                                      # * (8)
153
                        /ix
154
                        XX, trim($css), $matches, PREG_SET_ORDER);
1✔
155
                foreach ($matches as $m) {
1✔
156
                        if ($m[1] === '#') { // #ID
1✔
157
                                $xpath .= "[@id='$m[2]']";
1✔
158
                        } elseif ($m[1] === '.') { // .class
1✔
159
                                $xpath .= "[contains(concat(' ', normalize-space(@class), ' '), ' $m[2] ')]";
1✔
160
                        } elseif ($m[1] === ':') { // :pseudo-class
1✔
161
                                throw new \InvalidArgumentException('Not implemented.');
1✔
162
                        } elseif ($m[2]) { // tag
1✔
163
                                $xpath = rtrim($xpath, '*') . $m[2];
1✔
164
                        } elseif ($m[3]) { // [attribute]
1✔
165
                                $attr = '@' . strtolower($m[3]);
1✔
166
                                if (!isset($m[5])) {
1✔
167
                                        $xpath .= "[$attr]";
1✔
168
                                        continue;
1✔
169
                                }
170

171
                                $val = trim($m[5], '"\'');
1✔
172
                                if ($m[4] === '') {
1✔
173
                                        $xpath .= "[$attr='$val']";
1✔
174
                                } elseif ($m[4] === '~') {
1✔
175
                                        $xpath .= "[contains(concat(' ', normalize-space($attr), ' '), ' $val ')]";
1✔
176
                                } elseif ($m[4] === '*') {
1✔
177
                                        $xpath .= "[contains($attr, '$val')]";
1✔
178
                                } elseif ($m[4] === '^') {
1✔
179
                                        $xpath .= "[starts-with($attr, '$val')]";
1✔
180
                                } elseif ($m[4] === '$') {
1✔
181
                                        $xpath .= "[substring($attr, string-length($attr)-0)='$val']";
1✔
182
                                }
183
                        } elseif ($m[6] === '>') {
1✔
184
                                $xpath .= '/*';
1✔
185
                        } elseif ($m[6] === ',') {
1✔
186
                                $xpath .= '|//*';
1✔
187
                        } elseif ($m[6] === '~') {
1✔
188
                                $xpath .= '/following-sibling::*';
1✔
189
                        } elseif ($m[6] === '+') {
1✔
190
                                throw new \InvalidArgumentException('Not implemented.');
1✔
191
                        } elseif ($m[7]) {
1✔
192
                                $xpath .= '//*';
1✔
193
                        }
194
                }
195

196
                return $xpath;
1✔
197
        }
198
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc