Use packageFiles feature to replace special-purpose RL modules
[lhc/web/wiklou.git] / tests / phpunit / includes / resourceloader / ResourceLoaderTest.php
1 <?php
2
3 use Wikimedia\TestingAccessWrapper;
4
5 class ResourceLoaderTest extends ResourceLoaderTestCase {
6
7 protected function setUp() {
8 parent::setUp();
9
10 $this->setMwGlobals( [
11 'wgShowExceptionDetails' => true,
12 ] );
13 }
14
15 /**
16 * Ensure the ResourceLoaderRegisterModules hook is called.
17 *
18 * @covers ResourceLoader::__construct
19 */
20 public function testConstructRegistrationHook() {
21 $resourceLoaderRegisterModulesHook = false;
22
23 $this->setMwGlobals( 'wgHooks', [
24 'ResourceLoaderRegisterModules' => [
25 function ( &$resourceLoader ) use ( &$resourceLoaderRegisterModulesHook ) {
26 $resourceLoaderRegisterModulesHook = true;
27 }
28 ]
29 ] );
30
31 $unused = new ResourceLoader();
32 $this->assertTrue(
33 $resourceLoaderRegisterModulesHook,
34 'Hook ResourceLoaderRegisterModules called'
35 );
36 }
37
38 /**
39 * @covers ResourceLoader::register
40 * @covers ResourceLoader::getModule
41 */
42 public function testRegisterValidObject() {
43 $module = new ResourceLoaderTestModule();
44 $resourceLoader = new EmptyResourceLoader();
45 $resourceLoader->register( 'test', $module );
46 $this->assertEquals( $module, $resourceLoader->getModule( 'test' ) );
47 }
48
49 /**
50 * @covers ResourceLoader::register
51 * @covers ResourceLoader::getModule
52 */
53 public function testRegisterValidArray() {
54 $module = new ResourceLoaderTestModule();
55 $resourceLoader = new EmptyResourceLoader();
56 // Covers case of register() setting $rl->moduleInfos,
57 // but $rl->modules lazy-populated by getModule()
58 $resourceLoader->register( 'test', [ 'object' => $module ] );
59 $this->assertEquals( $module, $resourceLoader->getModule( 'test' ) );
60 }
61
62 /**
63 * @covers ResourceLoader::register
64 */
65 public function testRegisterEmptyString() {
66 $module = new ResourceLoaderTestModule();
67 $resourceLoader = new EmptyResourceLoader();
68 $resourceLoader->register( '', $module );
69 $this->assertEquals( $module, $resourceLoader->getModule( '' ) );
70 }
71
72 /**
73 * @covers ResourceLoader::register
74 */
75 public function testRegisterInvalidName() {
76 $resourceLoader = new EmptyResourceLoader();
77 $this->setExpectedException( MWException::class, "name 'test!invalid' is invalid" );
78 $resourceLoader->register( 'test!invalid', new ResourceLoaderTestModule() );
79 }
80
81 /**
82 * @covers ResourceLoader::register
83 */
84 public function testRegisterInvalidType() {
85 $resourceLoader = new EmptyResourceLoader();
86 $this->setExpectedException( MWException::class, 'ResourceLoader module info type error' );
87 $resourceLoader->register( 'test', new stdClass() );
88 }
89
90 /**
91 * @covers ResourceLoader::register
92 */
93 public function testRegisterDuplicate() {
94 $logger = $this->getMockBuilder( Psr\Log\LoggerInterface::class )->getMock();
95 $logger->expects( $this->once() )
96 ->method( 'warning' );
97 $resourceLoader = new EmptyResourceLoader( null, $logger );
98
99 $module1 = new ResourceLoaderTestModule();
100 $module2 = new ResourceLoaderTestModule();
101 $resourceLoader->register( 'test', $module1 );
102 $resourceLoader->register( 'test', $module2 );
103 $this->assertSame( $module2, $resourceLoader->getModule( 'test' ) );
104 }
105
106 /**
107 * @covers ResourceLoader::getModuleNames
108 */
109 public function testGetModuleNames() {
110 // Use an empty one so that core and extension modules don't get in.
111 $resourceLoader = new EmptyResourceLoader();
112 $resourceLoader->register( 'test.foo', new ResourceLoaderTestModule() );
113 $resourceLoader->register( 'test.bar', new ResourceLoaderTestModule() );
114 $this->assertEquals(
115 [ 'test.foo', 'test.bar' ],
116 $resourceLoader->getModuleNames()
117 );
118 }
119
120 public function provideTestIsFileModule() {
121 $fileModuleObj = $this->getMockBuilder( ResourceLoaderFileModule::class )
122 ->disableOriginalConstructor()
123 ->getMock();
124 return [
125 'object' => [ false,
126 new ResourceLoaderTestModule()
127 ],
128 'FileModule object' => [ false,
129 $fileModuleObj
130 ],
131 'simple empty' => [ true,
132 []
133 ],
134 'simple scripts' => [ true,
135 [ 'scripts' => 'example.js' ]
136 ],
137 'simple scripts, raw and targets' => [ true, [
138 'scripts' => [ 'a.js', 'b.js' ],
139 'raw' => true,
140 'targets' => [ 'desktop', 'mobile' ],
141 ] ],
142 'FileModule' => [ true,
143 [ 'class' => ResourceLoaderFileModule::class, 'scripts' => 'example.js' ]
144 ],
145 'TestModule' => [ false,
146 [ 'class' => ResourceLoaderTestModule::class, 'scripts' => 'example.js' ]
147 ],
148 'SkinModule (FileModule subclass)' => [ true,
149 [ 'class' => ResourceLoaderSkinModule::class, 'scripts' => 'example.js' ]
150 ],
151 'WikiModule' => [ false, [
152 'class' => ResourceLoaderWikiModule::class,
153 'scripts' => [ 'MediaWiki:Example.js' ],
154 ] ],
155 ];
156 }
157
158 /**
159 * @dataProvider provideTestIsFileModule
160 * @covers ResourceLoader::isFileModule
161 */
162 public function testIsFileModule( $expected, $module ) {
163 $rl = TestingAccessWrapper::newFromObject( new EmptyResourceLoader() );
164 $rl->register( 'test', $module );
165 $this->assertSame( $expected, $rl->isFileModule( 'test' ) );
166 }
167
168 /**
169 * @covers ResourceLoader::isFileModule
170 */
171 public function testIsFileModuleUnknown() {
172 $rl = TestingAccessWrapper::newFromObject( new EmptyResourceLoader() );
173 $this->assertSame( false, $rl->isFileModule( 'unknown' ) );
174 }
175
176 /**
177 * @covers ResourceLoader::isModuleRegistered
178 */
179 public function testIsModuleRegistered() {
180 $rl = new EmptyResourceLoader();
181 $rl->register( 'test', new ResourceLoaderTestModule() );
182 $this->assertTrue( $rl->isModuleRegistered( 'test' ) );
183 $this->assertFalse( $rl->isModuleRegistered( 'test.unknown' ) );
184 }
185
186 /**
187 * @covers ResourceLoader::getModule
188 */
189 public function testGetModuleUnknown() {
190 $rl = new EmptyResourceLoader();
191 $this->assertSame( null, $rl->getModule( 'test' ) );
192 }
193
194 /**
195 * @covers ResourceLoader::getModule
196 */
197 public function testGetModuleClass() {
198 $rl = new EmptyResourceLoader();
199 $rl->register( 'test', [ 'class' => ResourceLoaderTestModule::class ] );
200 $this->assertInstanceOf(
201 ResourceLoaderTestModule::class,
202 $rl->getModule( 'test' )
203 );
204 }
205
206 /**
207 * @covers ResourceLoader::getModule
208 */
209 public function testGetModuleFactory() {
210 $factory = function ( array $info ) {
211 $this->assertArrayHasKey( 'kitten', $info );
212 return new ResourceLoaderTestModule( $info );
213 };
214
215 $rl = new EmptyResourceLoader();
216 $rl->register( 'test', [ 'factory' => $factory, 'kitten' => 'little ball of fur' ] );
217 $this->assertInstanceOf(
218 ResourceLoaderTestModule::class,
219 $rl->getModule( 'test' )
220 );
221 }
222
223 /**
224 * @covers ResourceLoader::getModule
225 */
226 public function testGetModuleClassDefault() {
227 $rl = new EmptyResourceLoader();
228 $rl->register( 'test', [] );
229 $this->assertInstanceOf(
230 ResourceLoaderFileModule::class,
231 $rl->getModule( 'test' ),
232 'Array-style module registrations default to FileModule'
233 );
234 }
235
236 /**
237 * @covers ResourceLoader::getLessCompiler
238 */
239 public function testLessImportDirs() {
240 $rl = new EmptyResourceLoader();
241 $lc = $rl->getLessCompiler( [ 'foo' => '2px', 'Foo' => '#eeeeee' ] );
242 $basePath = dirname( dirname( __DIR__ ) ) . '/data/less';
243 $lc->SetImportDirs( [
244 "$basePath/common" => '',
245 ] );
246 $css = $lc->parseFile( "$basePath/module/use-import-dir.less" )->getCss();
247 $this->assertStringEqualsFile( "$basePath/module/styles.css", $css );
248 }
249
250 public static function providePackedModules() {
251 return [
252 [
253 'Example from makePackedModulesString doc comment',
254 [ 'foo.bar', 'foo.baz', 'bar.baz', 'bar.quux' ],
255 'foo.bar,baz|bar.baz,quux',
256 ],
257 [
258 'Example from expandModuleNames doc comment',
259 [ 'jquery.foo', 'jquery.bar', 'jquery.ui.baz', 'jquery.ui.quux' ],
260 'jquery.foo,bar|jquery.ui.baz,quux',
261 ],
262 [
263 'Regression fixed in r87497 (7fee86c38e) with dotless names',
264 [ 'foo', 'bar', 'baz' ],
265 'foo,bar,baz',
266 ],
267 [
268 'Prefixless modules after a prefixed module',
269 [ 'single.module', 'foobar', 'foobaz' ],
270 'single.module|foobar,foobaz',
271 ],
272 [
273 'Ordering',
274 [ 'foo', 'foo.baz', 'baz.quux', 'foo.bar' ],
275 'foo|foo.baz,bar|baz.quux',
276 [ 'foo', 'foo.baz', 'foo.bar', 'baz.quux' ],
277 ]
278 ];
279 }
280
281 /**
282 * @dataProvider providePackedModules
283 * @covers ResourceLoader::makePackedModulesString
284 */
285 public function testMakePackedModulesString( $desc, $modules, $packed ) {
286 $this->assertEquals( $packed, ResourceLoader::makePackedModulesString( $modules ), $desc );
287 }
288
289 /**
290 * @dataProvider providePackedModules
291 * @covers ResourceLoaderContext::expandModuleNames
292 */
293 public function testExpandModuleNames( $desc, $modules, $packed, $unpacked = null ) {
294 $this->assertEquals(
295 $unpacked ?: $modules,
296 ResourceLoaderContext::expandModuleNames( $packed ),
297 $desc
298 );
299 }
300
301 public static function provideAddSource() {
302 return [
303 [ 'foowiki', 'https://example.org/w/load.php', 'foowiki' ],
304 [ 'foowiki', [ 'loadScript' => 'https://example.org/w/load.php' ], 'foowiki' ],
305 [
306 [
307 'foowiki' => 'https://example.org/w/load.php',
308 'bazwiki' => 'https://example.com/w/load.php',
309 ],
310 null,
311 [ 'foowiki', 'bazwiki' ]
312 ]
313 ];
314 }
315
316 /**
317 * @dataProvider provideAddSource
318 * @covers ResourceLoader::addSource
319 * @covers ResourceLoader::getSources
320 */
321 public function testAddSource( $name, $info, $expected ) {
322 $rl = new ResourceLoader;
323 $rl->addSource( $name, $info );
324 if ( is_array( $expected ) ) {
325 foreach ( $expected as $source ) {
326 $this->assertArrayHasKey( $source, $rl->getSources() );
327 }
328 } else {
329 $this->assertArrayHasKey( $expected, $rl->getSources() );
330 }
331 }
332
333 /**
334 * @covers ResourceLoader::addSource
335 */
336 public function testAddSourceDupe() {
337 $rl = new ResourceLoader;
338 $this->setExpectedException(
339 MWException::class, 'ResourceLoader duplicate source addition error'
340 );
341 $rl->addSource( 'foo', 'https://example.org/w/load.php' );
342 $rl->addSource( 'foo', 'https://example.com/w/load.php' );
343 }
344
345 /**
346 * @covers ResourceLoader::addSource
347 */
348 public function testAddSourceInvalid() {
349 $rl = new ResourceLoader;
350 $this->setExpectedException( MWException::class, 'with no "loadScript" key' );
351 $rl->addSource( 'foo', [ 'x' => 'https://example.org/w/load.php' ] );
352 }
353
354 public static function provideLoaderImplement() {
355 return [
356 [ [
357 'title' => 'Implement scripts, styles and messages',
358
359 'name' => 'test.example',
360 'scripts' => 'mw.example();',
361 'styles' => [ 'css' => [ '.mw-example {}' ] ],
362 'messages' => [ 'example' => '' ],
363 'templates' => [],
364
365 'expected' => 'mw.loader.implement( "test.example", function ( $, jQuery, require, module ) {
366 mw.example();
367 }, {
368 "css": [
369 ".mw-example {}"
370 ]
371 }, {
372 "example": ""
373 } );',
374 ] ],
375 [ [
376 'title' => 'Implement scripts',
377
378 'name' => 'test.example',
379 'scripts' => 'mw.example();',
380 'styles' => [],
381
382 'expected' => 'mw.loader.implement( "test.example", function ( $, jQuery, require, module ) {
383 mw.example();
384 } );',
385 ] ],
386 [ [
387 'title' => 'Implement styles',
388
389 'name' => 'test.example',
390 'scripts' => [],
391 'styles' => [ 'css' => [ '.mw-example {}' ] ],
392
393 'expected' => 'mw.loader.implement( "test.example", [], {
394 "css": [
395 ".mw-example {}"
396 ]
397 } );',
398 ] ],
399 [ [
400 'title' => 'Implement scripts and messages',
401
402 'name' => 'test.example',
403 'scripts' => 'mw.example();',
404 'messages' => [ 'example' => '' ],
405
406 'expected' => 'mw.loader.implement( "test.example", function ( $, jQuery, require, module ) {
407 mw.example();
408 }, {}, {
409 "example": ""
410 } );',
411 ] ],
412 [ [
413 'title' => 'Implement scripts and templates',
414
415 'name' => 'test.example',
416 'scripts' => 'mw.example();',
417 'templates' => [ 'example.html' => '' ],
418
419 'expected' => 'mw.loader.implement( "test.example", function ( $, jQuery, require, module ) {
420 mw.example();
421 }, {}, {}, {
422 "example.html": ""
423 } );',
424 ] ],
425 [ [
426 'title' => 'Implement unwrapped user script',
427
428 'name' => 'user',
429 'scripts' => 'mw.example( 1 );',
430 'wrap' => false,
431
432 'expected' => 'mw.loader.implement( "user", "mw.example( 1 );" );',
433 ] ],
434 [ [
435 'title' => 'Implement multi-file script',
436
437 'name' => 'test.multifile',
438 'scripts' => [
439 'files' => [
440 'one.js' => [
441 'type' => 'script',
442 'content' => 'mw.example( 1 );',
443 ],
444 'two.json' => [
445 'type' => 'data',
446 'content' => [ 'n' => 2 ],
447 ],
448 'three.js' => [
449 'type' => 'script',
450 'content' => 'mw.example( 3 );'
451 ],
452 ],
453 'main' => 'three.js',
454 ],
455
456 'expected' => <<<END
457 mw.loader.implement( "test.multifile", {
458 "main": "three.js",
459 "files": {
460 "one.js": function ( require, module ) {
461 mw.example( 1 );
462 },
463 "two.json": {
464 "n": 2
465 },
466 "three.js": function ( require, module ) {
467 mw.example( 3 );
468 }
469 }
470 } );
471 END
472 ] ],
473 ];
474 }
475
476 /**
477 * @dataProvider provideLoaderImplement
478 * @covers ResourceLoader::makeLoaderImplementScript
479 * @covers ResourceLoader::trimArray
480 */
481 public function testMakeLoaderImplementScript( $case ) {
482 $case += [
483 'wrap' => true,
484 'styles' => [], 'templates' => [], 'messages' => new XmlJsCode( '{}' ), 'packageFiles' => [],
485 ];
486 ResourceLoader::clearCache();
487 $this->setMwGlobals( 'wgResourceLoaderDebug', true );
488
489 $rl = TestingAccessWrapper::newFromClass( ResourceLoader::class );
490 $this->assertEquals(
491 $case['expected'],
492 $rl->makeLoaderImplementScript(
493 $case['name'],
494 ( $case['wrap'] && is_string( $case['scripts'] ) )
495 ? new XmlJsCode( $case['scripts'] )
496 : $case['scripts'],
497 $case['styles'],
498 $case['messages'],
499 $case['templates'],
500 $case['packageFiles']
501 )
502 );
503 }
504
505 /**
506 * @covers ResourceLoader::makeLoaderImplementScript
507 */
508 public function testMakeLoaderImplementScriptInvalid() {
509 $this->setExpectedException( MWException::class, 'Invalid scripts error' );
510 $rl = TestingAccessWrapper::newFromClass( ResourceLoader::class );
511 $rl->makeLoaderImplementScript(
512 'test', // name
513 123, // scripts
514 null, // styles
515 null, // messages
516 null, // templates
517 null // package files
518 );
519 }
520
521 /**
522 * @covers ResourceLoader::makeLoaderRegisterScript
523 */
524 public function testMakeLoaderRegisterScript() {
525 $this->assertEquals(
526 'mw.loader.register( [
527 [
528 "test.name",
529 "1234567"
530 ]
531 ] );',
532 ResourceLoader::makeLoaderRegisterScript( [
533 [ 'test.name', '1234567' ],
534 ] ),
535 'Nested array parameter'
536 );
537
538 $this->assertEquals(
539 'mw.loader.register( [
540 [
541 "test.foo",
542 "100"
543 ],
544 [
545 "test.bar",
546 "200",
547 [
548 "test.unknown"
549 ]
550 ],
551 [
552 "test.baz",
553 "300",
554 [
555 3,
556 0
557 ]
558 ],
559 [
560 "test.quux",
561 "400",
562 [],
563 null,
564 null,
565 "return true;"
566 ]
567 ] );',
568 ResourceLoader::makeLoaderRegisterScript( [
569 [ 'test.foo', '100' , [], null, null ],
570 [ 'test.bar', '200', [ 'test.unknown' ], null ],
571 [ 'test.baz', '300', [ 'test.quux', 'test.foo' ], null ],
572 [ 'test.quux', '400', [], null, null, 'return true;' ],
573 ] ),
574 'Compact dependency indexes'
575 );
576 }
577
578 /**
579 * @covers ResourceLoader::makeLoaderSourcesScript
580 */
581 public function testMakeLoaderSourcesScript() {
582 $this->assertEquals(
583 'mw.loader.addSource( {
584 "local": "/w/load.php"
585 } );',
586 ResourceLoader::makeLoaderSourcesScript( 'local', '/w/load.php' )
587 );
588 $this->assertEquals(
589 'mw.loader.addSource( {
590 "local": "/w/load.php"
591 } );',
592 ResourceLoader::makeLoaderSourcesScript( [ 'local' => '/w/load.php' ] )
593 );
594 $this->assertEquals(
595 'mw.loader.addSource( {
596 "local": "/w/load.php",
597 "example": "https://example.org/w/load.php"
598 } );',
599 ResourceLoader::makeLoaderSourcesScript( [
600 'local' => '/w/load.php',
601 'example' => 'https://example.org/w/load.php'
602 ] )
603 );
604 $this->assertEquals(
605 'mw.loader.addSource( [] );',
606 ResourceLoader::makeLoaderSourcesScript( [] )
607 );
608 }
609
610 private static function fakeSources() {
611 return [
612 'examplewiki' => [
613 'loadScript' => '//example.org/w/load.php',
614 'apiScript' => '//example.org/w/api.php',
615 ],
616 'example2wiki' => [
617 'loadScript' => '//example.com/w/load.php',
618 'apiScript' => '//example.com/w/api.php',
619 ],
620 ];
621 }
622
623 /**
624 * @covers ResourceLoader::getLoadScript
625 */
626 public function testGetLoadScript() {
627 $this->setMwGlobals( 'wgResourceLoaderSources', [] );
628 $rl = new ResourceLoader();
629 $sources = self::fakeSources();
630 $rl->addSource( $sources );
631 foreach ( [ 'examplewiki', 'example2wiki' ] as $name ) {
632 $this->assertEquals( $rl->getLoadScript( $name ), $sources[$name]['loadScript'] );
633 }
634
635 try {
636 $rl->getLoadScript( 'thiswasneverreigstered' );
637 $this->assertTrue( false, 'ResourceLoader::getLoadScript should have thrown an exception' );
638 } catch ( MWException $e ) {
639 $this->assertTrue( true );
640 }
641 }
642
643 protected function getFailFerryMock( $getter = 'getScript' ) {
644 $mock = $this->getMockBuilder( ResourceLoaderTestModule::class )
645 ->setMethods( [ $getter ] )
646 ->getMock();
647 $mock->method( $getter )->will( $this->throwException(
648 new Exception( 'Ferry not found' )
649 ) );
650 return $mock;
651 }
652
653 protected function getSimpleModuleMock( $script = '' ) {
654 $mock = $this->getMockBuilder( ResourceLoaderTestModule::class )
655 ->setMethods( [ 'getScript' ] )
656 ->getMock();
657 $mock->method( 'getScript' )->willReturn( $script );
658 return $mock;
659 }
660
661 protected function getSimpleStyleModuleMock( $styles = '' ) {
662 $mock = $this->getMockBuilder( ResourceLoaderTestModule::class )
663 ->setMethods( [ 'getStyles' ] )
664 ->getMock();
665 $mock->method( 'getStyles' )->willReturn( [ '' => $styles ] );
666 return $mock;
667 }
668
669 /**
670 * @covers ResourceLoader::getCombinedVersion
671 */
672 public function testGetCombinedVersion() {
673 $rl = $this->getMockBuilder( EmptyResourceLoader::class )
674 // Disable log from outputErrorAndLog
675 ->setMethods( [ 'outputErrorAndLog' ] )->getMock();
676 $rl->register( [
677 'foo' => self::getSimpleModuleMock(),
678 'ferry' => self::getFailFerryMock(),
679 'bar' => self::getSimpleModuleMock(),
680 ] );
681 $context = $this->getResourceLoaderContext( [], $rl );
682
683 $this->assertEquals(
684 '',
685 $rl->getCombinedVersion( $context, [] ),
686 'empty list'
687 );
688
689 $this->assertEquals(
690 ResourceLoader::makeHash( self::BLANK_VERSION ),
691 $rl->getCombinedVersion( $context, [ 'foo' ] ),
692 'compute foo'
693 );
694
695 // Verify that getCombinedVersion() does not throw when ferry fails.
696 // Instead it gracefully continues to combine the remaining modules.
697 $this->assertEquals(
698 ResourceLoader::makeHash( self::BLANK_VERSION . self::BLANK_VERSION ),
699 $rl->getCombinedVersion( $context, [ 'foo', 'ferry', 'bar' ] ),
700 'compute foo+ferry+bar (T152266)'
701 );
702 }
703
704 public static function provideMakeModuleResponseConcat() {
705 $testcases = [
706 [
707 'modules' => [
708 'foo' => 'foo()',
709 ],
710 'expected' => "foo()\n" . 'mw.loader.state( {
711 "foo": "ready"
712 } );',
713 'minified' => "foo()\n" . 'mw.loader.state({"foo":"ready"});',
714 'message' => 'Script without semi-colon',
715 ],
716 [
717 'modules' => [
718 'foo' => 'foo()',
719 'bar' => 'bar()',
720 ],
721 'expected' => "foo()\nbar()\n" . 'mw.loader.state( {
722 "foo": "ready",
723 "bar": "ready"
724 } );',
725 'minified' => "foo()\nbar()\n" . 'mw.loader.state({"foo":"ready","bar":"ready"});',
726 'message' => 'Two scripts without semi-colon',
727 ],
728 [
729 'modules' => [
730 'foo' => "foo()\n// bar();"
731 ],
732 'expected' => "foo()\n// bar();\n" . 'mw.loader.state( {
733 "foo": "ready"
734 } );',
735 'minified' => "foo()\n" . 'mw.loader.state({"foo":"ready"});',
736 'message' => 'Script with semi-colon in comment (T162719)',
737 ],
738 ];
739 $ret = [];
740 foreach ( $testcases as $i => $case ) {
741 $ret["#$i"] = [
742 $case['modules'],
743 $case['expected'],
744 true, // debug
745 $case['message'],
746 ];
747 $ret["#$i (minified)"] = [
748 $case['modules'],
749 $case['minified'],
750 false, // debug
751 $case['message'],
752 ];
753 }
754 return $ret;
755 }
756
757 /**
758 * Verify how multiple scripts and mw.loader.state() calls are concatenated.
759 *
760 * @dataProvider provideMakeModuleResponseConcat
761 * @covers ResourceLoader::makeModuleResponse
762 */
763 public function testMakeModuleResponseConcat( $scripts, $expected, $debug, $message = null ) {
764 $rl = new EmptyResourceLoader();
765 $modules = array_map( function ( $script ) {
766 return self::getSimpleModuleMock( $script );
767 }, $scripts );
768 $rl->register( $modules );
769
770 $this->setMwGlobals( 'wgResourceLoaderDebug', $debug );
771 $context = $this->getResourceLoaderContext(
772 [
773 'modules' => implode( '|', array_keys( $modules ) ),
774 'only' => 'scripts',
775 ],
776 $rl
777 );
778
779 $response = $rl->makeModuleResponse( $context, $modules );
780 $this->assertSame( [], $rl->getErrors(), 'Errors' );
781 $this->assertEquals( $expected, $response, $message ?: 'Response' );
782 }
783
784 /**
785 * @covers ResourceLoader::makeModuleResponse
786 */
787 public function testMakeModuleResponseEmpty() {
788 $rl = new EmptyResourceLoader();
789 $context = $this->getResourceLoaderContext(
790 [ 'modules' => '', 'only' => 'scripts' ],
791 $rl
792 );
793
794 $response = $rl->makeModuleResponse( $context, [] );
795 $this->assertSame( [], $rl->getErrors(), 'Errors' );
796 $this->assertRegExp( '/^\/\*.+no modules were requested.+\*\/$/ms', $response );
797 }
798
799 /**
800 * Verify that when building module content in a load.php response,
801 * an exception from one module will not break script output from
802 * other modules.
803 *
804 * @covers ResourceLoader::makeModuleResponse
805 */
806 public function testMakeModuleResponseError() {
807 $modules = [
808 'foo' => self::getSimpleModuleMock( 'foo();' ),
809 'ferry' => self::getFailFerryMock(),
810 'bar' => self::getSimpleModuleMock( 'bar();' ),
811 ];
812 $rl = new EmptyResourceLoader();
813 $rl->register( $modules );
814 $context = $this->getResourceLoaderContext(
815 [
816 'modules' => 'foo|ferry|bar',
817 'only' => 'scripts',
818 ],
819 $rl
820 );
821
822 // Disable log from makeModuleResponse via outputErrorAndLog
823 $this->setLogger( 'exception', new Psr\Log\NullLogger() );
824
825 $response = $rl->makeModuleResponse( $context, $modules );
826 $errors = $rl->getErrors();
827
828 $this->assertCount( 1, $errors );
829 $this->assertRegExp( '/Ferry not found/', $errors[0] );
830 $this->assertEquals(
831 "foo();\nbar();\n" . 'mw.loader.state( {
832 "ferry": "error",
833 "foo": "ready",
834 "bar": "ready"
835 } );',
836 $response
837 );
838 }
839
840 /**
841 * Verify that exceptions in PHP for one module will not break others
842 * (stylesheet response).
843 *
844 * @covers ResourceLoader::makeModuleResponse
845 */
846 public function testMakeModuleResponseErrorCSS() {
847 $modules = [
848 'foo' => self::getSimpleStyleModuleMock( '.foo{}' ),
849 'ferry' => self::getFailFerryMock( 'getStyles' ),
850 'bar' => self::getSimpleStyleModuleMock( '.bar{}' ),
851 ];
852 $rl = new EmptyResourceLoader();
853 $rl->register( $modules );
854 $context = $this->getResourceLoaderContext(
855 [
856 'modules' => 'foo|ferry|bar',
857 'only' => 'styles',
858 'debug' => 'false',
859 ],
860 $rl
861 );
862
863 // Disable log from makeModuleResponse via outputErrorAndLog
864 $this->setLogger( 'exception', new Psr\Log\NullLogger() );
865
866 $response = $rl->makeModuleResponse( $context, $modules );
867 $errors = $rl->getErrors();
868
869 $this->assertCount( 2, $errors );
870 $this->assertRegExp( '/Ferry not found/', $errors[0] );
871 $this->assertRegExp( '/Problem.+"ferry":\s*"error"/ms', $errors[1] );
872 $this->assertEquals(
873 '.foo{}.bar{}',
874 $response
875 );
876 }
877
878 /**
879 * Verify that when building the startup module response,
880 * an exception from one module class will not break the entire
881 * startup module response. See T152266.
882 *
883 * @covers ResourceLoader::makeModuleResponse
884 */
885 public function testMakeModuleResponseStartupError() {
886 $rl = new EmptyResourceLoader();
887 $rl->register( [
888 'foo' => self::getSimpleModuleMock( 'foo();' ),
889 'ferry' => self::getFailFerryMock(),
890 'bar' => self::getSimpleModuleMock( 'bar();' ),
891 'startup' => [ 'class' => ResourceLoaderStartUpModule::class ],
892 ] );
893 $context = $this->getResourceLoaderContext(
894 [
895 'modules' => 'startup',
896 'only' => 'scripts',
897 ],
898 $rl
899 );
900
901 $this->assertEquals(
902 [ 'foo', 'ferry', 'bar', 'startup' ],
903 $rl->getModuleNames(),
904 'getModuleNames'
905 );
906
907 // Disable log from makeModuleResponse via outputErrorAndLog
908 $this->setLogger( 'exception', new Psr\Log\NullLogger() );
909
910 $modules = [ 'startup' => $rl->getModule( 'startup' ) ];
911 $response = $rl->makeModuleResponse( $context, $modules );
912 $errors = $rl->getErrors();
913
914 $this->assertRegExp( '/Ferry not found/', $errors[0] );
915 $this->assertCount( 1, $errors );
916 $this->assertRegExp(
917 '/isCompatible.*window\.RLQ/s',
918 $response,
919 'startup response undisrupted (T152266)'
920 );
921 $this->assertRegExp(
922 '/register\([^)]+"ferry",\s*""/s',
923 $response,
924 'startup response registers broken module'
925 );
926 $this->assertRegExp(
927 '/state\([^)]+"ferry":\s*"error"/s',
928 $response,
929 'startup response sets state to error'
930 );
931 }
932
933 /**
934 * Integration test for modules sending extra HTTP response headers.
935 *
936 * @covers ResourceLoaderModule::getHeaders
937 * @covers ResourceLoaderModule::buildContent
938 * @covers ResourceLoader::makeModuleResponse
939 */
940 public function testMakeModuleResponseExtraHeaders() {
941 $module = $this->getMockBuilder( ResourceLoaderTestModule::class )
942 ->setMethods( [ 'getPreloadLinks' ] )->getMock();
943 $module->method( 'getPreloadLinks' )->willReturn( [
944 'https://example.org/script.js' => [ 'as' => 'script' ],
945 ] );
946
947 $rl = new EmptyResourceLoader();
948 $rl->register( [
949 'foo' => $module,
950 ] );
951 $context = $this->getResourceLoaderContext(
952 [ 'modules' => 'foo', 'only' => 'scripts' ],
953 $rl
954 );
955
956 $modules = [ 'foo' => $rl->getModule( 'foo' ) ];
957 $response = $rl->makeModuleResponse( $context, $modules );
958 $extraHeaders = TestingAccessWrapper::newFromObject( $rl )->extraHeaders;
959
960 $this->assertEquals(
961 [
962 'Link: <https://example.org/script.js>;rel=preload;as=script'
963 ],
964 $extraHeaders,
965 'Extra headers'
966 );
967 }
968
969 /**
970 * @covers ResourceLoaderModule::getHeaders
971 * @covers ResourceLoaderModule::buildContent
972 * @covers ResourceLoader::makeModuleResponse
973 */
974 public function testMakeModuleResponseExtraHeadersMulti() {
975 $foo = $this->getMockBuilder( ResourceLoaderTestModule::class )
976 ->setMethods( [ 'getPreloadLinks' ] )->getMock();
977 $foo->method( 'getPreloadLinks' )->willReturn( [
978 'https://example.org/script.js' => [ 'as' => 'script' ],
979 ] );
980
981 $bar = $this->getMockBuilder( ResourceLoaderTestModule::class )
982 ->setMethods( [ 'getPreloadLinks' ] )->getMock();
983 $bar->method( 'getPreloadLinks' )->willReturn( [
984 '/example.png' => [ 'as' => 'image' ],
985 '/example.jpg' => [ 'as' => 'image' ],
986 ] );
987
988 $rl = new EmptyResourceLoader();
989 $rl->register( [ 'foo' => $foo, 'bar' => $bar ] );
990 $context = $this->getResourceLoaderContext(
991 [ 'modules' => 'foo|bar', 'only' => 'scripts' ],
992 $rl
993 );
994
995 $modules = [ 'foo' => $rl->getModule( 'foo' ), 'bar' => $rl->getModule( 'bar' ) ];
996 $response = $rl->makeModuleResponse( $context, $modules );
997 $extraHeaders = TestingAccessWrapper::newFromObject( $rl )->extraHeaders;
998 $this->assertEquals(
999 [
1000 'Link: <https://example.org/script.js>;rel=preload;as=script',
1001 'Link: </example.png>;rel=preload;as=image,</example.jpg>;rel=preload;as=image'
1002 ],
1003 $extraHeaders,
1004 'Extra headers'
1005 );
1006 }
1007
1008 /**
1009 * @covers ResourceLoader::respond
1010 */
1011 public function testRespondEmpty() {
1012 $rl = $this->getMockBuilder( EmptyResourceLoader::class )
1013 ->setMethods( [
1014 'tryRespondNotModified',
1015 'sendResponseHeaders',
1016 'measureResponseTime',
1017 ] )
1018 ->getMock();
1019 $context = $this->getResourceLoaderContext( [ 'modules' => '' ], $rl );
1020
1021 $rl->expects( $this->once() )->method( 'measureResponseTime' );
1022 $this->expectOutputRegex( '/no modules were requested/' );
1023
1024 $rl->respond( $context );
1025 }
1026
1027 /**
1028 * @covers ResourceLoader::respond
1029 */
1030 public function testRespondSimple() {
1031 $module = new ResourceLoaderTestModule( [ 'script' => 'foo();' ] );
1032 $rl = $this->getMockBuilder( EmptyResourceLoader::class )
1033 ->setMethods( [
1034 'measureResponseTime',
1035 'tryRespondNotModified',
1036 'sendResponseHeaders',
1037 'makeModuleResponse',
1038 ] )
1039 ->getMock();
1040 $rl->register( 'test', $module );
1041 $context = $this->getResourceLoaderContext(
1042 [ 'modules' => 'test', 'only' => null ],
1043 $rl
1044 );
1045
1046 $rl->expects( $this->once() )->method( 'makeModuleResponse' )
1047 ->with( $context, [ 'test' => $module ] )
1048 ->willReturn( 'implement_foo;' );
1049 $this->expectOutputRegex( '/^implement_foo;/' );
1050
1051 $rl->respond( $context );
1052 }
1053
1054 /**
1055 * @covers ResourceLoader::respond
1056 */
1057 public function testRespondInternalFailures() {
1058 $module = new ResourceLoaderTestModule( [ 'script' => 'foo();' ] );
1059 $rl = $this->getMockBuilder( EmptyResourceLoader::class )
1060 ->setMethods( [
1061 'measureResponseTime',
1062 'preloadModuleInfo',
1063 'getCombinedVersion',
1064 'tryRespondNotModified',
1065 'makeModuleResponse',
1066 'sendResponseHeaders',
1067 ] )
1068 ->getMock();
1069 $rl->register( 'test', $module );
1070 $context = $this->getResourceLoaderContext( [ 'modules' => 'test' ], $rl );
1071 // Disable logging from outputErrorAndLog
1072 $this->setLogger( 'exception', new Psr\Log\NullLogger() );
1073
1074 $rl->expects( $this->once() )->method( 'preloadModuleInfo' )
1075 ->willThrowException( new Exception( 'Preload error' ) );
1076 $rl->expects( $this->once() )->method( 'getCombinedVersion' )
1077 ->willThrowException( new Exception( 'Version error' ) );
1078 $rl->expects( $this->once() )->method( 'makeModuleResponse' )
1079 ->with( $context, [ 'test' => $module ] )
1080 ->willReturn( 'foo;' );
1081 // Internal errors should be caught and logged without affecting module output
1082 $this->expectOutputRegex( '/^\/\*.+Preload error.+Version error.+\*\/.*foo;/ms' );
1083
1084 $rl->respond( $context );
1085 }
1086
1087 /**
1088 * @covers ResourceLoader::measureResponseTime
1089 */
1090 public function testMeasureResponseTime() {
1091 $stats = $this->getMockBuilder( NullStatsdDataFactory::class )
1092 ->setMethods( [ 'timing' ] )->getMock();
1093 $this->setService( 'StatsdDataFactory', $stats );
1094
1095 $stats->expects( $this->once() )->method( 'timing' )
1096 ->with( 'resourceloader.responseTime', $this->anything() );
1097
1098 $timing = new Timing();
1099 $timing->mark( 'requestShutdown' );
1100 $rl = TestingAccessWrapper::newFromObject( new EmptyResourceLoader );
1101 $rl->measureResponseTime( $timing );
1102 DeferredUpdates::doUpdates();
1103 }
1104 }