Passed
Pull Request — 4 (#10050)
by Steve
08:27
created

FunctionalTest::assertPartialHTMLMatchBySelector()   A

Complexity

Conditions 6
Paths 8

Size

Total Lines 23
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 6
eloc 13
nc 8
nop 3
dl 0
loc 23
rs 9.2222
c 0
b 0
f 0
1
<?php
2
3
namespace SilverStripe\Dev;
4
5
use PHPUnit_Framework_AssertionFailedError;
6
use SilverStripe\Control\Director;
7
use SilverStripe\Control\HTTPResponse;
8
use SilverStripe\Control\Session;
9
use SilverStripe\Core\Config\Config;
10
use SilverStripe\Security\BasicAuth;
11
use SilverStripe\Security\Member;
12
use SilverStripe\Security\SecurityToken;
13
use SilverStripe\View\SSViewer;
14
use SimpleXMLElement;
15
16
/**
17
 * SilverStripe-specific testing object designed to support functional testing of your web app.  It simulates get/post
18
 * requests, form submission, and can validate resulting HTML, looking up content by CSS selector.
19
 *
20
 * The example below shows how it works.
21
 *
22
 * <code>
23
 *   public function testMyForm() {
24
 *   // Visit a URL
25
 *   $this->get("your/url");
26
 *
27
 *   // Submit a form on the page that you get in response
28
 *   $this->submitForm("MyForm_ID", "action_dologin", array("Email" => "invalid email ^&*&^"));
29
 *
30
 *   // Validate the content that is returned
31
 *   $this->assertExactMatchBySelector("#MyForm_ID p.error", array("That email address is invalid."));
32
 *  }
33
 * </code>
34
 */
35
class FunctionalTest extends SapphireTest implements TestOnly
36
{
37
    /**
38
     * Set this to true on your sub-class to disable the use of themes in this test.
39
     * This can be handy for functional testing of modules without having to worry about whether a user has changed
40
     * behaviour by replacing the theme.
41
     *
42
     * @var bool
43
     */
44
    protected static $disable_themes = false;
45
46
    /**
47
     * Set this to true on your sub-class to use the draft site by default for every test in this class.
48
     *
49
     * @deprecated 4.2.0:5.0.0 Use ?stage=Stage in your ->get() querystring requests instead
50
     * @var bool
51
     */
52
    protected static $use_draft_site = false;
53
54
    /**
55
     * @var TestSession
56
     */
57
    protected $mainSession = null;
58
59
    /**
60
     * CSSContentParser for the most recently requested page.
61
     *
62
     * @var CSSContentParser
63
     */
64
    protected $cssParser = null;
65
66
    /**
67
     * If this is true, then 30x Location headers will be automatically followed.
68
     * If not, then you will have to manaully call $this->mainSession->followRedirection() to follow them.
69
     * However, this will let you inspect the intermediary headers
70
     *
71
     * @var bool
72
     */
73
    protected $autoFollowRedirection = true;
74
75
    /**
76
     * Returns the {@link Session} object for this test
77
     *
78
     * @return Session
79
     */
80
    public function session()
81
    {
82
        return $this->mainSession->session();
83
    }
84
85
    protected function setUp()
86
    {
87
        parent::setUp();
88
89
        // Skip calling FunctionalTest directly.
90
        if (static::class == __CLASS__) {
0 ignored issues
show
introduced by
The condition static::class == __CLASS__ is always true.
Loading history...
91
            $this->markTestSkipped(sprintf('Skipping %s ', static::class));
92
        }
93
94
        $this->ensureMainSessionExists();
95
96
        // Disable theme, if necessary
97
        if (static::get_disable_themes()) {
98
            SSViewer::config()->update('theme_enabled', false);
99
        }
100
101
        // Flush user
102
        $this->logOut();
103
104
        // Switch to draft site, if necessary
105
        // If you rely on this you should be crafting stage-specific urls instead though.
106
        if (static::get_use_draft_site()) {
107
            $this->useDraftSite();
108
        }
109
110
        // Unprotect the site, tests are running with the assumption it's off. They will enable it on a case-by-case
111
        // basis.
112
        BasicAuth::protect_entire_site(false);
113
114
        SecurityToken::disable();
115
    }
116
117
    private function ensureMainSessionExists()
118
    {
119
        if (!$this->mainSession) {
120
            $this->mainSession = new TestSession();
121
        }
122
    }
123
124
    protected function tearDown()
125
    {
126
        SecurityToken::enable();
127
        unset($this->mainSession);
128
        parent::tearDown();
129
    }
130
131
    /**
132
     * Log in as the given member
133
     *
134
     * @param Member|int|string $member The ID, fixture codename, or Member object of the member that you want to log in
135
     */
136
    function logInAs($member)
137
    {
138
        parent::logInAs($member);
139
        $member = $this->getMemberFromMixedVariable($member);
140
        if ($member instanceof Member) {
141
            $this->ensureMainSessionExists();
142
            $this->mainSession->session()->set('loggedInAs', $member->ID);
143
        }
144
    }
145
146
    /**
147
     * Run a test while mocking the base url with the provided value
148
     * @param string $url The base URL to use for this test
149
     * @param callable $callback The test to run
150
     */
151
    protected function withBaseURL($url, $callback)
152
    {
153
        $oldBase = Config::inst()->get(Director::class, 'alternate_base_url');
154
        Config::modify()->set(Director::class, 'alternate_base_url', $url);
155
        $callback($this);
156
        Config::modify()->set(Director::class, 'alternate_base_url', $oldBase);
157
    }
158
159
    /**
160
     * Run a test while mocking the base folder with the provided value
161
     * @param string $folder The base folder to use for this test
162
     * @param callable $callback The test to run
163
     */
164
    protected function withBaseFolder($folder, $callback)
165
    {
166
        $oldFolder = Config::inst()->get(Director::class, 'alternate_base_folder');
167
        Config::modify()->set(Director::class, 'alternate_base_folder', $folder);
168
        $callback($this);
169
        Config::modify()->set(Director::class, 'alternate_base_folder', $oldFolder);
170
    }
171
172
    /**
173
     * Submit a get request
174
     * @uses Director::test()
175
     *
176
     * @param string $url
177
     * @param Session $session
178
     * @param array $headers
179
     * @param array $cookies
180
     * @return HTTPResponse
181
     */
182
    public function get($url, $session = null, $headers = null, $cookies = null)
183
    {
184
        $this->cssParser = null;
185
        $response = $this->mainSession->get($url, $session, $headers, $cookies);
186
        if ($this->autoFollowRedirection && is_object($response) && $response->getHeader('Location')) {
187
            $response = $this->mainSession->followRedirection();
188
        }
189
        return $response;
190
    }
191
192
    /**
193
     * Submit a post request
194
     *
195
     * @uses Director::test()
196
     * @param string $url
197
     * @param array $data
198
     * @param array $headers
199
     * @param Session $session
200
     * @param string $body
201
     * @param array $cookies
202
     * @return HTTPResponse
203
     */
204
    public function post($url, $data, $headers = null, $session = null, $body = null, $cookies = null)
205
    {
206
        $this->cssParser = null;
207
        $response = $this->mainSession->post($url, $data, $headers, $session, $body, $cookies);
208
        if ($this->autoFollowRedirection && is_object($response) && $response->getHeader('Location')) {
209
            $response = $this->mainSession->followRedirection();
210
        }
211
        return $response;
212
    }
213
214
    /**
215
     * Submit the form with the given HTML ID, filling it out with the given data.
216
     * Acts on the most recent response.
217
     *
218
     * Any data parameters have to be present in the form, with exact form field name
219
     * and values, otherwise they are removed from the submission.
220
     *
221
     * Caution: Parameter names have to be formatted
222
     * as they are in the form submission, not as they are interpreted by PHP.
223
     * Wrong: array('mycheckboxvalues' => array(1 => 'one', 2 => 'two'))
224
     * Right: array('mycheckboxvalues[1]' => 'one', 'mycheckboxvalues[2]' => 'two')
225
     *
226
     * @see http://www.simpletest.org/en/form_testing_documentation.html
227
     *
228
     * @param string $formID HTML 'id' attribute of a form (loaded through a previous response)
229
     * @param string $button HTML 'name' attribute of the button (NOT the 'id' attribute)
230
     * @param array $data Map of GET/POST data.
231
     * @return HTTPResponse
232
     */
233
    public function submitForm($formID, $button = null, $data = [])
234
    {
235
        $this->cssParser = null;
236
        $response = $this->mainSession->submitForm($formID, $button, $data);
237
        if ($this->autoFollowRedirection && is_object($response) && $response->getHeader('Location')) {
238
            $response = $this->mainSession->followRedirection();
239
        }
240
        return $response;
241
    }
242
243
    /**
244
     * Return the most recent content
245
     *
246
     * @return string
247
     */
248
    public function content()
249
    {
250
        return $this->mainSession->lastContent();
251
    }
252
253
    /**
254
     * Find an attribute in a SimpleXMLElement object by name.
255
     * @param SimpleXMLElement $object
256
     * @param string $attribute Name of attribute to find
257
     * @return SimpleXMLElement object of the attribute
258
     */
259
    public function findAttribute($object, $attribute)
260
    {
261
        $found = false;
262
        foreach ($object->attributes() as $a => $b) {
263
            if ($a == $attribute) {
264
                $found = $b;
265
            }
266
        }
267
        return $found;
268
    }
269
270
    /**
271
     * Return a CSSContentParser for the most recent content.
272
     *
273
     * @return CSSContentParser
274
     */
275
    public function cssParser()
276
    {
277
        if (!$this->cssParser) {
278
            $this->cssParser = new CSSContentParser($this->mainSession->lastContent());
279
        }
280
        return $this->cssParser;
281
    }
282
283
    /**
284
     * Assert that the most recently queried page contains a number of content tags specified by a CSS selector.
285
     * The given CSS selector will be applied to the HTML of the most recent page.  The content of every matching tag
286
     * will be examined. The assertion fails if one of the expectedMatches fails to appear.
287
     *
288
     * Note: &nbsp; characters are stripped from the content; make sure that your assertions take this into account.
289
     *
290
     * @param string $selector A basic CSS selector, e.g. 'li.jobs h3'
291
     * @param array|string $expectedMatches The content of at least one of the matched tags
292
     * @param string $message
293
     * @throws PHPUnit_Framework_AssertionFailedError
294
     */
295
    public function assertPartialMatchBySelector($selector, $expectedMatches, $message = null)
296
    {
297
        if (is_string($expectedMatches)) {
298
            $expectedMatches = [$expectedMatches];
299
        }
300
301
        $items = $this->cssParser()->getBySelector($selector);
302
303
        $actuals = [];
304
        if ($items) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $items of type SimpleXMLElement[] is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
305
            foreach ($items as $item) {
306
                $actuals[trim(preg_replace('/\s+/', ' ', (string)$item))] = true;
307
            }
308
        }
309
310
        $message = $message ?:
311
        "Failed asserting the CSS selector '$selector' has a partial match to the expected elements:\n'"
312
            . implode("'\n'", $expectedMatches) . "'\n\n"
313
            . "Instead the following elements were found:\n'" . implode("'\n'", array_keys($actuals)) . "'";
314
315
        foreach ($expectedMatches as $match) {
316
            $this->assertTrue(isset($actuals[$match]), $message);
317
        }
318
    }
319
320
    /**
321
     * Assert that the most recently queried page contains a number of content tags specified by a CSS selector.
322
     * The given CSS selector will be applied to the HTML of the most recent page.  The full HTML of every matching tag
323
     * will be examined. The assertion fails if one of the expectedMatches fails to appear.
324
     *
325
     * Note: &nbsp; characters are stripped from the content; make sure that your assertions take this into account.
326
     *
327
     * @param string $selector A basic CSS selector, e.g. 'li.jobs h3'
328
     * @param array|string $expectedMatches The content of *all* matching tags as an array
329
     * @param string $message
330
     * @throws PHPUnit_Framework_AssertionFailedError
331
     */
332
    public function assertExactMatchBySelector($selector, $expectedMatches, $message = null)
333
    {
334
        if (is_string($expectedMatches)) {
335
            $expectedMatches = [$expectedMatches];
336
        }
337
338
        $items = $this->cssParser()->getBySelector($selector);
339
340
        $actuals = [];
341
        if ($items) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $items of type SimpleXMLElement[] is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
342
            foreach ($items as $item) {
343
                $actuals[] = trim(preg_replace('/\s+/', ' ', (string)$item));
344
            }
345
        }
346
347
        $message = $message ?:
348
                "Failed asserting the CSS selector '$selector' has an exact match to the expected elements:\n'"
349
                . implode("'\n'", $expectedMatches) . "'\n\n"
350
            . "Instead the following elements were found:\n'" . implode("'\n'", $actuals) . "'";
351
352
        $this->assertTrue($expectedMatches == $actuals, $message);
353
    }
354
355
    /**
356
     * Assert that the most recently queried page contains a number of content tags specified by a CSS selector.
357
     * The given CSS selector will be applied to the HTML of the most recent page.  The content of every matching tag
358
     * will be examined. The assertion fails if one of the expectedMatches fails to appear.
359
     *
360
     * Note: &nbsp; characters are stripped from the content; make sure that your assertions take this into account.
361
     *
362
     * @param string $selector A basic CSS selector, e.g. 'li.jobs h3'
363
     * @param array|string $expectedMatches The content of at least one of the matched tags
364
     * @param string $message
365
     * @throws PHPUnit_Framework_AssertionFailedError
366
     */
367
    public function assertPartialHTMLMatchBySelector($selector, $expectedMatches, $message = null)
368
    {
369
        if (is_string($expectedMatches)) {
370
            $expectedMatches = [$expectedMatches];
371
        }
372
373
        $items = $this->cssParser()->getBySelector($selector);
374
375
        $actuals = [];
376
        if ($items) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $items of type SimpleXMLElement[] is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
377
            /** @var SimpleXMLElement $item */
378
            foreach ($items as $item) {
379
                $actuals[$item->asXML()] = true;
380
            }
381
        }
382
383
        $message = $message ?:
384
                "Failed asserting the CSS selector '$selector' has a partial match to the expected elements:\n'"
385
                . implode("'\n'", $expectedMatches) . "'\n\n"
386
            . "Instead the following elements were found:\n'" . implode("'\n'", array_keys($actuals)) . "'";
387
388
        foreach ($expectedMatches as $match) {
389
            $this->assertTrue(isset($actuals[$match]), $message);
390
        }
391
    }
392
393
    /**
394
     * Assert that the most recently queried page contains a number of content tags specified by a CSS selector.
395
     * The given CSS selector will be applied to the HTML of the most recent page.  The full HTML of every matching tag
396
     * will be examined. The assertion fails if one of the expectedMatches fails to appear.
397
     *
398
     * Note: &nbsp; characters are stripped from the content; make sure that your assertions take this into account.
399
     *
400
     * @param string $selector A basic CSS selector, e.g. 'li.jobs h3'
401
     * @param array|string $expectedMatches The content of *all* matched tags as an array
402
     * @param string $message
403
     * @throws PHPUnit_Framework_AssertionFailedError
404
     */
405
    public function assertExactHTMLMatchBySelector($selector, $expectedMatches, $message = null)
406
    {
407
        $items = $this->cssParser()->getBySelector($selector);
408
409
        $actuals = [];
410
        if ($items) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $items of type SimpleXMLElement[] is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
411
            /** @var SimpleXMLElement $item */
412
            foreach ($items as $item) {
413
                $actuals[] = $item->asXML();
414
            }
415
        }
416
417
        $message = $message ?:
418
            "Failed asserting the CSS selector '$selector' has an exact match to the expected elements:\n'"
419
            . implode("'\n'", $expectedMatches) . "'\n\n"
0 ignored issues
show
Bug introduced by
It seems like $expectedMatches can also be of type string; however, parameter $pieces of implode() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

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

419
            . implode("'\n'", /** @scrutinizer ignore-type */ $expectedMatches) . "'\n\n"
Loading history...
420
            . "Instead the following elements were found:\n'" . implode("'\n'", $actuals) . "'";
421
422
        $this->assertTrue($expectedMatches == $actuals, $message);
423
    }
424
425
    /**
426
     * Use the draft (stage) site for testing.
427
     * This is helpful if you're not testing publication functionality and don't want "stage management" cluttering
428
     * your test.
429
     *
430
     * @deprecated 4.2.0:5.0.0 Use ?stage=Stage querystring arguments instead of useDraftSite
431
     * @param bool $enabled toggle the use of the draft site
432
     */
433
    public function useDraftSite($enabled = true)
434
    {
435
        Deprecation::notice('5.0', 'Use ?stage=Stage querystring arguments instead of useDraftSite');
436
        if ($enabled) {
437
            $this->session()->set('readingMode', 'Stage.Stage');
438
            $this->session()->set('unsecuredDraftSite', true);
439
        } else {
440
            $this->session()->clear('readingMode');
441
            $this->session()->clear('unsecuredDraftSite');
442
        }
443
    }
444
445
    /**
446
     * @return bool
447
     */
448
    public static function get_disable_themes()
449
    {
450
        return static::$disable_themes;
451
    }
452
453
    /**
454
     * @deprecated 4.2.0:5.0.0 Use ?stage=Stage in your querystring arguments instead
455
     * @return bool
456
     */
457
    public static function get_use_draft_site()
458
    {
459
        return static::$use_draft_site;
460
    }
461
}
462