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