Passed
Push — master ( 3eab15...c13991 )
by Marc
03:43
created

PerformanceContext::checkResourceCache()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 29
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 20
nc 1
nop 2
dl 0
loc 29
rs 9.6
c 0
b 0
f 0
1
<?php
2
3
namespace MOrtola\BehatSEOContexts\Context;
4
5
use Behat\Mink\Element\NodeElement;
6
use Behat\Mink\Exception\UnsupportedDriverActionException;
7
use Behat\Symfony2Extension\Driver\KernelDriver;
8
use PHPUnit\Framework\Assert;
9
10
class PerformanceContext extends BaseContext
11
{
12
    const RES_EXT = [
13
        'PNG' => 'png',
14
        'HTML' => 'html',
15
        'JPEG' => 'jpeg',
16
        'GIF' => 'gif',
17
        'ICO' => 'ico',
18
        'JAVASCRIPT' => 'js',
19
        'CSS' => 'css',
20
        'CSS_INLINE_HEAD' => 'css-inline-head',
21
        'CSS_LINK_HEAD' => 'css-link-head',
22
    ];
23
24
    /**
25
     * @throws \Exception
26
     *
27
     * @Then /^Javascript code should load (async|defer)$/
28
     */
29
    public function javascriptFilesShouldLoadAsync()
30
    {
31
        foreach ($this->getPageResources(self::RES_EXT['JAVASCRIPT']) as $scriptElement) {
32
            Assert::assertTrue(
33
                $scriptElement->hasAttribute('async') || $scriptElement->hasAttribute('defer'),
34
                sprintf(
35
                    'Javascript file %s is render blocking in %s',
36
                    $this->getResourceUrl($scriptElement, self::RES_EXT['JAVASCRIPT']),
37
                    $this->getCurrentUrl()
38
                )
39
            );
40
        }
41
    }
42
43
    /**
44
     * @return NodeElement[]
45
     */
46
    private function getPageResources(string $resourceType, bool $selfHosted = true, string $host = null): array
47
    {
48
        if (!$xpath = $this->getResourceXpath($resourceType)) {
49
            return [];
50
        }
51
52
        if ($selfHosted) {
53
            $xpath = preg_replace(
54
                '/\[contains\(@(.*),/',
55
                sprintf('[(starts-with(@$1,"%s") or starts-with(@$1,"/")) and contains(@$1,', $this->webUrl),
56
                $xpath
57
            );
58
        } elseif ('external' === $host) {
59
            $xpath = preg_replace(
60
                '/\[contains\(@(.*),/',
61
                '[not(starts-with(@$1,"' . $this->webUrl . '") or starts-with(@$1,"/")) and contains(@$1,',
62
                $xpath
63
            );
64
        } elseif (null !== $host) {
65
            $xpath = preg_replace(
66
                '/\[contains\(@(.*),/',
67
                '[(starts-with(@$1,"' . $host . '") or starts-with(@$1,"/")) and contains(@$1,',
68
                $xpath
69
            );
70
        }
71
72
        return $this->getSession()->getPage()->findAll('xpath', $xpath);
73
    }
74
75
    private function getResourceXpath(string $resourceType): string
76
    {
77
        if (in_array($resourceType, [self::RES_EXT['JPEG'], self::RES_EXT['PNG'], self::RES_EXT['GIF']], true)) {
78
            return sprintf('//img[contains(@src,".%s")]', $resourceType);
79
        }
80
        if (in_array($resourceType, [self::RES_EXT['ICO'], self::RES_EXT['CSS']], true)) {
81
            return sprintf('//link[contains(@href,".%s")]', $resourceType);
82
        }
83
        if (self::RES_EXT['JAVASCRIPT'] === $resourceType) {
84
            return '//script[contains(@src,".js")]';
85
        }
86
        if (self::RES_EXT['CSS_INLINE_HEAD'] === $resourceType) {
87
            return '//head//style';
88
        }
89
        if (self::RES_EXT['CSS_LINK_HEAD'] === $resourceType) {
90
            return '//head//link[contains(@href,".css")]';
91
        }
92
93
        return '';
94
    }
95
96
    /**
97
     * @return string
98
     * @throws \Exception
99
     */
100
    private function getResourceUrl(NodeElement $element, string $resourceType): string
101
    {
102
        $this->assertResourceTypeIsValid($resourceType);
103
104
        if (in_array($resourceType, [
105
            self::RES_EXT['PNG'],
106
            self::RES_EXT['JPEG'],
107
            self::RES_EXT['GIF'],
108
            self::RES_EXT['JAVASCRIPT']
109
        ], true)) {
110
            return $element->getAttribute('src');
111
        }
112
113
        if (in_array($resourceType, [self::RES_EXT['CSS'], self::RES_EXT['ICO']], true)) {
114
            return $element->getAttribute('href');
115
        }
116
117
        throw new \Exception(
118
            sprintf('%s resource type url is not implemented', $resourceType)
119
        );
120
    }
121
122
    private function assertResourceTypeIsValid(string $resourceType)
123
    {
124
        if (!in_array($resourceType, self::RES_EXT, true)) {
125
            throw new \InvalidArgumentException(
126
                sprintf(
127
                    '%s resource type is not valid. Allowed types are: %s',
128
                    $resourceType,
129
                    implode(',', self::RES_EXT)
130
                )
131
            );
132
        }
133
    }
134
135
    /**
136
     * @throws \Exception
137
     *
138
     * @Then HTML code should be minified
139
     */
140
    public function htmlShouldBeMinified()
141
    {
142
        $this->assertContentIsMinified(
143
            $this->getSession()->getPage()->getContent(),
144
            $this->minimizeHtml($this->getSession()->getPage()->getContent())
145
        );
146
    }
147
148
    private function assertContentIsMinified(string $content, string $contentMinified)
149
    {
150
        Assert::assertSame(
151
            $content,
152
            $contentMinified,
153
            'Code is not minified.'
154
        );
155
    }
156
157
    private function minimizeCss(string $css): string
158
    {
159
        return preg_replace(
160
            [
161
                '#("(?:[^"\\\]++|\\\.)*+"|\'(?:[^\'\\\\]++|\\\.)*+\')|\/\*(?!\!)(?>.*?\*\/)|^\s*|\s*$#s',
162
                '#("(?:[^"\\\]++|\\\.)*+"|\'(?:[^\'\\\\]++|\\\.)*+\'|\/\*(?>.*?\*\/))|\s*+;\s*+(})\s*+|\s*+([*$~^|
163
                ]?+=|[{};,>~+]|\s*+-(?![0-9\.])|!important\b)\s*+|([[(:])\s++|\s++([])])|\s++(:)\s*+(?!(?>[^{}"\']++|
164
                "(?:[^"\\\]++|\\\.)*+"|\'(?:[^\'\\\\]++|\\\.)*+\')*+{)|^\s++|\s++\z|(\s)\s+#si',
165
            ],
166
            ['$1', '$1$2$3$4$5$6$7'],
167
            $css
168
        );
169
    }
170
171
    private function minimizeJs(string $js): string
172
    {
173
        return preg_replace(
174
            [
175
                '#\s*("(?:[^"\\\]++|\\\.)*+"|\'(?:[^\'\\\\]++|\\\.)*+\')\s*|\s*\/\*(?!\!|@cc_on)(?>[\s\S]*?\*\/)\s*|
176
                \s*(?<![\:\=])\/\/.*(?=[\n\r]|$)|^\s*|\s*$#',
177
                '#("(?:[^"\\\]++|\\\.)*+"|\'(?:[^\'\\\\]++|\\\.)*+\'|\/\*(?>.*?\*\/)|\/(?!\/)[^\n\r]*?\/(?=[\s.,;]|
178
                [gimuy]|$))|\s*([!%&*\(\)\-=+\[\]\{\}|;:,.<>?\/])\s*#s',
179
            ],
180
            ['$1', '$1$2'],
181
            $js
182
        );
183
    }
184
185
    private function minimizeHtml(string $html): string
186
    {
187
        return preg_replace(
188
            '/(?<=>)\s+|\s+(?=<)/',
189
            '',
190
            $html
191
        );
192
    }
193
194
    /**
195
     * @throws \Exception
196
     *
197
     * @Then CSS code should load deferred
198
     */
199
    public function cssFilesShouldLoadDeferred()
200
    {
201
        Assert::assertEmpty(
202
            $this->getPageResources(self::RES_EXT['CSS_LINK_HEAD'], true),
203
            sprintf(
204
                'Some self hosted css files are loading in head in %s',
205
                $this->getCurrentUrl()
206
            )
207
        );
208
    }
209
210
    /**
211
     * @throws \Exception
212
     *
213
     * @Then critical CSS code should exist in head
214
     */
215
    public function criticalCssShouldExistInHead()
216
    {
217
        Assert::assertNotEmpty(
218
            $this->getPageResources(self::RES_EXT['CSS_INLINE_HEAD']),
219
            sprintf(
220
                'No inline css is loading in head in %s',
221
                $this->getCurrentUrl()
222
            )
223
        );
224
    }
225
226
    /**
227
     * @Then HTML code should not be minified
228
     */
229
    public function htmlShouldNotBeMinified()
230
    {
231
        $this->assertInverse(
232
            [$this, 'htmlShouldBeMinified'],
233
            'HTML should not be minified.'
234
        );
235
    }
236
237
    /**
238
     * @Then /^(CSS|Javascript) code should not be minified$/
239
     */
240
    public function cssOrJavascriptFilesShouldNotBeMinified(string $resourceType)
241
    {
242
        $this->assertInverse(
243
            function () use ($resourceType) {
244
                $this->cssOrJavascriptFilesShouldBeMinified($resourceType);
245
            },
246
            sprintf('%s should not be minified.', $resourceType)
247
        );
248
    }
249
250
    /**
251
     * @throws UnsupportedDriverActionException
252
     * @throws \Exception
253
     * @Then /^(CSS|Javascript) code should be minified$/
254
     */
255
    public function cssOrJavascriptFilesShouldBeMinified(string $resourceType)
256
    {
257
        $this->doesNotSupportDriver(KernelDriver::class);
258
259
        $resourceType = 'Javascript' === $resourceType ? 'js' : 'css';
260
261
        foreach ($this->getPageResources($resourceType) as $element) {
262
            $this->getSession()->visit($this->getResourceUrl($element, $resourceType));
263
264
            $this->assertContentIsMinified(
265
                $this->getSession()->getPage()->getContent(),
266
                'js' === $resourceType ?
267
                    $this->minimizeJs($this->getSession()->getPage()->getContent())
268
                    : $this->minimizeCss($this->getSession()->getPage()->getContent())
269
            );
270
271
            $this->getSession()->back();
272
        }
273
    }
274
275
    /**
276
     * @Then critical CSS code should not exist in head
277
     */
278
    public function criticalCssShouldNotExistInHead()
279
    {
280
        $this->assertInverse(
281
            [$this, 'criticalCssShouldExistInHead'],
282
            'Critical CSS exist in head.'
283
        );
284
    }
285
286
    /**
287
     * @Then /^browser cache should not be enabled for (.+|external|internal) (png|jpeg|gif|ico|js|css) resources$/
288
     */
289
    public function browserCacheMustNotBeEnabledForResources(string $host, string $resourceType)
290
    {
291
        $this->assertInverse(
292
            function () use ($host, $resourceType) {
293
                $this->browserCacheMustBeEnabledForResources($host, $resourceType);
294
            },
295
            sprintf('Browser cache is enabled for %s resources.', $resourceType)
296
        );
297
    }
298
299
    /**
300
     * @throws UnsupportedDriverActionException
301
     * @throws \Exception
302
     * @Then /^browser cache should be enabled for (.+|external|internal) (png|jpeg|gif|ico|js|css) resources$/
303
     */
304
    public function browserCacheMustBeEnabledForResources(string $host, string $resourceType)
305
    {
306
        $this->doesNotSupportDriver(KernelDriver::class);
307
308
        switch ($host) {
309
            case 'internal':
310
                $elements = $this->getPageResources($resourceType, true);
311
                break;
312
            default:
313
                $elements = $this->getPageResources($resourceType, false, $host);
314
                break;
315
        }
316
317
        Assert::assertNotEmpty(
318
            $elements,
319
            sprintf(
320
                'The are not %s resources in %s.',
321
                $host,
322
                $this->getCurrentUrl()
323
            )
324
        );
325
326
        $this->checkResourceCache(
327
            $elements[array_rand($elements)],
328
            $resourceType
329
        );
330
    }
331
332
    /**
333
     * @Then /^Javascript code should not load (async|defer)$/
334
     */
335
    public function jsShouldNotLoadAsyncOr()
336
    {
337
        $this->assertInverse(
338
            [$this, 'javascriptFilesShouldLoadAsync'],
339
            'All JS files load async.'
340
        );
341
    }
342
343
    /**
344
     * @throws \Exception
345
     */
346
    private function checkResourceCache(NodeElement $element, string $resourceType)
347
    {
348
        $this->getSession()->visit($this->getResourceUrl($element, $resourceType));
349
        $headers = array_change_key_case($this->getSession()->getResponseHeaders());
350
        $this->getSession()->back();
351
352
        Assert::assertTrue(
353
            array_key_exists('cache-control', $headers),
354
            sprintf(
355
                'Browser cache is not enabled for %s resources. Cache-Control HTTP header was not received.',
356
                $resourceType
357
            )
358
        );
359
360
        Assert::assertNotContains(
361
            'no-cache',
362
            $headers['cache-control'],
363
            sprintf(
364
                'Browser cache is not enabled for %s resources. Cache-Control HTTP header is "no-cache".',
365
                $resourceType
366
            )
367
        );
368
369
        Assert::assertNotContains(
370
            'max-age=0',
371
            $headers['cache-control'],
372
            sprintf(
373
                'Browser cache is not enabled for %s resources. Cache-Control HTTP header is "max-age=0".',
374
                $resourceType
375
            )
376
        );
377
    }
378
}
379