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