Passed
Pull Request — 1.x (#62)
by Kevin
02:01
created

Dom::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 1
Metric Value
cc 1
eloc 1
nc 1
nop 1
dl 0
loc 3
rs 10
c 1
b 0
f 1
1
<?php
2
3
namespace Zenstruck\Browser;
4
5
use Symfony\Component\DomCrawler\Crawler;
6
use Symfony\Component\DomCrawler\Field\ChoiceFormField as BaseChoiceFormField;
7
use Symfony\Component\DomCrawler\Field\FormField;
8
use Symfony\Component\DomCrawler\Form;
9
use Zenstruck\Assert;
10
use Zenstruck\Browser\Dom\Assertion\FieldCheckedAssertion;
11
use Zenstruck\Browser\Dom\Assertion\FieldSelectedAssertion;
12
use Zenstruck\Browser\Dom\Field\ChoiceFormField;
13
14
/**
15
 * @mixin Crawler
16
 *
17
 * @author Kevin Bond <[email protected]>
18
 */
19
final class Dom implements \IteratorAggregate, \Countable
20
{
21
    private Crawler $crawler;
22
23
    private function __construct(Crawler $crawler)
24
    {
25
        $this->crawler = $crawler;
26
    }
27
28
    public function __call(string $name, array $arguments)
29
    {
30
        if (!\method_exists($this->crawler, $name)) {
31
            throw new \BadMethodCallException(\sprintf('Method "%s" does not exist on "%s".', $name, \get_class($this->crawler)));
32
        }
33
34
        $ret = $this->crawler->{$name}(...$arguments);
35
36
        return $ret instanceof Crawler ? new self($ret) : $ret;
37
    }
38
39
    /**
40
     * @param Crawler|self $crawler
41
     */
42
    public static function wrap($crawler): self
43
    {
44
        return $crawler instanceof self ? $crawler : new self($crawler);
45
    }
46
47
    public static function fromString(string $value): self
48
    {
49
        return self::wrap(new Crawler($value));
50
    }
51
52
    public function crawler(): Crawler
53
    {
54
        return $this->crawler;
55
    }
56
57
    /**
58
     * Similar to {@see Crawler::getIterator()} but iterate over the nodes
59
     * as {@see self} instead of {@see \DOMNode}.
60
     *
61
     * @return \Traversable|self[]
62
     */
63
    public function getIterator(): \Traversable
64
    {
65
        return new \ArrayIterator($this->each(fn(Crawler $c) => new self($c)));
0 ignored issues
show
Bug introduced by
The method each() does not exist on Zenstruck\Browser\Dom. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

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

65
        return new \ArrayIterator($this->/** @scrutinizer ignore-call */ each(fn(Crawler $c) => new self($c)));
Loading history...
66
    }
67
68
    public function count(): int
69
    {
70
        return $this->crawler->count();
71
    }
72
73
    /**
74
     * @param string $selector XPath or CSS expression
75
     */
76
    public function find(string $selector): self
77
    {
78
        try {
79
            return $this->filter($selector);
0 ignored issues
show
Bug introduced by
The method filter() does not exist on Zenstruck\Browser\Dom. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

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

79
            return $this->/** @scrutinizer ignore-call */ filter($selector);
Loading history...
80
        } catch (\Exception $e) {
81
            // could not covert selector to xpath, try as xpath directly
82
            try {
83
                \set_error_handler(static function($errno, $errstr, $errfile, $errline) {
84
                    throw new \ErrorException($errstr, 0, $errno, $errfile, $errline);
85
                });
86
87
                return $this->filterXPath($selector);
0 ignored issues
show
Bug introduced by
The method filterXPath() does not exist on Zenstruck\Browser\Dom. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

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

87
                return $this->/** @scrutinizer ignore-call */ filterXPath($selector);
Loading history...
88
            } finally {
89
                \restore_error_handler();
90
            }
91
        }
92
    }
93
94
    /**
95
     * Similar to {@see BaseCrawler::form()} but with a few differences:
96
     * 1. If not currently on a form node, attempt to find the closest.
97
     * 2. todo Cache the form object so it can be manipulated by the form manipulation methods.
98
     */
99
    public function form(?array $values = null, ?string $method = null): Form
100
    {
101
        if (!$element = $this->closest('form')) {
0 ignored issues
show
Bug introduced by
The method closest() does not exist on Zenstruck\Browser\Dom. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

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

101
        if (!$element = $this->/** @scrutinizer ignore-call */ closest('form')) {
Loading history...
102
            throw new \InvalidArgumentException('Unable to find form in DOM tree.');
103
        }
104
105
        return $element->crawler->form($values, $method);
106
    }
107
108
    public function field(): FormField
109
    {
110
        $form = $this->form();
111
        $field = $form->get(\str_replace('[]', '', $this->attr('name')));
0 ignored issues
show
Bug introduced by
The method attr() does not exist on Zenstruck\Browser\Dom. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

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

111
        $field = $form->get(\str_replace('[]', '', $this->/** @scrutinizer ignore-call */ attr('name')));
Loading history...
112
        // TODO
113
//        if (\is_array($field) && isset($field[0]) && $field[0] instanceof BaseFileFormField) {
114
//            $field = $field[0];
115
//        }
116
//
117
//        if ($field instanceof BaseFileFormField) {
118
//            return new FileFormField($field, $form);
119
//        }
120
121
        if ($field instanceof BaseChoiceFormField) {
122
            return new ChoiceFormField($field, $this);
123
        }
124
125
        return $field;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $field could return the type Symfony\Component\DomCra...wler\Field\FormField[]> which is incompatible with the type-hinted return Symfony\Component\DomCrawler\Field\FormField. Consider adding an additional type-check to rule them out.
Loading history...
126
    }
127
128
    /**
129
     * @param string $selector css, xpath, name, id, or label text
130
     */
131
    public function findFormField(string $selector): ?FormField
132
    {
133
        try {
134
            return $this->selectField($selector)->field();
135
        } catch (\InvalidArgumentException $e) {
136
            return null;
137
        }
138
    }
139
140
    /**
141
     * @see findFormField()
142
     *
143
     * @throws \InvalidArgumentException If not found
144
     */
145
    public function getFormField(string $selector): FormField
146
    {
147
        if (!$field = $this->findFormField($selector)) {
148
            throw new \InvalidArgumentException("Form field matching \"{$selector}\" not found.");
149
        }
150
151
        return $field;
152
    }
153
154
    /**
155
     * @param string $selector css, xpath, name, id, or label text
156
     */
157
    public function selectField(string $selector): self
158
    {
159
        // try css/xpath
160
        try {
161
            $element = $this->find($selector);
162
        } catch (\Exception $e) {
163
            $element = [];
164
        }
165
166
        if (!\count($element)) {
167
            // try by name
168
            $element = $this->filter("form [name=\"{$selector}\"]");
169
        }
170
171
        if (!\count($element)) {
172
            // try by id
173
            $element = $this->filterXPath(".//*[@id=\"{$selector}\"]");
174
        }
175
176
        if (!\count($element) && \count($label = $this->filterXPath(".//label[contains(normalize-space(),\"{$selector}\")]"))) {
177
            // try by label text
178
            if ($attr = $label->attr('for')) {
179
                // <label for="input-id">
180
                $element = $this->filter("#{$attr}");
181
            } else {
182
                // <label><input/></label>
183
                $element = $label->filter('[name]');
184
            }
185
        }
186
187
        return $element;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $element could return the type array which is incompatible with the type-hinted return Zenstruck\Browser\Dom. Consider adding an additional type-check to rule them out.
Loading history...
188
    }
189
190
    public function assertSee(string $expected): self
191
    {
192
        Assert::that($this->text())->contains($expected, 'Expected to see text "{needle}".');
0 ignored issues
show
Bug introduced by
The method text() does not exist on Zenstruck\Browser\Dom. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

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

192
        Assert::that($this->/** @scrutinizer ignore-call */ text())->contains($expected, 'Expected to see text "{needle}".');
Loading history...
193
194
        return $this;
195
    }
196
197
    public function assertNotSee(string $expected): self
198
    {
199
        Assert::that($this->text())->doesNotContain($expected, 'Expected to not see text "{needle}".');
200
201
        return $this;
202
    }
203
204
    public function assertSeeElement(string $selector): self
205
    {
206
        Assert::that($this->find($selector))
207
            ->isNotEmpty('Expected to find element matching "{selector}".', [
208
                'selector' => $selector,
209
                'actual' => $this->html(),
0 ignored issues
show
Bug introduced by
The method html() does not exist on Zenstruck\Browser\Dom. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

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

209
                'actual' => $this->/** @scrutinizer ignore-call */ html(),
Loading history...
210
            ])
211
        ;
212
213
        return $this;
214
    }
215
216
    public function assertNotSeeElement(string $selector): self
217
    {
218
        Assert::that($this->find($selector))
219
            ->isEmpty(
220
                'Expected to not find element matching "{selector}" but found {count}.',
221
                ['selector' => $selector, 'actual' => $this->html()]
222
            )
223
        ;
224
225
        return $this;
226
    }
227
228
    public function assertSeeIn(string $selector, string $expected): self
229
    {
230
        $this->assertSeeElement($selector);
231
232
        Assert::that($this->find($selector)->text())->contains(
233
            $expected,
234
            'Expected to see text "{needle}" in element matching "{selector}".',
235
            ['selector' => $selector]
236
        );
237
238
        return $this;
239
    }
240
241
    public function assertNotSeeIn(string $selector, string $expected): self
242
    {
243
        $this->assertSeeElement($selector);
244
245
        Assert::that($this->find($selector)->text())->doesNotContain(
246
            $expected,
247
            'Expected to not see text "{needle}" in element matching "{selector}".',
248
            ['selector' => $selector, 'haystack' => $this->html()]
249
        );
250
251
        return $this;
252
    }
253
254
    public function assertElementCount(string $selector, int $expected): self
255
    {
256
        Assert::that($this->find($selector))
257
            ->hasCount($expected, 'Expected to find {expected} element(s) matching "{selector}" but found {actual}.',
258
                ['selector' => $selector, 'haystack' => $this->html()]
259
            )
260
        ;
261
262
        return $this;
263
    }
264
265
    public function assertElementAttributeContains(string $selector, string $attribute, string $expected): self
266
    {
267
        $this->assertElementHasAttribute($selector, $attribute);
268
269
        Assert::that($this->find($selector)->attr($attribute))->contains(
270
            $expected,
271
            'Expected "{attribute}" attribute for element matching "{selector}" to contain "{needle}".',
272
            ['attribute' => $attribute, 'selector' => $selector]
273
        );
274
275
        return $this;
276
    }
277
278
    public function assertElementAttributeNotContains(string $selector, string $attribute, string $expected): self
279
    {
280
        $this->assertElementHasAttribute($selector, $attribute);
281
282
        Assert::that($this->find($selector)->attr($attribute))->doesNotContain(
283
            $expected,
284
            'Expected "{attribute}" attribute for element matching "{selector}" to not contain "{needle}".',
285
            ['attribute' => $attribute, 'selector' => $selector]
286
        );
287
288
        return $this;
289
    }
290
291
    public function assertElementHasAttribute(string $selector, string $attribute): self
292
    {
293
        $this->assertSeeElement($selector);
294
295
        Assert::that($this->find($selector)->attr($attribute))->isNotEmpty(
296
            'Expected element matching "{selector}" to have "{attribute}" attribute.',
297
            ['selector' => $selector, 'attribute' => $attribute]
298
        );
299
300
        return $this;
301
    }
302
303
    public function assertFieldEquals(string $selector, string $expected): self
304
    {
305
        Assert::that(Assert::try(fn() => $this->getFormField($selector))->getValue())
306
            ->equals(
307
                $expected,
308
                'Expected form field matching "{selector}" to equal "{expected}".',
309
                ['selector' => $selector]
310
            )
311
        ;
312
313
        return $this;
314
    }
315
316
    public function assertFieldNotEquals(string $selector, string $expected): self
317
    {
318
        Assert::that(Assert::try(fn() => $this->getFormField($selector))->getValue())
319
            ->isNotEqualTo(
320
                $expected,
321
                'Expected form field matching "{selector}" to not equal "{expected}".',
322
                ['selector' => $selector]
323
            )
324
        ;
325
326
        return $this;
327
    }
328
329
    public function assertFieldContains(string $selector, string $expected): self
330
    {
331
        Assert::that(Assert::try(fn() => $this->getFormField($selector))->getValue())
332
            ->contains(
333
                $expected,
334
                'Expected form field matching "{selector}" to contain "{needle}".',
335
                ['selector' => $selector]
336
            )
337
        ;
338
339
        return $this;
340
    }
341
342
    public function assertFieldNotContains(string $selector, string $expected): self
343
    {
344
        Assert::that(Assert::try(fn() => $this->getFormField($selector))->getValue())
345
            ->doesNotContain(
346
                $expected,
347
                'Expected form field matching "{selector}" to not contain "{needle}".',
348
                ['selector' => $selector]
349
            )
350
        ;
351
352
        return $this;
353
    }
354
355
    public function assertFieldEmpty(string $selector): self
356
    {
357
        Assert::that(Assert::try(fn() => $this->getFormField($selector))->getValue())
358
            ->isEmpty(
359
                'Expected form field matching "{selector}" to be empty.',
360
                ['selector' => $selector]
361
            )
362
        ;
363
364
        return $this;
365
    }
366
367
    public function assertFieldNotEmpty(string $selector): self
368
    {
369
        Assert::that(Assert::try(fn() => $this->getFormField($selector))->getValue())
370
            ->isNotEmpty(
371
                'Expected form field matching "{selector}" to not be empty.',
372
                ['selector' => $selector]
373
            )
374
        ;
375
376
        return $this;
377
    }
378
379
    public function assertChecked(string $selector): self
380
    {
381
        Assert::run(new FieldCheckedAssertion($this, $selector));
382
383
        return $this;
384
    }
385
386
    public function assertNotChecked(string $selector): self
387
    {
388
        Assert::not(new FieldCheckedAssertion($this, $selector));
389
390
        return $this;
391
    }
392
393
    public function assertSelected(string $selector, ?string $expected = null): self
394
    {
395
        if (null === $expected) {
396
            return $this->assertChecked($selector);
397
        }
398
399
        Assert::run(new FieldSelectedAssertion($this, $selector, $expected));
400
401
        return $this;
402
    }
403
404
    public function assertNotSelected(string $selector, ?string $expected = null): self
405
    {
406
        if (null === $expected) {
407
            return $this->assertNotChecked($selector);
408
        }
409
410
        Assert::not(new FieldSelectedAssertion($this, $selector, $expected));
411
412
        return $this;
413
    }
414
}
415