[FEATURE] Load merged JS files asynchronous
[Packages/TYPO3.CMS.git] / typo3 / sysext / core / Tests / Unit / Resource / ResourceCompressorTest.php
1 <?php
2 declare(strict_types = 1);
3 namespace TYPO3\CMS\Core\Tests\Unit\Resource;
4
5 /*
6 * This file is part of the TYPO3 CMS project.
7 *
8 * It is free software; you can redistribute it and/or modify it under
9 * the terms of the GNU General Public License, either version 2
10 * of the License, or any later version.
11 *
12 * For the full copyright and license information, please read the
13 * LICENSE.txt file that was distributed with this source code.
14 *
15 * The TYPO3 project - inspiring people to share!
16 */
17
18 use TYPO3\CMS\Core\Resource\ResourceCompressor;
19 use TYPO3\CMS\Core\Utility\PathUtility;
20
21 /**
22 * Testcase for the ResourceCompressor class
23 */
24 class ResourceCompressorTest extends BaseTestCase
25 {
26 /**
27 * @var ResourceCompressor|\PHPUnit_Framework_MockObject_MockObject|\TYPO3\TestingFramework\Core\AccessibleObjectInterface
28 */
29 protected $subject;
30
31 /**
32 * Set up the test
33 */
34 protected function setUp(): void
35 {
36 parent::setUp();
37 $this->subject = $this->getAccessibleMock(ResourceCompressor::class, ['compressCssFile', 'compressJsFile', 'createMergedCssFile', 'createMergedJsFile', 'getFilenameFromMainDir', 'checkBaseDirectory']);
38 }
39
40 /**
41 * @return array
42 */
43 public function cssFixStatementsDataProvider(): array
44 {
45 return [
46 'nothing to do - no charset/import/namespace' => [
47 'body { background: #ffffff; }',
48 'body { background: #ffffff; }'
49 ],
50 'import in front' => [
51 '@import url(http://www.example.com/css); body { background: #ffffff; }',
52 'LF/* moved by compressor */LF@import url(http://www.example.com/css);LF/* moved by compressor */LFbody { background: #ffffff; }'
53 ],
54 'import in back, without quotes' => [
55 'body { background: #ffffff; } @import url(http://www.example.com/css);',
56 'LF/* moved by compressor */LF@import url(http://www.example.com/css);LF/* moved by compressor */LFbody { background: #ffffff; }'
57 ],
58 'import in back, with double-quotes' => [
59 'body { background: #ffffff; } @import url("http://www.example.com/css");',
60 'LF/* moved by compressor */LF@import url("http://www.example.com/css");LF/* moved by compressor */LFbody { background: #ffffff; }'
61 ],
62 'import in back, with single-quotes' => [
63 'body { background: #ffffff; } @import url(\'http://www.example.com/css\');',
64 'LF/* moved by compressor */LF@import url(\'http://www.example.com/css\');LF/* moved by compressor */LFbody { background: #ffffff; }'
65 ],
66 'import in middle and back, without quotes' => [
67 'body { background: #ffffff; } @import url(http://www.example.com/A); div { background: #000; } @import url(http://www.example.com/B);',
68 'LF/* moved by compressor */LF@import url(http://www.example.com/A);@import url(http://www.example.com/B);LF/* moved by compressor */LFbody { background: #ffffff; } div { background: #000; }'
69 ],
70 'charset declaration is unique' => [
71 'body { background: #ffffff; } @charset "UTF-8"; div { background: #000; }; @charset "UTF-8";',
72 '@charset "UTF-8";LF/* moved by compressor */LFbody { background: #ffffff; } div { background: #000; };'
73 ],
74 'order of charset, namespace and import is correct' => [
75 'body { background: #ffffff; } @charset "UTF-8"; div { background: #000; }; @import "file2.css"; @namespace url(http://www.w3.org/1999/xhtml);',
76 '@charset "UTF-8";LF/* moved by compressor */LF@namespace url(http://www.w3.org/1999/xhtml);LF/* moved by compressor */LF@import "file2.css";LF/* moved by compressor */LFbody { background: #ffffff; } div { background: #000; };'
77 ],
78 ];
79 }
80
81 /**
82 * @test
83 * @dataProvider cssFixStatementsDataProvider
84 * @param string $input
85 * @param string $expected
86 */
87 public function cssFixStatementsMovesStatementsToTopIfNeeded($input, $expected): void
88 {
89 $result = $this->subject->_call('cssFixStatements', $input);
90 $resultWithReadableLinefeed = str_replace(LF, 'LF', $result);
91 $this->assertEquals($expected, $resultWithReadableLinefeed);
92 }
93
94 /**
95 * @test
96 */
97 public function compressedCssFileIsFlaggedToNotCompressAgain(): void
98 {
99 $fileName = 'fooFile.css';
100 $compressedFileName = $fileName . '.gzip';
101 $testFileFixture = [
102 $fileName => [
103 'file' => $fileName,
104 'compress' => true,
105 ]
106 ];
107 $this->subject->expects($this->once())
108 ->method('compressCssFile')
109 ->with($fileName)
110 ->will($this->returnValue($compressedFileName));
111
112 $result = $this->subject->compressCssFiles($testFileFixture);
113
114 $this->assertArrayHasKey($compressedFileName, $result);
115 $this->assertArrayHasKey('compress', $result[$compressedFileName]);
116 $this->assertFalse($result[$compressedFileName]['compress']);
117 }
118
119 /**
120 * @test
121 */
122 public function compressedJsFileIsFlaggedToNotCompressAgain(): void
123 {
124 $fileName = 'fooFile.js';
125 $compressedFileName = $fileName . '.gzip';
126 $testFileFixture = [
127 $fileName => [
128 'file' => $fileName,
129 'compress' => true,
130 ]
131 ];
132 $this->subject->expects($this->once())
133 ->method('compressJsFile')
134 ->with($fileName)
135 ->will($this->returnValue($compressedFileName));
136
137 $result = $this->subject->compressJsFiles($testFileFixture);
138
139 $this->assertArrayHasKey($compressedFileName, $result);
140 $this->assertArrayHasKey('compress', $result[$compressedFileName]);
141 $this->assertFalse($result[$compressedFileName]['compress']);
142 }
143
144 /**
145 * @test
146 */
147 public function concatenatedCssFileIsFlaggedToNotConcatenateAgain(): void
148 {
149 $fileName = 'fooFile.css';
150 $concatenatedFileName = 'merged_' . $fileName;
151 $testFileFixture = [
152 $fileName => [
153 'file' => $fileName,
154 'excludeFromConcatenation' => false,
155 'media' => 'all',
156 ]
157 ];
158 $this->subject->expects($this->once())
159 ->method('createMergedCssFile')
160 ->will($this->returnValue($concatenatedFileName));
161
162 $result = $this->subject->concatenateCssFiles($testFileFixture);
163
164 $this->assertArrayHasKey($concatenatedFileName, $result);
165 $this->assertArrayHasKey('excludeFromConcatenation', $result[$concatenatedFileName]);
166 $this->assertTrue($result[$concatenatedFileName]['excludeFromConcatenation']);
167 }
168
169 /**
170 * @test
171 */
172 public function concatenatedCssFilesAreSeparatedByMediaType(): void
173 {
174 $allFileName = 'allFile.css';
175 $screenFileName1 = 'screenFile.css';
176 $screenFileName2 = 'screenFile2.css';
177 $testFileFixture = [
178 $allFileName => [
179 'file' => $allFileName,
180 'excludeFromConcatenation' => false,
181 'media' => 'all',
182 ],
183 // use two screen files to check if they are merged into one, even with a different media type
184 $screenFileName1 => [
185 'file' => $screenFileName1,
186 'excludeFromConcatenation' => false,
187 'media' => 'screen',
188 ],
189 $screenFileName2 => [
190 'file' => $screenFileName2,
191 'excludeFromConcatenation' => false,
192 'media' => 'screen',
193 ],
194 ];
195 $this->subject->expects($this->exactly(2))
196 ->method('createMergedCssFile')
197 ->will($this->onConsecutiveCalls(
198 $this->returnValue('merged_' . $allFileName),
199 $this->returnValue('merged_' . $screenFileName1)
200 ));
201
202 $result = $this->subject->concatenateCssFiles($testFileFixture);
203
204 $this->assertEquals([
205 'merged_' . $allFileName,
206 'merged_' . $screenFileName1
207 ], array_keys($result));
208 $this->assertEquals('all', $result['merged_' . $allFileName]['media']);
209 $this->assertEquals('screen', $result['merged_' . $screenFileName1]['media']);
210 }
211
212 /**
213 * @test
214 */
215 public function concatenatedCssFilesObeyForceOnTopOption(): void
216 {
217 $screen1FileName = 'screen1File.css';
218 $screen2FileName = 'screen2File.css';
219 $screen3FileName = 'screen3File.css';
220 $testFileFixture = [
221 $screen1FileName => [
222 'file' => $screen1FileName,
223 'excludeFromConcatenation' => false,
224 'media' => 'screen',
225 ],
226 $screen2FileName => [
227 'file' => $screen2FileName,
228 'excludeFromConcatenation' => false,
229 'media' => 'screen',
230 ],
231 $screen3FileName => [
232 'file' => $screen3FileName,
233 'excludeFromConcatenation' => false,
234 'forceOnTop' => true,
235 'media' => 'screen',
236 ],
237 ];
238 // Replace mocked method getFilenameFromMainDir by passthrough callback
239 $this->subject->expects($this->any())->method('getFilenameFromMainDir')->willReturnArgument(0);
240 $this->subject->expects($this->once())
241 ->method('createMergedCssFile')
242 ->with($this->equalTo([$screen3FileName, $screen1FileName, $screen2FileName]));
243
244 $this->subject->concatenateCssFiles($testFileFixture);
245 }
246
247 /**
248 * @test
249 */
250 public function concatenatedCssFilesObeyExcludeFromConcatenation(): void
251 {
252 $screen1FileName = 'screen1File.css';
253 $screen2FileName = 'screen2File.css';
254 $screen3FileName = 'screen3File.css';
255 $testFileFixture = [
256 $screen1FileName => [
257 'file' => $screen1FileName,
258 'excludeFromConcatenation' => false,
259 'media' => 'screen',
260 ],
261 $screen2FileName => [
262 'file' => $screen2FileName,
263 'excludeFromConcatenation' => true,
264 'media' => 'screen',
265 ],
266 $screen3FileName => [
267 'file' => $screen3FileName,
268 'excludeFromConcatenation' => false,
269 'media' => 'screen',
270 ],
271 ];
272 $this->subject->expects($this->any())->method('getFilenameFromMainDir')->willReturnArgument(0);
273 $this->subject->expects($this->once())
274 ->method('createMergedCssFile')
275 ->with($this->equalTo([$screen1FileName, $screen3FileName]))
276 ->will($this->returnValue('merged_screen'));
277
278 $result = $this->subject->concatenateCssFiles($testFileFixture);
279 $this->assertEquals([
280 $screen2FileName,
281 'merged_screen'
282 ], array_keys($result));
283 $this->assertEquals('screen', $result[$screen2FileName]['media']);
284 $this->assertEquals('screen', $result['merged_screen']['media']);
285 }
286
287 /**
288 * @test
289 */
290 public function concatenateJsFileIsFlaggedToNotConcatenateAgain(): void
291 {
292 $fileName = 'fooFile.js';
293 $concatenatedFileName = 'merged_' . $fileName;
294 $testFileFixture = [
295 $fileName => [
296 'file' => $fileName,
297 'excludeFromConcatenation' => false,
298 'section' => 'top',
299 ]
300 ];
301 $this->subject->expects($this->once())
302 ->method('createMergedJsFile')
303 ->will($this->returnValue($concatenatedFileName));
304
305 $result = $this->subject->concatenateJsFiles($testFileFixture);
306
307 $this->assertArrayHasKey($concatenatedFileName, $result);
308 $this->assertArrayHasKey('excludeFromConcatenation', $result[$concatenatedFileName]);
309 $this->assertTrue($result[$concatenatedFileName]['excludeFromConcatenation']);
310 }
311
312 /**
313 * @return array
314 */
315 public function concatenateJsFileAsyncDataProvider(): array
316 {
317 return [
318 'all files have no async' => [
319 [
320 [
321 'file' => 'file1.js',
322 'excludeFromConcatenation' => false,
323 'section' => 'top',
324 ],
325 [
326 'file' => 'file2.js',
327 'excludeFromConcatenation' => false,
328 'section' => 'top',
329 ],
330 ],
331 false
332 ],
333 'all files have async false' => [
334 [
335 [
336 'file' => 'file1.js',
337 'excludeFromConcatenation' => false,
338 'section' => 'top',
339 'async' => false,
340 ],
341 [
342 'file' => 'file2.js',
343 'excludeFromConcatenation' => false,
344 'section' => 'top',
345 'async' => false,
346 ],
347 ],
348 false
349 ],
350 'all files have async true' => [
351 [
352 [
353 'file' => 'file1.js',
354 'excludeFromConcatenation' => false,
355 'section' => 'top',
356 'async' => true,
357 ],
358 [
359 'file' => 'file2.js',
360 'excludeFromConcatenation' => false,
361 'section' => 'top',
362 'async' => true,
363 ],
364 ],
365 true
366 ],
367 'one file async true and one file async false' => [
368 [
369 [
370 'file' => 'file1.js',
371 'excludeFromConcatenation' => false,
372 'section' => 'top',
373 'async' => true,
374 ],
375 [
376 'file' => 'file2.js',
377 'excludeFromConcatenation' => false,
378 'section' => 'top',
379 'async' => false,
380 ],
381 ],
382 false
383 ],
384 'one file async true and one file async false but is excluded form concatenation' => [
385 [
386 [
387 'file' => 'file1.js',
388 'excludeFromConcatenation' => false,
389 'section' => 'top',
390 'async' => true,
391 ],
392 [
393 'file' => 'file2.js',
394 'excludeFromConcatenation' => true,
395 'section' => 'top',
396 'async' => false,
397 ],
398 ],
399 true
400 ],
401 'one file async false and one file async true but is excluded form concatenation' => [
402 [
403 [
404 'file' => 'file1.js',
405 'excludeFromConcatenation' => false,
406 'section' => 'top',
407 'async' => false,
408 ],
409 [
410 'file' => 'file2.js',
411 'excludeFromConcatenation' => true,
412 'section' => 'top',
413 'async' => true,
414 ],
415 ],
416 false
417 ],
418 ];
419 }
420
421 /**
422 * @test
423 * @dataProvider concatenateJsFileAsyncDataProvider
424 * @param string $input
425 * @param bool $expected
426 */
427 public function concatenateJsFileAddsAsyncPropertyIfAllFilesAreAsync(array $input, bool $expected): void
428 {
429 $concatenatedFileName = 'merged_foo.js';
430 $this->subject->expects($this->once())
431 ->method('createMergedJsFile')
432 ->will($this->returnValue($concatenatedFileName));
433
434 $result = $this->subject->concatenateJsFiles($input);
435
436 $this->assertSame($expected, $result[$concatenatedFileName]['async']);
437 }
438
439 /**
440 * @return array
441 */
442 public function calcStatementsDataProvider(): array
443 {
444 return [
445 'simple calc' => [
446 'calc(100% - 3px)',
447 'calc(100% - 3px)',
448 ],
449 'complex calc with parentheses at the beginning' => [
450 'calc((100%/20) - 2*3px)',
451 'calc((100%/20) - 2*3px)',
452 ],
453 'complex calc with parentheses at the end' => [
454 'calc(100%/20 - 2*3px - (200px + 3%))',
455 'calc(100%/20 - 2*3px - (200px + 3%))',
456 ],
457 'complex calc with many parentheses' => [
458 'calc((100%/20) - (2 * (3px - (200px + 3%))))',
459 'calc((100%/20) - (2 * (3px - (200px + 3%))))',
460 ],
461 ];
462 }
463
464 /**
465 * @test
466 * @dataProvider calcStatementsDataProvider
467 * @param string $input
468 * @param string $expected
469 */
470 public function calcFunctionMustRetainWhitespaces($input, $expected): void
471 {
472 $result = $this->subject->_call('compressCssString', $input);
473 $this->assertSame($expected, trim($result));
474 }
475
476 /**
477 * @return array
478 */
479 public function compressCssFileContentDataProvider(): array
480 {
481 $path = __DIR__ . '/ResourceCompressorTest/Fixtures/';
482 return [
483 // File. Tests:
484 // - Stripped comments and white-space.
485 // - Retain white-space in selectors. (http://drupal.org/node/472820)
486 // - Retain pseudo-selectors. (http://drupal.org/node/460448)
487 0 => [
488 $path . 'css_input_without_import.css',
489 $path . 'css_input_without_import.css.optimized.css'
490 ],
491 // File. Tests:
492 // - Retain comment hacks.
493 2 => [
494 $path . 'comment_hacks.css',
495 $path . 'comment_hacks.css.optimized.css'
496 ], /*
497 // File. Tests:
498 // - Any @charset declaration at the beginning of a file should be
499 // removed without breaking subsequent CSS.*/
500 6 => [
501 $path . 'charset_sameline.css',
502 $path . 'charset.css.optimized.css'
503 ],
504 7 => [
505 $path . 'charset_newline.css',
506 $path . 'charset.css.optimized.css'
507 ],
508 ];
509 }
510
511 /**
512 * Tests optimizing a CSS asset group.
513 *
514 * @test
515 * @dataProvider compressCssFileContentDataProvider
516 * @param string $cssFile
517 * @param string $expected
518 */
519 public function compressCssFileContent($cssFile, $expected): void
520 {
521 $cssContent = file_get_contents($cssFile);
522 $compressedCss = $this->subject->_call('compressCssString', $cssContent);
523 // we have to fix relative paths, if we aren't working on a file in our target directory
524 $relativeFilename = str_replace(PATH_site, '', $cssFile);
525 if (strpos($relativeFilename, $this->subject->_get('targetDirectory')) === false) {
526 $compressedCss = $this->subject->_call('cssFixRelativeUrlPaths', $compressedCss, PathUtility::dirname($relativeFilename) . '/');
527 }
528 $this->assertEquals(file_get_contents($expected), $compressedCss, 'Group of file CSS assets optimized correctly.');
529 }
530 }