Passed
Pull Request — master (#4)
by
unknown
04:24
created

PerformanceContext::criticalCssShouldExistInHead()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 11
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 7
nc 1
nop 0
dl 0
loc 11
rs 10
c 0
b 0
f 0
1
<?php
2
3
namespace MOrtola\BehatSEOContexts;
4
5
use Behat\Mink\Element\NodeElement;
6
use Behat\Mink\Exception\UnsupportedDriverActionException;
7
use PHPUnit\Framework\Assert;
8
9
class PerformanceContext extends BaseContext
0 ignored issues
show
Bug introduced by
The type MOrtola\BehatSEOContexts\BaseContext was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
10
{
11
    const RESOURCE_TYPES = [
12
        'PNG' => 'png',
13
        'HTML' => 'html',
14
        'JPEG' => 'jpeg',
15
        'GIF' => 'gif',
16
        'ICO' => 'ico',
17
        'JAVASCRIPT' => 'js',
18
        'CSS' => 'css',
19
        'CSS_INLINE_HEAD' => 'css-inline-head',
20
        'CSS_LINK_HEAD' => 'css-link-head',
21
    ];
22
23
    /**
24
     * @param string $host
25
     * @param string $resourceType
26
     *
27
     * @throws UnsupportedDriverActionException
28
     * @throws \Exception
29
     *
30
     * @Then /^browser cache must be enabled for (.+\..+|external|internal) (png|jpeg|gif|ico|js|css) resources$
31
     */
32
    public function browserCacheMustBeEnabledForResources($host, $resourceType)
33
    {
34
        $this->supportsSymfony(false);
35
        switch ($host) {
36
            case 'internal':
37
                $elements = $this->getPageResources($resourceType, true);
38
                break;
39
            case 'external':
40
                $elements = $this->getPageResources($resourceType, false, $host);
0 ignored issues
show
Bug introduced by
$host of type string is incompatible with the type boolean expected by parameter $expected of MOrtola\BehatSEOContexts...ext::getPageResources(). ( Ignorable by Annotation )

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

40
                $elements = $this->getPageResources($resourceType, false, /** @scrutinizer ignore-type */ $host);
Loading history...
41
                break;
42
            default:
43
                $elements = $this->getPageResources($resourceType, false, $host);
44
                break;
45
        }
46
        $this->checkResourceCache($elements[array_rand($elements)], $resourceType);
47
        $this->getSession()->back();
48
    }
49
50
    /**
51
     * @param        $element
52
     * @param string $resourceType
53
     *
54
     * @throws \Exception
55
     */
56
    private function checkResourceCache($element, $resourceType)
57
    {
58
        $elementUrl = $this->getResourceUrl($element, $resourceType);
59
60
        $this->getSession()->visit($elementUrl);
61
62
        $responseHeaders = $this->getSession()->getResponseHeaders();
63
64
        Assert::assertTrue(
65
            isset($responseHeaders['Cache-Control']),
66
            sprintf(
67
                'Browser cache is not enabled for %s resources. Cache-Control HTTP header was not received.',
68
                $resourceType
69
            )
70
        );
71
72
        Assert::assertNotContains(
73
            '-no',
74
            $responseHeaders['Cache-Control'],
75
            sprintf(
76
                'Browser cache is not enabled for %s resources. Cache-Control HTTP header is "no-cache".',
77
                $resourceType
78
            )
79
        );
80
    }
81
82
    /**
83
     * @param string $resourceType
84
     * @param bool   $selfHosted
85
     * @param bool   $expected
86
     *
87
     * @param null   $host
0 ignored issues
show
Documentation Bug introduced by
Are you sure the doc-type for parameter $host is correct as it would always require null to be passed?
Loading history...
88
     *
89
     * @return NodeElement[]
90
     * @throws \Exception
91
     */
92
    private function getPageResources($resourceType, $selfHosted = true, $expected = true, $host = null)
93
    {
94
        switch ($resourceType) {
95
            case self::RESOURCE_TYPES['JPEG']:
96
                $xpath = '//img[contains(@src,".jpeg")]';
97
98
                break;
99
            case self::RESOURCE_TYPES['PNG']:
100
                $xpath = '//img[contains(@src,".png")]';
101
102
                break;
103
            case self::RESOURCE_TYPES['GIF']:
104
                $xpath = '//img[contains(@src,".gif")]';
105
106
                break;
107
            case self::RESOURCE_TYPES['ICO']:
108
                $xpath = '//link[contains(@href,".ico")]';
109
110
                break;
111
            case self::RESOURCE_TYPES['CSS']:
112
                $xpath = '//link[contains(@href,".css")]';
113
114
                break;
115
            case self::RESOURCE_TYPES['JAVASCRIPT']:
116
                $xpath = '//script[contains(@src,".js")]';
117
118
                break;
119
            case self::RESOURCE_TYPES['CSS_INLINE_HEAD']:
120
                $xpath = '//head//style';
121
122
                break;
123
            case self::RESOURCE_TYPES['CSS_LINK_HEAD']:
124
                $xpath = '//head//link[contains(@href,".css")]';
125
126
                break;
127
            default:
128
                throw new \Exception(
129
                    sprintf('TODO: Must implement %s resource type xpath constructor', $resourceType)
130
                );
131
        }
132
133
        if (true === $selfHosted) {
134
            $xpath = preg_replace(
135
                '/\[contains\(@(.*),/',
136
                '[(starts-with(@$1,"' . $this->webUrl . '") or starts-with(@$1,"/")) and contains(@$1,',
137
                $xpath
138
            );
139
        } elseif (false === $selfHosted && $host === 'external') {
0 ignored issues
show
introduced by
The condition $host === 'external' is always false.
Loading history...
140
            $xpath = preg_replace(
141
                '/\[contains\(@(.*),/',
142
                '[not(starts-with(@$1,"' . $this->webUrl . '") or starts-with(@$1,"/")) and contains(@$1,',
143
                $xpath
144
            );
145
        } elseif (null !== $host) {
0 ignored issues
show
introduced by
The condition null !== $host is always false.
Loading history...
146
            $xpath = preg_replace(
147
                '/\[contains\(@(.*),/',
148
                '[(starts-with(@$1,"' . $host . '") or starts-with(@$1,"/")) and contains(@$1,',
149
                $xpath
150
            );
151
        }
152
153
        $elements = $this->getSession()->getPage()->findAll('xpath', $xpath);
154
155
        if (true === $expected) {
156
            Assert::assertNotEmpty(
157
                $elements,
158
                sprintf(
159
                    'No%s %s files are found for %s',
160
                    $selfHosted ? ' self hosted' : '',
161
                    $resourceType,
162
                    $host ?: $this->getCurrentUrl()
0 ignored issues
show
introduced by
$host is of type null, thus it always evaluated to false.
Loading history...
163
                )
164
            );
165
        }
166
167
        return $elements;
168
    }
169
170
    /**
171
     * @param NodeElement $element
172
     * @param string $resourceType
173
     *
174
     * @return string
175
     * @throws \Exception
176
     */
177
    private function getResourceUrl(NodeElement $element, $resourceType)
178
    {
179
        $this->assertResourceTypeIsValid($resourceType);
180
181
        switch ($resourceType) {
182
            case self::RESOURCE_TYPES['PNG']:
183
            case self::RESOURCE_TYPES['JPEG']:
184
            case self::RESOURCE_TYPES['GIF']:
185
            case self::RESOURCE_TYPES['JAVASCRIPT']:
186
                return $element->getAttribute('src');
187
188
                break;
0 ignored issues
show
Unused Code introduced by
break is not strictly necessary here and could be removed.

The break statement is not necessary if it is preceded for example by a return statement:

switch ($x) {
    case 1:
        return 'foo';
        break; // This break is not necessary and can be left off.
}

If you would like to keep this construct to be consistent with other case statements, you can safely mark this issue as a false-positive.

Loading history...
189
            case self::RESOURCE_TYPES['CSS']:
190
            case self::RESOURCE_TYPES['ICO']:
191
                return $element->getAttribute('href');
192
193
                break;
194
            default:
195
                throw new \Exception(
196
                    sprintf('%s resource type url is not implemented', $resourceType)
197
                );
198
        }
199
    }
200
201
    /**
202
     * @param string $resourceType
203
     */
204
    private function assertResourceTypeIsValid($resourceType)
205
    {
206
        if (!in_array($resourceType, self::RESOURCE_TYPES)) {
207
            throw new \InvalidArgumentException(
208
                sprintf(
209
                    '%s resource type is not valid. Allowed types are: %s',
210
                    $resourceType,
211
                    implode(',', self::RESOURCE_TYPES)
212
                )
213
            );
214
        }
215
    }
216
217
    /**
218
     * @throws \Exception
219
     *
220
     * @Then /^js should load (async|defer)$/
221
     */
222
    public function theJavascriptFilesShouldLoadAsync()
223
    {
224
        $scriptElements = $this->getPageResources(self::RESOURCE_TYPES['JAVASCRIPT']);
225
226
        foreach ($scriptElements as $scriptElement) {
227
            Assert::assertTrue(
228
                $scriptElement->hasAttribute('async') || $scriptElement->hasAttribute('defer'),
229
                sprintf(
230
                    'Javascript file %s is render blocking in %s',
231
                    $this->getResourceUrl($scriptElement, self::RESOURCE_TYPES['JAVASCRIPT']),
232
                    $this->getCurrentUrl()
233
                )
234
            );
235
        }
236
    }
237
238
    /**
239
     * @throws \Exception
240
     *
241
     * @Then /^html should be minimized$/
242
     */
243
    public function htmlShouldBeMinimized()
244
    {
245
        $content = $this->getSession()->getPage()->getContent();
246
        $this->assertContentIsMinified($content, self::RESOURCE_TYPES['HTML']);
247
    }
248
249
    /**
250
     * @param string $content
251
     * @param string $resourceType
252
     *
253
     * @throws \Exception
254
     */
255
    private function assertContentIsMinified($content, $resourceType)
256
    {
257
        switch ($resourceType) {
258
            case self::RESOURCE_TYPES['CSS']:
259
                $contentMinified = $this->minimizeCss($content);
260
261
                break;
262
            case self::RESOURCE_TYPES['JAVASCRIPT']:
263
                $contentMinified = $this->minimizeJs($content);
264
265
                break;
266
            case self::RESOURCE_TYPES['HTML']:
267
                $contentMinified = $this->minimizeHtml($content);
268
269
                break;
270
            default:
271
                throw new \Exception(
272
                    sprintf('Resource type "%s" can not be minified', $resourceType)
273
                );
274
        }
275
276
        Assert::assertTrue(
277
            $content == $contentMinified,
278
            sprintf(
279
                'Page %s %s code is not minimized.',
280
                $this->getCurrentUrl(),
281
                $resourceType
282
            )
283
        );
284
    }
285
286
    /**
287
     * @param $css
288
     *
289
     * @return string
290
     */
291
    private function minimizeCss($css)
292
    {
293
        return preg_replace(
294
            [
295
                '#("(?:[^"\\\]++|\\\.)*+"|\'(?:[^\'\\\\]++|\\\.)*+\')|\/\*(?!\!)(?>.*?\*\/)|^\s*|\s*$#s',
296
                '#("(?:[^"\\\]++|\\\.)*+"|\'(?:[^\'\\\\]++|\\\.)*+\'|\/\*(?>.*?\*\/))|\s*+;\s*+(})\s*+|\s*+([*$~^|
297
                ]?+=|[{};,>~+]|\s*+-(?![0-9\.])|!important\b)\s*+|([[(:])\s++|\s++([])])|\s++(:)\s*+(?!(?>[^{}"\']++|
298
                "(?:[^"\\\]++|\\\.)*+"|\'(?:[^\'\\\\]++|\\\.)*+\')*+{)|^\s++|\s++\z|(\s)\s+#si',
299
            ],
300
            ['$1', '$1$2$3$4$5$6$7'],
301
            $css
302
        );
303
    }
304
305
    /**
306
     * @param $js
307
     *
308
     * @return string
309
     */
310
    private function minimizeJs($js)
311
    {
312
        return preg_replace(
313
            [
314
                '#\s*("(?:[^"\\\]++|\\\.)*+"|\'(?:[^\'\\\\]++|\\\.)*+\')\s*|\s*\/\*(?!\!|@cc_on)(?>[\s\S]*?\*\/)\s*|
315
                \s*(?<![\:\=])\/\/.*(?=[\n\r]|$)|^\s*|\s*$#',
316
                '#("(?:[^"\\\]++|\\\.)*+"|\'(?:[^\'\\\\]++|\\\.)*+\'|\/\*(?>.*?\*\/)|\/(?!\/)[^\n\r]*?\/(?=[\s.,;]|
317
                [gimuy]|$))|\s*([!%&*\(\)\-=+\[\]\{\}|;:,.<>?\/])\s*#s',
318
            ],
319
            ['$1', '$1$2'],
320
            $js
321
        );
322
    }
323
324
    /**
325
     * @param $html
326
     *
327
     * @return string
328
     */
329
    private function minimizeHtml($html)
330
    {
331
        return preg_replace_callback(
332
            '#<([^\/\s<>!]+)(?:\s+([^<>]*?)\s*|\s*)(\/?)>#s',
333
            function ($matches) {
334
                $withoutSpaces = preg_replace(
335
                    '#([^\s=]+)(\=([\'"]?)(.*?)\3)?(\s+|$)#s',
336
                    ' $1$2',
337
                    $matches[2]
338
                );
339
340
                return sprintf('<%s%s%s>', $matches[1], $withoutSpaces, $matches[3]);
341
            },
342
            $html
343
        );
344
    }
345
346
    /**
347
     * @param string $resourceType
348
     *
349
     * @throws \Exception
350
     * @throws UnsupportedDriverActionException
351
     *
352
     * @Then /^(css|js) should be minimized$/
353
     */
354
    public function cssOrJavascriptFilesShouldBeMinimized($resourceType)
355
    {
356
        $this->supportsSymfony(false);
357
358
        $elements = $this->getPageResources($resourceType);
359
360
        foreach ($elements as $element) {
361
            $elementUrl = $this->getResourceUrl($element, $resourceType);
362
363
            $this->getSession()->visit($elementUrl);
364
365
            $content = $this->getSession()->getPage()->getContent();
366
            $this->assertContentIsMinified($content, $resourceType);
367
368
            $this->getSession()->back();
369
        }
370
    }
371
372
    /**
373
     * @throws \Exception
374
     *
375
     * @Then css should load deferred
376
     */
377
    public function cssFilesShouldLoadDeferred()
378
    {
379
        $cssElements = $this->getPageResources(
380
            self::RESOURCE_TYPES['CSS_LINK_HEAD'],
381
            true,
382
            false
383
        );
384
385
        Assert::assertEmpty(
386
            $cssElements,
387
            sprintf(
388
                '%s self hosted css files are loading in head in %s',
389
                count($cssElements),
390
                $this->getCurrentUrl()
391
            )
392
        );
393
    }
394
395
    /**
396
     * @throws \Exception
397
     *
398
     * @Then critical css should exist in head
399
     */
400
    public function criticalCssShouldExistInHead()
401
    {
402
        $styleCssElements = $this->getPageResources(
403
            self::RESOURCE_TYPES['CSS_INLINE_HEAD']
404
        );
405
406
        Assert::assertNotEmpty(
407
            $styleCssElements,
408
            sprintf(
409
                'No inline css is loading in head in %s',
410
                $this->getCurrentUrl()
411
            )
412
        );
413
    }
414
}
415