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