[BUGFIX] Repair relative path resolvement in ResourceCompressor
[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\Core\Environment;
19 use TYPO3\CMS\Core\Resource\ResourceCompressor;
20 use TYPO3\CMS\Core\Utility\PathUtility;
21
22 /**
23 * Testcase for the ResourceCompressor class
24 */
25 class ResourceCompressorTest extends BaseTestCase
26 {
27 /**
28 * Restore Environment after the test
29 * @var bool
30 */
31 protected $backupEnvironment = true;
32
33 /**
34 * @var ResourceCompressor|\PHPUnit_Framework_MockObject_MockObject|\TYPO3\TestingFramework\Core\AccessibleObjectInterface
35 */
36 protected $subject;
37
38 /**
39 * Set up the test
40 */
41 protected function setUp(): void
42 {
43 parent::setUp();
44 $this->subject = $this->getAccessibleMock(ResourceCompressor::class, ['compressCssFile', 'compressJsFile', 'createMergedCssFile', 'createMergedJsFile', 'getFilenameFromMainDir', 'checkBaseDirectory']);
45 }
46
47 /**
48 * @return array
49 */
50 public function cssFixStatementsDataProvider(): array
51 {
52 return [
53 'nothing to do - no charset/import/namespace' => [
54 'body { background: #ffffff; }',
55 'body { background: #ffffff; }'
56 ],
57 'import in front' => [
58 '@import url(http://www.example.com/css); body { background: #ffffff; }',
59 'LF/* moved by compressor */LF@import url(http://www.example.com/css);LF/* moved by compressor */LFbody { background: #ffffff; }'
60 ],
61 'import in back, without quotes' => [
62 'body { background: #ffffff; } @import url(http://www.example.com/css);',
63 'LF/* moved by compressor */LF@import url(http://www.example.com/css);LF/* moved by compressor */LFbody { background: #ffffff; }'
64 ],
65 'import in back, with double-quotes' => [
66 'body { background: #ffffff; } @import url("http://www.example.com/css");',
67 'LF/* moved by compressor */LF@import url("http://www.example.com/css");LF/* moved by compressor */LFbody { background: #ffffff; }'
68 ],
69 'import in back, with single-quotes' => [
70 'body { background: #ffffff; } @import url(\'http://www.example.com/css\');',
71 'LF/* moved by compressor */LF@import url(\'http://www.example.com/css\');LF/* moved by compressor */LFbody { background: #ffffff; }'
72 ],
73 'import in middle and back, without quotes' => [
74 'body { background: #ffffff; } @import url(http://www.example.com/A); div { background: #000; } @import url(http://www.example.com/B);',
75 '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; }'
76 ],
77 'charset declaration is unique' => [
78 'body { background: #ffffff; } @charset "UTF-8"; div { background: #000; }; @charset "UTF-8";',
79 '@charset "UTF-8";LF/* moved by compressor */LFbody { background: #ffffff; } div { background: #000; };'
80 ],
81 'order of charset, namespace and import is correct' => [
82 'body { background: #ffffff; } @charset "UTF-8"; div { background: #000; }; @import "file2.css"; @namespace url(http://www.w3.org/1999/xhtml);',
83 '@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; };'
84 ],
85 ];
86 }
87
88 /**
89 * @test
90 * @dataProvider cssFixStatementsDataProvider
91 * @param string $input
92 * @param string $expected
93 */
94 public function cssFixStatementsMovesStatementsToTopIfNeeded($input, $expected): void
95 {
96 $result = $this->subject->_call('cssFixStatements', $input);
97 $resultWithReadableLinefeed = str_replace(LF, 'LF', $result);
98 $this->assertEquals($expected, $resultWithReadableLinefeed);
99 }
100
101 /**
102 * @test
103 */
104 public function compressedCssFileIsFlaggedToNotCompressAgain(): void
105 {
106 $fileName = 'fooFile.css';
107 $compressedFileName = $fileName . '.gzip';
108 $testFileFixture = [
109 $fileName => [
110 'file' => $fileName,
111 'compress' => true,
112 ]
113 ];
114 $this->subject->expects($this->once())
115 ->method('compressCssFile')
116 ->with($fileName)
117 ->will($this->returnValue($compressedFileName));
118
119 $result = $this->subject->compressCssFiles($testFileFixture);
120
121 $this->assertArrayHasKey($compressedFileName, $result);
122 $this->assertArrayHasKey('compress', $result[$compressedFileName]);
123 $this->assertFalse($result[$compressedFileName]['compress']);
124 }
125
126 /**
127 * @test
128 */
129 public function compressedJsFileIsFlaggedToNotCompressAgain(): void
130 {
131 $fileName = 'fooFile.js';
132 $compressedFileName = $fileName . '.gzip';
133 $testFileFixture = [
134 $fileName => [
135 'file' => $fileName,
136 'compress' => true,
137 ]
138 ];
139 $this->subject->expects($this->once())
140 ->method('compressJsFile')
141 ->with($fileName)
142 ->will($this->returnValue($compressedFileName));
143
144 $result = $this->subject->compressJsFiles($testFileFixture);
145
146 $this->assertArrayHasKey($compressedFileName, $result);
147 $this->assertArrayHasKey('compress', $result[$compressedFileName]);
148 $this->assertFalse($result[$compressedFileName]['compress']);
149 }
150
151 /**
152 * @test
153 */
154 public function concatenatedCssFileIsFlaggedToNotConcatenateAgain(): void
155 {
156 $fileName = 'fooFile.css';
157 $concatenatedFileName = 'merged_' . $fileName;
158 $testFileFixture = [
159 $fileName => [
160 'file' => $fileName,
161 'excludeFromConcatenation' => false,
162 'media' => 'all',
163 ]
164 ];
165 $this->subject->expects($this->once())
166 ->method('createMergedCssFile')
167 ->will($this->returnValue($concatenatedFileName));
168
169 $result = $this->subject->concatenateCssFiles($testFileFixture);
170
171 $this->assertArrayHasKey($concatenatedFileName, $result);
172 $this->assertArrayHasKey('excludeFromConcatenation', $result[$concatenatedFileName]);
173 $this->assertTrue($result[$concatenatedFileName]['excludeFromConcatenation']);
174 }
175
176 /**
177 * @test
178 */
179 public function concatenatedCssFilesAreSeparatedByMediaType(): void
180 {
181 $allFileName = 'allFile.css';
182 $screenFileName1 = 'screenFile.css';
183 $screenFileName2 = 'screenFile2.css';
184 $testFileFixture = [
185 $allFileName => [
186 'file' => $allFileName,
187 'excludeFromConcatenation' => false,
188 'media' => 'all',
189 ],
190 // use two screen files to check if they are merged into one, even with a different media type
191 $screenFileName1 => [
192 'file' => $screenFileName1,
193 'excludeFromConcatenation' => false,
194 'media' => 'screen',
195 ],
196 $screenFileName2 => [
197 'file' => $screenFileName2,
198 'excludeFromConcatenation' => false,
199 'media' => 'screen',
200 ],
201 ];
202 $this->subject->expects($this->exactly(2))
203 ->method('createMergedCssFile')
204 ->will($this->onConsecutiveCalls(
205 $this->returnValue('merged_' . $allFileName),
206 $this->returnValue('merged_' . $screenFileName1)
207 ));
208
209 $result = $this->subject->concatenateCssFiles($testFileFixture);
210
211 $this->assertEquals([
212 'merged_' . $allFileName,
213 'merged_' . $screenFileName1
214 ], array_keys($result));
215 $this->assertEquals('all', $result['merged_' . $allFileName]['media']);
216 $this->assertEquals('screen', $result['merged_' . $screenFileName1]['media']);
217 }
218
219 /**
220 * @test
221 */
222 public function concatenatedCssFilesObeyForceOnTopOption(): void
223 {
224 $screen1FileName = 'screen1File.css';
225 $screen2FileName = 'screen2File.css';
226 $screen3FileName = 'screen3File.css';
227 $testFileFixture = [
228 $screen1FileName => [
229 'file' => $screen1FileName,
230 'excludeFromConcatenation' => false,
231 'media' => 'screen',
232 ],
233 $screen2FileName => [
234 'file' => $screen2FileName,
235 'excludeFromConcatenation' => false,
236 'media' => 'screen',
237 ],
238 $screen3FileName => [
239 'file' => $screen3FileName,
240 'excludeFromConcatenation' => false,
241 'forceOnTop' => true,
242 'media' => 'screen',
243 ],
244 ];
245 // Replace mocked method getFilenameFromMainDir by passthrough callback
246 $this->subject->expects($this->any())->method('getFilenameFromMainDir')->willReturnArgument(0);
247 $this->subject->expects($this->once())
248 ->method('createMergedCssFile')
249 ->with($this->equalTo([$screen3FileName, $screen1FileName, $screen2FileName]));
250
251 $this->subject->concatenateCssFiles($testFileFixture);
252 }
253
254 /**
255 * @test
256 */
257 public function concatenatedCssFilesObeyExcludeFromConcatenation(): void
258 {
259 $screen1FileName = 'screen1File.css';
260 $screen2FileName = 'screen2File.css';
261 $screen3FileName = 'screen3File.css';
262 $testFileFixture = [
263 $screen1FileName => [
264 'file' => $screen1FileName,
265 'excludeFromConcatenation' => false,
266 'media' => 'screen',
267 ],
268 $screen2FileName => [
269 'file' => $screen2FileName,
270 'excludeFromConcatenation' => true,
271 'media' => 'screen',
272 ],
273 $screen3FileName => [
274 'file' => $screen3FileName,
275 'excludeFromConcatenation' => false,
276 'media' => 'screen',
277 ],
278 ];
279 $this->subject->expects($this->any())->method('getFilenameFromMainDir')->willReturnArgument(0);
280 $this->subject->expects($this->once())
281 ->method('createMergedCssFile')
282 ->with($this->equalTo([$screen1FileName, $screen3FileName]))
283 ->will($this->returnValue('merged_screen'));
284
285 $result = $this->subject->concatenateCssFiles($testFileFixture);
286 $this->assertEquals([
287 $screen2FileName,
288 'merged_screen'
289 ], array_keys($result));
290 $this->assertEquals('screen', $result[$screen2FileName]['media']);
291 $this->assertEquals('screen', $result['merged_screen']['media']);
292 }
293
294 /**
295 * @test
296 */
297 public function concatenateJsFileIsFlaggedToNotConcatenateAgain(): void
298 {
299 $fileName = 'fooFile.js';
300 $concatenatedFileName = 'merged_' . $fileName;
301 $testFileFixture = [
302 $fileName => [
303 'file' => $fileName,
304 'excludeFromConcatenation' => false,
305 'section' => 'top',
306 ]
307 ];
308 $this->subject->expects($this->once())
309 ->method('createMergedJsFile')
310 ->will($this->returnValue($concatenatedFileName));
311
312 $result = $this->subject->concatenateJsFiles($testFileFixture);
313
314 $this->assertArrayHasKey($concatenatedFileName, $result);
315 $this->assertArrayHasKey('excludeFromConcatenation', $result[$concatenatedFileName]);
316 $this->assertTrue($result[$concatenatedFileName]['excludeFromConcatenation']);
317 }
318
319 /**
320 * @return array
321 */
322 public function concatenateJsFileAsyncDataProvider(): array
323 {
324 return [
325 'all files have no async' => [
326 [
327 [
328 'file' => 'file1.js',
329 'excludeFromConcatenation' => false,
330 'section' => 'top',
331 ],
332 [
333 'file' => 'file2.js',
334 'excludeFromConcatenation' => false,
335 'section' => 'top',
336 ],
337 ],
338 false
339 ],
340 'all files have async false' => [
341 [
342 [
343 'file' => 'file1.js',
344 'excludeFromConcatenation' => false,
345 'section' => 'top',
346 'async' => false,
347 ],
348 [
349 'file' => 'file2.js',
350 'excludeFromConcatenation' => false,
351 'section' => 'top',
352 'async' => false,
353 ],
354 ],
355 false
356 ],
357 'all files have async true' => [
358 [
359 [
360 'file' => 'file1.js',
361 'excludeFromConcatenation' => false,
362 'section' => 'top',
363 'async' => true,
364 ],
365 [
366 'file' => 'file2.js',
367 'excludeFromConcatenation' => false,
368 'section' => 'top',
369 'async' => true,
370 ],
371 ],
372 true
373 ],
374 'one file async true and one file async false' => [
375 [
376 [
377 'file' => 'file1.js',
378 'excludeFromConcatenation' => false,
379 'section' => 'top',
380 'async' => true,
381 ],
382 [
383 'file' => 'file2.js',
384 'excludeFromConcatenation' => false,
385 'section' => 'top',
386 'async' => false,
387 ],
388 ],
389 false
390 ],
391 'one file async true and one file async false but is excluded form concatenation' => [
392 [
393 [
394 'file' => 'file1.js',
395 'excludeFromConcatenation' => false,
396 'section' => 'top',
397 'async' => true,
398 ],
399 [
400 'file' => 'file2.js',
401 'excludeFromConcatenation' => true,
402 'section' => 'top',
403 'async' => false,
404 ],
405 ],
406 true
407 ],
408 'one file async false and one file async true but is excluded form concatenation' => [
409 [
410 [
411 'file' => 'file1.js',
412 'excludeFromConcatenation' => false,
413 'section' => 'top',
414 'async' => false,
415 ],
416 [
417 'file' => 'file2.js',
418 'excludeFromConcatenation' => true,
419 'section' => 'top',
420 'async' => true,
421 ],
422 ],
423 false
424 ],
425 ];
426 }
427
428 /**
429 * @test
430 * @dataProvider concatenateJsFileAsyncDataProvider
431 * @param string $input
432 * @param bool $expected
433 */
434 public function concatenateJsFileAddsAsyncPropertyIfAllFilesAreAsync(array $input, bool $expected): void
435 {
436 $concatenatedFileName = 'merged_foo.js';
437 $this->subject->expects($this->once())
438 ->method('createMergedJsFile')
439 ->will($this->returnValue($concatenatedFileName));
440
441 $result = $this->subject->concatenateJsFiles($input);
442
443 $this->assertSame($expected, $result[$concatenatedFileName]['async']);
444 }
445
446 /**
447 * @return array
448 */
449 public function calcStatementsDataProvider(): array
450 {
451 return [
452 'simple calc' => [
453 'calc(100% - 3px)',
454 'calc(100% - 3px)',
455 ],
456 'complex calc with parentheses at the beginning' => [
457 'calc((100%/20) - 2*3px)',
458 'calc((100%/20) - 2*3px)',
459 ],
460 'complex calc with parentheses at the end' => [
461 'calc(100%/20 - 2*3px - (200px + 3%))',
462 'calc(100%/20 - 2*3px - (200px + 3%))',
463 ],
464 'complex calc with many parentheses' => [
465 'calc((100%/20) - (2 * (3px - (200px + 3%))))',
466 'calc((100%/20) - (2 * (3px - (200px + 3%))))',
467 ],
468 ];
469 }
470
471 /**
472 * @test
473 * @dataProvider calcStatementsDataProvider
474 * @param string $input
475 * @param string $expected
476 */
477 public function calcFunctionMustRetainWhitespaces($input, $expected): void
478 {
479 $result = $this->subject->_call('compressCssString', $input);
480 $this->assertSame($expected, trim($result));
481 }
482
483 /**
484 * @return array
485 */
486 public function compressCssFileContentDataProvider(): array
487 {
488 $path = __DIR__ . '/ResourceCompressorTest/Fixtures/';
489 return [
490 // File. Tests:
491 // - Stripped comments and white-space.
492 // - Retain white-space in selectors. (http://drupal.org/node/472820)
493 // - Retain pseudo-selectors. (http://drupal.org/node/460448)
494 0 => [
495 $path . 'css_input_without_import.css',
496 $path . 'css_input_without_import.css.optimized.css'
497 ],
498 // File. Tests:
499 // - Retain comment hacks.
500 2 => [
501 $path . 'comment_hacks.css',
502 $path . 'comment_hacks.css.optimized.css'
503 ], /*
504 // File. Tests:
505 // - Any @charset declaration at the beginning of a file should be
506 // removed without breaking subsequent CSS.*/
507 6 => [
508 $path . 'charset_sameline.css',
509 $path . 'charset.css.optimized.css'
510 ],
511 7 => [
512 $path . 'charset_newline.css',
513 $path . 'charset.css.optimized.css'
514 ],
515 ];
516 }
517
518 /**
519 * Tests optimizing a CSS asset group.
520 *
521 * @test
522 * @dataProvider compressCssFileContentDataProvider
523 * @param string $cssFile
524 * @param string $expected
525 */
526 public function compressCssFileContent($cssFile, $expected): void
527 {
528 $cssContent = file_get_contents($cssFile);
529 $compressedCss = $this->subject->_call('compressCssString', $cssContent);
530 // we have to fix relative paths, if we aren't working on a file in our target directory
531 $relativeFilename = str_replace(Environment::getPublicPath() . '/', '', $cssFile);
532 if (strpos($relativeFilename, $this->subject->_get('targetDirectory')) === false) {
533 $compressedCss = $this->subject->_call('cssFixRelativeUrlPaths', $compressedCss, PathUtility::dirname($relativeFilename) . '/');
534 }
535 $this->assertEquals(file_get_contents($expected), $compressedCss, 'Group of file CSS assets optimized correctly.');
536 }
537
538 /**
539 * @return array
540 */
541 public function getVariousFilenamesFromMainDirInBackendContextDataProvider(): array
542 {
543 return [
544 // Get filename using EXT:
545 [
546 'EXT:core/Tests/Unit/Resource/ResourceCompressorTest/Fixtures/charset.css',
547 'sysext/core/Tests/Unit/Resource/ResourceCompressorTest/Fixtures/charset.css'
548 ],
549 // Get filename using relative path
550 [
551 'typo3/sysext/core/Tests/Unit/Resource/ResourceCompressorTest/Fixtures/charset.css',
552 'sysext/core/Tests/Unit/Resource/ResourceCompressorTest/Fixtures/charset.css'
553 ],
554 [
555 'sysext/core/Tests/Unit/Resource/ResourceCompressorTest/Fixtures/charset.css',
556 'sysext/core/Tests/Unit/Resource/ResourceCompressorTest/Fixtures/charset.css'
557 ],
558 [
559 'typo3temp/assets/compressed/.htaccess',
560 '../typo3temp/assets/compressed/.htaccess'
561 ],
562 ];
563 }
564
565 /**
566 * @test
567 * @dataProvider getVariousFilenamesFromMainDirInBackendContextDataProvider
568 * @param string $filename input that will be fired on the extension
569 * @param string $expected
570 */
571 public function getVariousFilenamesFromMainDirInBackendContext(string $filename, string $expected)
572 {
573 $rootPath = \dirname($_SERVER['SCRIPT_NAME']);
574 $this->subject = $this->getAccessibleMock(ResourceCompressor::class, ['dummy']);
575 $this->subject->setRootPath($rootPath . '/');
576
577 $relativeToRootPath = $this->subject->_call('getFilenameFromMainDir', $filename);
578 $this->assertSame($expected, $relativeToRootPath, 'Path to the file relative to the path converted correctly.');
579 }
580
581 /**
582 * @return array
583 */
584 public function getVariousFilenamesFromMainDirInFrontendContextDataProvider(): array
585 {
586 return [
587 // Get filename using EXT:
588 [
589 'EXT:core/Tests/Unit/Resource/ResourceCompressorTest/Fixtures/charset.css',
590 'typo3/sysext/core/Tests/Unit/Resource/ResourceCompressorTest/Fixtures/charset.css'
591 ],
592 // Get filename using relative path
593 [
594 'typo3/sysext/core/Tests/Unit/Resource/ResourceCompressorTest/Fixtures/charset.css',
595 'typo3/sysext/core/Tests/Unit/Resource/ResourceCompressorTest/Fixtures/charset.css'
596 ],
597 [
598 'typo3temp/assets/compressed/.htaccess',
599 'typo3temp/assets/compressed/.htaccess'
600 ],
601 ];
602 }
603
604 /**
605 * @test
606 * @dataProvider getVariousFilenamesFromMainDirInFrontendContextDataProvider
607 * @param string $filename input that will be fired on the extension
608 * @param string $expected
609 */
610 public function getVariousFilenamesFromMainDirInFrontendContext(string $filename, string $expected)
611 {
612 Environment::initialize(
613 Environment::getContext(),
614 true,
615 false,
616 Environment::getProjectPath(),
617 Environment::getPublicPath(),
618 Environment::getVarPath(),
619 Environment::getConfigPath(),
620 PATH_site . 'index.php',
621 TYPO3_OS === 'WIN' ? 'WINDOWS' : 'UNIX'
622 );
623 $_SERVER['ORIG_SCRIPT_NAME'] = '/index.php';
624 $this->subject = $this->getAccessibleMock(ResourceCompressor::class, ['dummy']);
625 $this->subject->setRootPath(PATH_site);
626
627 $relativeToRootPath = $this->subject->_call('getFilenameFromMainDir', $filename);
628 $this->assertSame($expected, $relativeToRootPath, 'Path to the file relative to the path converted correctly.');
629 }
630 }