summaryrefslogtreecommitdiff
path: root/www/wiki/tests/phpunit/includes/resourceloader
diff options
context:
space:
mode:
Diffstat (limited to 'www/wiki/tests/phpunit/includes/resourceloader')
-rw-r--r--www/wiki/tests/phpunit/includes/resourceloader/DerivativeResourceLoaderContextTest.php128
-rw-r--r--www/wiki/tests/phpunit/includes/resourceloader/MessageBlobStoreTest.php224
-rw-r--r--www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderClientHtmlTest.php405
-rw-r--r--www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderContextTest.php120
-rw-r--r--www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderFileModuleTest.php353
-rw-r--r--www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderImageModuleTest.php265
-rw-r--r--www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderImageTest.php136
-rw-r--r--www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderModuleTest.php224
-rw-r--r--www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderOOUIImageModuleTest.php65
-rw-r--r--www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderSkinModuleTest.php207
-rw-r--r--www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderStartUpModuleTest.php494
-rw-r--r--www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderTest.php911
-rw-r--r--www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderWikiModuleTest.php380
-rw-r--r--www/wiki/tests/phpunit/includes/resourceloader/templates/template.html1
-rw-r--r--www/wiki/tests/phpunit/includes/resourceloader/templates/template2.html1
-rw-r--r--www/wiki/tests/phpunit/includes/resourceloader/templates/template_awesome.handlebars1
16 files changed, 3915 insertions, 0 deletions
diff --git a/www/wiki/tests/phpunit/includes/resourceloader/DerivativeResourceLoaderContextTest.php b/www/wiki/tests/phpunit/includes/resourceloader/DerivativeResourceLoaderContextTest.php
new file mode 100644
index 00000000..e4f58eb1
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/resourceloader/DerivativeResourceLoaderContextTest.php
@@ -0,0 +1,128 @@
+<?php
+
+/**
+ * @group ResourceLoader
+ * @covers DerivativeResourceLoaderContext
+ */
+class DerivativeResourceLoaderContextTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ protected static function getContext() {
+ $request = new FauxRequest( [
+ 'lang' => 'zh',
+ 'modules' => 'test.context',
+ 'only' => 'scripts',
+ 'skin' => 'fallback',
+ 'target' => 'test',
+ ] );
+ return new ResourceLoaderContext( new ResourceLoader(), $request );
+ }
+
+ public function testGetInherited() {
+ $derived = new DerivativeResourceLoaderContext( self::getContext() );
+
+ // Request parameters
+ $this->assertEquals( $derived->getDebug(), false );
+ $this->assertEquals( $derived->getLanguage(), 'zh' );
+ $this->assertEquals( $derived->getModules(), [ 'test.context' ] );
+ $this->assertEquals( $derived->getOnly(), 'scripts' );
+ $this->assertEquals( $derived->getSkin(), 'fallback' );
+ $this->assertEquals( $derived->getUser(), null );
+
+ // Misc
+ $this->assertEquals( $derived->getDirection(), 'ltr' );
+ $this->assertEquals( $derived->getHash(), 'zh|fallback|||scripts|||||' );
+ }
+
+ public function testModules() {
+ $derived = new DerivativeResourceLoaderContext( self::getContext() );
+
+ $derived->setModules( [ 'test.override' ] );
+ $this->assertEquals( $derived->getModules(), [ 'test.override' ] );
+ }
+
+ public function testLanguage() {
+ $context = self::getContext();
+ $derived = new DerivativeResourceLoaderContext( $context );
+
+ $derived->setLanguage( 'nl' );
+ $this->assertEquals( $derived->getLanguage(), 'nl' );
+ }
+
+ public function testDirection() {
+ $derived = new DerivativeResourceLoaderContext( self::getContext() );
+
+ $derived->setLanguage( 'nl' );
+ $this->assertEquals( $derived->getDirection(), 'ltr' );
+
+ $derived->setLanguage( 'he' );
+ $this->assertEquals( $derived->getDirection(), 'rtl' );
+
+ $derived->setDirection( 'ltr' );
+ $this->assertEquals( $derived->getDirection(), 'ltr' );
+ }
+
+ public function testSkin() {
+ $derived = new DerivativeResourceLoaderContext( self::getContext() );
+
+ $derived->setSkin( 'override' );
+ $this->assertEquals( $derived->getSkin(), 'override' );
+ }
+
+ public function testUser() {
+ $derived = new DerivativeResourceLoaderContext( self::getContext() );
+
+ $derived->setUser( 'Example' );
+ $this->assertEquals( $derived->getUser(), 'Example' );
+ }
+
+ public function testDebug() {
+ $derived = new DerivativeResourceLoaderContext( self::getContext() );
+
+ $derived->setDebug( true );
+ $this->assertEquals( $derived->getDebug(), true );
+ }
+
+ public function testOnly() {
+ $derived = new DerivativeResourceLoaderContext( self::getContext() );
+
+ $derived->setOnly( 'styles' );
+ $this->assertEquals( $derived->getOnly(), 'styles' );
+
+ $derived->setOnly( null );
+ $this->assertEquals( $derived->getOnly(), null );
+ }
+
+ public function testVersion() {
+ $derived = new DerivativeResourceLoaderContext( self::getContext() );
+
+ $derived->setVersion( 'hw1' );
+ $this->assertEquals( $derived->getVersion(), 'hw1' );
+ }
+
+ public function testRaw() {
+ $derived = new DerivativeResourceLoaderContext( self::getContext() );
+
+ $derived->setRaw( true );
+ $this->assertEquals( $derived->getRaw(), true );
+ }
+
+ public function testGetHash() {
+ $derived = new DerivativeResourceLoaderContext( self::getContext() );
+
+ $this->assertEquals( $derived->getHash(), 'zh|fallback|||scripts|||||' );
+
+ $derived->setLanguage( 'nl' );
+ $derived->setUser( 'Example' );
+ // Assert that subclass is able to clear parent class "hash" member
+ $this->assertEquals( $derived->getHash(), 'nl|fallback||Example|scripts|||||' );
+ }
+
+ public function testAccessors() {
+ $context = self::getContext();
+ $derived = new DerivativeResourceLoaderContext( $context );
+ $this->assertSame( $derived->getRequest(), $context->getRequest() );
+ $this->assertSame( $derived->getResourceLoader(), $context->getResourceLoader() );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/resourceloader/MessageBlobStoreTest.php b/www/wiki/tests/phpunit/includes/resourceloader/MessageBlobStoreTest.php
new file mode 100644
index 00000000..7eb09441
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/resourceloader/MessageBlobStoreTest.php
@@ -0,0 +1,224 @@
+<?php
+
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group Cache
+ * @covers MessageBlobStore
+ */
+class MessageBlobStoreTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ protected function setUp() {
+ parent::setUp();
+ // MediaWiki tests defaults $wgMainWANCache to CACHE_NONE.
+ // Use hash instead so that caching is observed
+ $this->wanCache = $this->getMockBuilder( WANObjectCache::class )
+ ->setConstructorArgs( [ [
+ 'cache' => new HashBagOStuff(),
+ 'pool' => 'test',
+ 'relayer' => new EventRelayerNull( [] )
+ ] ] )
+ ->setMethods( [ 'makePurgeValue' ] )
+ ->getMock();
+
+ $this->wanCache->expects( $this->any() )
+ ->method( 'makePurgeValue' )
+ ->will( $this->returnCallback( function ( $timestamp, $holdoff ) {
+ // Disable holdoff as it messes with testing. Aside from a 0-second holdoff,
+ // make sure that "time" passes between getMulti() check init and the set()
+ // in recacheMessageBlob(). This especially matters for Windows clocks.
+ $ts = (float)$timestamp - 0.0001;
+
+ return WANObjectCache::PURGE_VAL_PREFIX . $ts . ':0';
+ } ) );
+ }
+
+ protected function makeBlobStore( $methods = null, $rl = null ) {
+ $blobStore = $this->getMockBuilder( MessageBlobStore::class )
+ ->setConstructorArgs( [ $rl ] )
+ ->setMethods( $methods )
+ ->getMock();
+
+ $access = TestingAccessWrapper::newFromObject( $blobStore );
+ $access->wanCache = $this->wanCache;
+ return $blobStore;
+ }
+
+ protected function makeModule( array $messages ) {
+ $module = new ResourceLoaderTestModule( [ 'messages' => $messages ] );
+ $module->setName( 'test.blobstore' );
+ return $module;
+ }
+
+ /** @covers MessageBlobStore::setLogger */
+ public function testSetLogger() {
+ $blobStore = $this->makeBlobStore();
+ $this->assertSame( null, $blobStore->setLogger( new Psr\Log\NullLogger() ) );
+ }
+
+ /** @covers MessageBlobStore::getResourceLoader */
+ public function testGetResourceLoader() {
+ // Call protected method
+ $blobStore = TestingAccessWrapper::newFromObject( $this->makeBlobStore() );
+ $this->assertInstanceOf(
+ ResourceLoader::class,
+ $blobStore->getResourceLoader()
+ );
+ }
+
+ /** @covers MessageBlobStore::fetchMessage */
+ public function testFetchMessage() {
+ $module = $this->makeModule( [ 'mainpage' ] );
+ $rl = new ResourceLoader();
+ $rl->register( $module->getName(), $module );
+
+ $blobStore = $this->makeBlobStore( null, $rl );
+ $blob = $blobStore->getBlob( $module, 'en' );
+
+ $this->assertEquals( '{"mainpage":"Main Page"}', $blob, 'Generated blob' );
+ }
+
+ /** @covers MessageBlobStore::fetchMessage */
+ public function testFetchMessageFail() {
+ $module = $this->makeModule( [ 'i-dont-exist' ] );
+ $rl = new ResourceLoader();
+ $rl->register( $module->getName(), $module );
+
+ $blobStore = $this->makeBlobStore( null, $rl );
+ $blob = $blobStore->getBlob( $module, 'en' );
+
+ $this->assertEquals( '{"i-dont-exist":"\u29fci-dont-exist\u29fd"}', $blob, 'Generated blob' );
+ }
+
+ public function testGetBlob() {
+ $module = $this->makeModule( [ 'foo' ] );
+ $rl = new ResourceLoader();
+ $rl->register( $module->getName(), $module );
+
+ $blobStore = $this->makeBlobStore( [ 'fetchMessage' ], $rl );
+ $blobStore->expects( $this->once() )
+ ->method( 'fetchMessage' )
+ ->will( $this->returnValue( 'Example' ) );
+
+ $blob = $blobStore->getBlob( $module, 'en' );
+
+ $this->assertEquals( '{"foo":"Example"}', $blob, 'Generated blob' );
+ }
+
+ /**
+ * Seems to fail sometimes (T176097).
+ *
+ * @group Broken
+ */
+ public function testGetBlobCached() {
+ $module = $this->makeModule( [ 'example' ] );
+ $rl = new ResourceLoader();
+ $rl->register( $module->getName(), $module );
+
+ $blobStore = $this->makeBlobStore( [ 'fetchMessage' ], $rl );
+ $blobStore->expects( $this->once() )
+ ->method( 'fetchMessage' )
+ ->will( $this->returnValue( 'First' ) );
+
+ $module = $this->makeModule( [ 'example' ] );
+ $blob = $blobStore->getBlob( $module, 'en' );
+ $this->assertEquals( '{"example":"First"}', $blob, 'Generated blob' );
+
+ $blobStore = $this->makeBlobStore( [ 'fetchMessage' ], $rl );
+ $blobStore->expects( $this->never() )
+ ->method( 'fetchMessage' )
+ ->will( $this->returnValue( 'Second' ) );
+
+ $module = $this->makeModule( [ 'example' ] );
+ $blob = $blobStore->getBlob( $module, 'en' );
+ $this->assertEquals( '{"example":"First"}', $blob, 'Cache hit' );
+ }
+
+ public function testUpdateMessage() {
+ $module = $this->makeModule( [ 'example' ] );
+ $rl = new ResourceLoader();
+ $rl->register( $module->getName(), $module );
+ $blobStore = $this->makeBlobStore( [ 'fetchMessage' ], $rl );
+ $blobStore->expects( $this->once() )
+ ->method( 'fetchMessage' )
+ ->will( $this->returnValue( 'First' ) );
+
+ $blob = $blobStore->getBlob( $module, 'en' );
+ $this->assertEquals( '{"example":"First"}', $blob, 'Generated blob' );
+
+ $blobStore->updateMessage( 'example' );
+
+ $module = $this->makeModule( [ 'example' ] );
+ $rl = new ResourceLoader();
+ $rl->register( $module->getName(), $module );
+ $blobStore = $this->makeBlobStore( [ 'fetchMessage' ], $rl );
+ $blobStore->expects( $this->once() )
+ ->method( 'fetchMessage' )
+ ->will( $this->returnValue( 'Second' ) );
+
+ $blob = $blobStore->getBlob( $module, 'en' );
+ $this->assertEquals( '{"example":"Second"}', $blob, 'Updated blob' );
+ }
+
+ public function testValidation() {
+ $module = $this->makeModule( [ 'foo' ] );
+ $rl = new ResourceLoader();
+ $rl->register( $module->getName(), $module );
+
+ $blobStore = $this->makeBlobStore( [ 'fetchMessage' ], $rl );
+ $blobStore->expects( $this->once() )
+ ->method( 'fetchMessage' )
+ ->will( $this->returnValueMap( [
+ [ 'foo', 'en', 'Hello' ],
+ ] ) );
+
+ $blob = $blobStore->getBlob( $module, 'en' );
+ $this->assertEquals( '{"foo":"Hello"}', $blob, 'Generated blob' );
+
+ // Now, imagine a change to the module is deployed. The module now contains
+ // message 'foo' and 'bar'. While updateMessage() was not called (since no
+ // message values were changed) it should detect the change in list of
+ // message keys.
+ $module = $this->makeModule( [ 'foo', 'bar' ] );
+ $rl = new ResourceLoader();
+ $rl->register( $module->getName(), $module );
+
+ $blobStore = $this->makeBlobStore( [ 'fetchMessage' ], $rl );
+ $blobStore->expects( $this->exactly( 2 ) )
+ ->method( 'fetchMessage' )
+ ->will( $this->returnValueMap( [
+ [ 'foo', 'en', 'Hello' ],
+ [ 'bar', 'en', 'World' ],
+ ] ) );
+
+ $blob = $blobStore->getBlob( $module, 'en' );
+ $this->assertEquals( '{"foo":"Hello","bar":"World"}', $blob, 'Updated blob' );
+ }
+
+ public function testClear() {
+ $module = $this->makeModule( [ 'example' ] );
+ $rl = new ResourceLoader();
+ $rl->register( $module->getName(), $module );
+ $blobStore = $this->makeBlobStore( [ 'fetchMessage' ], $rl );
+ $blobStore->expects( $this->exactly( 2 ) )
+ ->method( 'fetchMessage' )
+ ->will( $this->onConsecutiveCalls( 'First', 'Second' ) );
+
+ $now = microtime( true );
+ $this->wanCache->setMockTime( $now );
+
+ $blob = $blobStore->getBlob( $module, 'en' );
+ $this->assertEquals( '{"example":"First"}', $blob, 'Generated blob' );
+
+ $blob = $blobStore->getBlob( $module, 'en' );
+ $this->assertEquals( '{"example":"First"}', $blob, 'Cache-hit' );
+
+ $now += 1;
+ $blobStore->clear();
+
+ $blob = $blobStore->getBlob( $module, 'en' );
+ $this->assertEquals( '{"example":"Second"}', $blob, 'Updated blob' );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderClientHtmlTest.php b/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderClientHtmlTest.php
new file mode 100644
index 00000000..07956f1d
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderClientHtmlTest.php
@@ -0,0 +1,405 @@
+<?php
+
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group ResourceLoader
+ */
+class ResourceLoaderClientHtmlTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ protected static function expandVariables( $text ) {
+ return strtr( $text, [
+ '{blankVer}' => ResourceLoaderTestCase::BLANK_VERSION
+ ] );
+ }
+
+ protected static function makeContext( $extraQuery = [] ) {
+ $conf = new HashConfig( [
+ 'ResourceLoaderSources' => [],
+ 'ResourceModuleSkinStyles' => [],
+ 'ResourceModules' => [],
+ 'EnableJavaScriptTest' => false,
+ 'ResourceLoaderDebug' => false,
+ 'LoadScript' => '/w/load.php',
+ ] );
+ return new ResourceLoaderContext(
+ new ResourceLoader( $conf ),
+ new FauxRequest( array_merge( [
+ 'lang' => 'nl',
+ 'skin' => 'fallback',
+ 'user' => 'Example',
+ 'target' => 'phpunit',
+ ], $extraQuery ) )
+ );
+ }
+
+ protected static function makeModule( array $options = [] ) {
+ return new ResourceLoaderTestModule( $options );
+ }
+
+ protected static function makeSampleModules() {
+ $modules = [
+ 'test' => [],
+ 'test.private' => [ 'group' => 'private' ],
+ 'test.shouldembed.empty' => [ 'shouldEmbed' => true, 'isKnownEmpty' => true ],
+ 'test.shouldembed' => [ 'shouldEmbed' => true ],
+
+ 'test.styles.pure' => [ 'type' => ResourceLoaderModule::LOAD_STYLES ],
+ 'test.styles.mixed' => [],
+ 'test.styles.noscript' => [
+ 'type' => ResourceLoaderModule::LOAD_STYLES,
+ 'group' => 'noscript',
+ ],
+ 'test.styles.user' => [
+ 'type' => ResourceLoaderModule::LOAD_STYLES,
+ 'group' => 'user',
+ ],
+ 'test.styles.user.empty' => [
+ 'type' => ResourceLoaderModule::LOAD_STYLES,
+ 'group' => 'user',
+ 'isKnownEmpty' => true,
+ ],
+ 'test.styles.private' => [
+ 'type' => ResourceLoaderModule::LOAD_STYLES,
+ 'group' => 'private',
+ 'styles' => '.private{}',
+ ],
+ 'test.styles.shouldembed' => [
+ 'type' => ResourceLoaderModule::LOAD_STYLES,
+ 'shouldEmbed' => true,
+ 'styles' => '.shouldembed{}',
+ ],
+
+ 'test.scripts' => [],
+ 'test.scripts.user' => [ 'group' => 'user' ],
+ 'test.scripts.user.empty' => [ 'group' => 'user', 'isKnownEmpty' => true ],
+ 'test.scripts.raw' => [ 'isRaw' => true ],
+ 'test.scripts.shouldembed' => [ 'shouldEmbed' => true ],
+
+ 'test.ordering.a' => [ 'shouldEmbed' => false ],
+ 'test.ordering.b' => [ 'shouldEmbed' => false ],
+ 'test.ordering.c' => [ 'shouldEmbed' => true, 'styles' => '.orderingC{}' ],
+ 'test.ordering.d' => [ 'shouldEmbed' => true, 'styles' => '.orderingD{}' ],
+ 'test.ordering.e' => [ 'shouldEmbed' => false ],
+ ];
+ return array_map( function ( $options ) {
+ return self::makeModule( $options );
+ }, $modules );
+ }
+
+ /**
+ * @covers ResourceLoaderClientHtml::getDocumentAttributes
+ */
+ public function testGetDocumentAttributes() {
+ $client = new ResourceLoaderClientHtml( self::makeContext() );
+ $this->assertInternalType( 'array', $client->getDocumentAttributes() );
+ }
+
+ /**
+ * @covers ResourceLoaderClientHtml::__construct
+ * @covers ResourceLoaderClientHtml::setModules
+ * @covers ResourceLoaderClientHtml::setModuleStyles
+ * @covers ResourceLoaderClientHtml::setModuleScripts
+ * @covers ResourceLoaderClientHtml::getData
+ * @covers ResourceLoaderClientHtml::getContext
+ */
+ public function testGetData() {
+ $context = self::makeContext();
+ $context->getResourceLoader()->register( self::makeSampleModules() );
+
+ $client = new ResourceLoaderClientHtml( $context );
+ $client->setModules( [
+ 'test',
+ 'test.private',
+ 'test.shouldembed.empty',
+ 'test.shouldembed',
+ 'test.unregistered',
+ ] );
+ $client->setModuleStyles( [
+ 'test.styles.mixed',
+ 'test.styles.user.empty',
+ 'test.styles.private',
+ 'test.styles.pure',
+ 'test.styles.shouldembed',
+ 'test.unregistered.styles',
+ ] );
+ $client->setModuleScripts( [
+ 'test.scripts',
+ 'test.scripts.user',
+ 'test.scripts.user.empty',
+ 'test.scripts.shouldembed',
+ 'test.unregistered.scripts',
+ ] );
+
+ $expected = [
+ 'states' => [
+ 'test.private' => 'loading',
+ 'test.shouldembed.empty' => 'ready',
+ 'test.shouldembed' => 'loading',
+ 'test.styles.pure' => 'ready',
+ 'test.styles.user.empty' => 'ready',
+ 'test.styles.private' => 'ready',
+ 'test.styles.shouldembed' => 'ready',
+ 'test.scripts' => 'loading',
+ 'test.scripts.user' => 'loading',
+ 'test.scripts.user.empty' => 'ready',
+ 'test.scripts.shouldembed' => 'loading',
+ ],
+ 'general' => [
+ 'test',
+ ],
+ 'styles' => [
+ 'test.styles.pure',
+ ],
+ 'scripts' => [
+ 'test.scripts',
+ 'test.scripts.user',
+ 'test.scripts.shouldembed',
+ ],
+ 'embed' => [
+ 'styles' => [ 'test.styles.private', 'test.styles.shouldembed' ],
+ 'general' => [
+ 'test.private',
+ 'test.shouldembed',
+ ],
+ ],
+ ];
+
+ $access = TestingAccessWrapper::newFromObject( $client );
+ $this->assertEquals( $expected, $access->getData() );
+ }
+
+ /**
+ * @covers ResourceLoaderClientHtml::setConfig
+ * @covers ResourceLoaderClientHtml::setExemptStates
+ * @covers ResourceLoaderClientHtml::getHeadHtml
+ * @covers ResourceLoaderClientHtml::getLoad
+ * @covers ResourceLoader::makeLoaderStateScript
+ */
+ public function testGetHeadHtml() {
+ $context = self::makeContext();
+ $context->getResourceLoader()->register( self::makeSampleModules() );
+
+ $client = new ResourceLoaderClientHtml( $context );
+ $client->setConfig( [ 'key' => 'value' ] );
+ $client->setModules( [
+ 'test',
+ 'test.private',
+ ] );
+ $client->setModuleStyles( [
+ 'test.styles.pure',
+ 'test.styles.private',
+ ] );
+ $client->setModuleScripts( [
+ 'test.scripts',
+ ] );
+ $client->setExemptStates( [
+ 'test.exempt' => 'ready',
+ ] );
+
+ // phpcs:disable Generic.Files.LineLength
+ $expected = '<script>document.documentElement.className = document.documentElement.className.replace( /(^|\s)client-nojs(\s|$)/, "$1client-js$2" );</script>' . "\n"
+ . '<script>(window.RLQ=window.RLQ||[]).push(function(){'
+ . 'mw.config.set({"key":"value"});'
+ . 'mw.loader.state({"test.exempt":"ready","test.private":"loading","test.styles.pure":"ready","test.styles.private":"ready","test.scripts":"loading"});'
+ . 'mw.loader.implement("test.private@{blankVer}",function($,jQuery,require,module){},{"css":[]});'
+ . 'mw.loader.load(["test"]);'
+ . 'mw.loader.load("/w/load.php?debug=false\u0026lang=nl\u0026modules=test.scripts\u0026only=scripts\u0026skin=fallback");'
+ . '});</script>' . "\n"
+ . '<link rel="stylesheet" href="/w/load.php?debug=false&amp;lang=nl&amp;modules=test.styles.pure&amp;only=styles&amp;skin=fallback"/>' . "\n"
+ . '<style>.private{}</style>' . "\n"
+ . '<script async="" src="/w/load.php?debug=false&amp;lang=nl&amp;modules=startup&amp;only=scripts&amp;skin=fallback"></script>';
+ // phpcs:enable
+ $expected = self::expandVariables( $expected );
+
+ $this->assertEquals( $expected, $client->getHeadHtml() );
+ }
+
+ /**
+ * Confirm that 'target' is passed down to the startup module's load url.
+ *
+ * @covers ResourceLoaderClientHtml::getHeadHtml
+ */
+ public function testGetHeadHtmlWithTarget() {
+ $client = new ResourceLoaderClientHtml(
+ self::makeContext(),
+ [ 'target' => 'example' ]
+ );
+
+ // phpcs:disable Generic.Files.LineLength
+ $expected = '<script>document.documentElement.className = document.documentElement.className.replace( /(^|\s)client-nojs(\s|$)/, "$1client-js$2" );</script>' . "\n"
+ . '<script async="" src="/w/load.php?debug=false&amp;lang=nl&amp;modules=startup&amp;only=scripts&amp;skin=fallback&amp;target=example"></script>';
+ // phpcs:enable
+
+ $this->assertEquals( $expected, $client->getHeadHtml() );
+ }
+
+ /**
+ * Confirm that a null 'target' is the same as no target.
+ *
+ * @covers ResourceLoaderClientHtml::getHeadHtml
+ */
+ public function testGetHeadHtmlWithNullTarget() {
+ $client = new ResourceLoaderClientHtml(
+ self::makeContext(),
+ [ 'target' => null ]
+ );
+
+ // phpcs:disable Generic.Files.LineLength
+ $expected = '<script>document.documentElement.className = document.documentElement.className.replace( /(^|\s)client-nojs(\s|$)/, "$1client-js$2" );</script>' . "\n"
+ . '<script async="" src="/w/load.php?debug=false&amp;lang=nl&amp;modules=startup&amp;only=scripts&amp;skin=fallback"></script>';
+ // phpcs:enable
+
+ $this->assertEquals( $expected, $client->getHeadHtml() );
+ }
+
+ /**
+ * @covers ResourceLoaderClientHtml::getBodyHtml
+ * @covers ResourceLoaderClientHtml::getLoad
+ */
+ public function testGetBodyHtml() {
+ $context = self::makeContext();
+ $context->getResourceLoader()->register( self::makeSampleModules() );
+
+ $client = new ResourceLoaderClientHtml( $context );
+ $client->setConfig( [ 'key' => 'value' ] );
+ $client->setModules( [
+ 'test',
+ 'test.private.bottom',
+ ] );
+ $client->setModuleScripts( [
+ 'test.scripts',
+ ] );
+
+ $expected = '';
+ $expected = self::expandVariables( $expected );
+
+ $this->assertEquals( $expected, $client->getBodyHtml() );
+ }
+
+ public static function provideMakeLoad() {
+ // phpcs:disable Generic.Files.LineLength
+ return [
+ [
+ 'context' => [],
+ 'modules' => [ 'test.unknown' ],
+ 'only' => ResourceLoaderModule::TYPE_STYLES,
+ 'output' => '',
+ ],
+ [
+ 'context' => [],
+ 'modules' => [ 'test.styles.private' ],
+ 'only' => ResourceLoaderModule::TYPE_STYLES,
+ 'output' => '<style>.private{}</style>',
+ ],
+ [
+ 'context' => [],
+ 'modules' => [ 'test.private' ],
+ 'only' => ResourceLoaderModule::TYPE_COMBINED,
+ 'output' => '<script>(window.RLQ=window.RLQ||[]).push(function(){mw.loader.implement("test.private@{blankVer}",function($,jQuery,require,module){},{"css":[]});});</script>',
+ ],
+ [
+ 'context' => [],
+ // Eg. startup module
+ 'modules' => [ 'test.scripts.raw' ],
+ 'only' => ResourceLoaderModule::TYPE_SCRIPTS,
+ 'output' => '<script async="" src="/w/load.php?debug=false&amp;lang=nl&amp;modules=test.scripts.raw&amp;only=scripts&amp;skin=fallback"></script>',
+ ],
+ [
+ 'context' => [ 'sync' => true ],
+ 'modules' => [ 'test.scripts.raw' ],
+ 'only' => ResourceLoaderModule::TYPE_SCRIPTS,
+ 'output' => '<script src="/w/load.php?debug=false&amp;lang=nl&amp;modules=test.scripts.raw&amp;only=scripts&amp;skin=fallback&amp;sync=1"></script>',
+ ],
+ [
+ 'context' => [],
+ 'modules' => [ 'test.scripts.user' ],
+ 'only' => ResourceLoaderModule::TYPE_SCRIPTS,
+ 'output' => '<script>(window.RLQ=window.RLQ||[]).push(function(){mw.loader.load("/w/load.php?debug=false\u0026lang=nl\u0026modules=test.scripts.user\u0026only=scripts\u0026skin=fallback\u0026user=Example\u0026version=0a56zyi");});</script>',
+ ],
+ [
+ 'context' => [ 'debug' => true ],
+ 'modules' => [ 'test.styles.pure', 'test.styles.mixed' ],
+ 'only' => ResourceLoaderModule::TYPE_STYLES,
+ 'output' => '<link rel="stylesheet" href="/w/load.php?debug=true&amp;lang=nl&amp;modules=test.styles.mixed&amp;only=styles&amp;skin=fallback"/>' . "\n"
+ . '<link rel="stylesheet" href="/w/load.php?debug=true&amp;lang=nl&amp;modules=test.styles.pure&amp;only=styles&amp;skin=fallback"/>',
+ ],
+ [
+ 'context' => [ 'debug' => false ],
+ 'modules' => [ 'test.styles.pure', 'test.styles.mixed' ],
+ 'only' => ResourceLoaderModule::TYPE_STYLES,
+ 'output' => '<link rel="stylesheet" href="/w/load.php?debug=false&amp;lang=nl&amp;modules=test.styles.mixed%2Cpure&amp;only=styles&amp;skin=fallback"/>',
+ ],
+ [
+ 'context' => [],
+ 'modules' => [ 'test.styles.noscript' ],
+ 'only' => ResourceLoaderModule::TYPE_STYLES,
+ 'output' => '<noscript><link rel="stylesheet" href="/w/load.php?debug=false&amp;lang=nl&amp;modules=test.styles.noscript&amp;only=styles&amp;skin=fallback"/></noscript>',
+ ],
+ [
+ 'context' => [],
+ 'modules' => [ 'test.shouldembed' ],
+ 'only' => ResourceLoaderModule::TYPE_COMBINED,
+ 'output' => '<script>(window.RLQ=window.RLQ||[]).push(function(){mw.loader.implement("test.shouldembed@09p30q0",function($,jQuery,require,module){},{"css":[]});});</script>',
+ ],
+ [
+ 'context' => [],
+ 'modules' => [ 'test.styles.shouldembed' ],
+ 'only' => ResourceLoaderModule::TYPE_STYLES,
+ 'output' => '<style>.shouldembed{}</style>',
+ ],
+ [
+ 'context' => [],
+ 'modules' => [ 'test.scripts.shouldembed' ],
+ 'only' => ResourceLoaderModule::TYPE_SCRIPTS,
+ 'output' => '<script>(window.RLQ=window.RLQ||[]).push(function(){mw.loader.state({"test.scripts.shouldembed":"ready"});});</script>',
+ ],
+ [
+ 'context' => [],
+ 'modules' => [ 'test', 'test.shouldembed' ],
+ 'only' => ResourceLoaderModule::TYPE_COMBINED,
+ 'output' => '<script>(window.RLQ=window.RLQ||[]).push(function(){mw.loader.load("/w/load.php?debug=false\u0026lang=nl\u0026modules=test\u0026skin=fallback");mw.loader.implement("test.shouldembed@09p30q0",function($,jQuery,require,module){},{"css":[]});});</script>',
+ ],
+ [
+ 'context' => [],
+ 'modules' => [ 'test.styles.pure', 'test.styles.shouldembed' ],
+ 'only' => ResourceLoaderModule::TYPE_STYLES,
+ 'output' =>
+ '<link rel="stylesheet" href="/w/load.php?debug=false&amp;lang=nl&amp;modules=test.styles.pure&amp;only=styles&amp;skin=fallback"/>' . "\n"
+ . '<style>.shouldembed{}</style>'
+ ],
+ [
+ 'context' => [],
+ 'modules' => [ 'test.ordering.a', 'test.ordering.e', 'test.ordering.b', 'test.ordering.d', 'test.ordering.c' ],
+ 'only' => ResourceLoaderModule::TYPE_STYLES,
+ 'output' =>
+ '<link rel="stylesheet" href="/w/load.php?debug=false&amp;lang=nl&amp;modules=test.ordering.a%2Cb&amp;only=styles&amp;skin=fallback"/>' . "\n"
+ . '<style>.orderingC{}.orderingD{}</style>' . "\n"
+ . '<link rel="stylesheet" href="/w/load.php?debug=false&amp;lang=nl&amp;modules=test.ordering.e&amp;only=styles&amp;skin=fallback"/>'
+ ],
+ ];
+ // phpcs:enable
+ }
+
+ /**
+ * @dataProvider provideMakeLoad
+ * @covers ResourceLoaderClientHtml::makeLoad
+ * @covers ResourceLoaderClientHtml::makeContext
+ * @covers ResourceLoader::makeModuleResponse
+ * @covers ResourceLoaderModule::getModuleContent
+ * @covers ResourceLoader::getCombinedVersion
+ * @covers ResourceLoader::createLoaderURL
+ * @covers ResourceLoader::createLoaderQuery
+ * @covers ResourceLoader::makeLoaderQuery
+ * @covers ResourceLoader::makeInlineScript
+ */
+ public function testMakeLoad( array $extraQuery, array $modules, $type, $expected ) {
+ $context = self::makeContext( $extraQuery );
+ $context->getResourceLoader()->register( self::makeSampleModules() );
+ $actual = ResourceLoaderClientHtml::makeLoad( $context, $modules, $type, $extraQuery );
+ $expected = self::expandVariables( $expected );
+ $this->assertEquals( $expected, (string)$actual );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderContextTest.php b/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderContextTest.php
new file mode 100644
index 00000000..b226ee1c
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderContextTest.php
@@ -0,0 +1,120 @@
+<?php
+
+/**
+ * See also:
+ * - ResourceLoaderTest::testExpandModuleNames
+ * - ResourceLoaderImageModuleTest::testContext
+ *
+ * @group Cache
+ * @covers ResourceLoaderContext
+ */
+class ResourceLoaderContextTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ protected static function getResourceLoader() {
+ return new EmptyResourceLoader( new HashConfig( [
+ 'ResourceLoaderDebug' => false,
+ 'DefaultSkin' => 'fallback',
+ 'LanguageCode' => 'nl',
+ 'LoadScript' => '/w/load.php',
+ ] ) );
+ }
+
+ public function testEmpty() {
+ $ctx = new ResourceLoaderContext( $this->getResourceLoader(), new FauxRequest( [] ) );
+
+ // Request parameters
+ $this->assertEquals( [], $ctx->getModules() );
+ $this->assertEquals( 'nl', $ctx->getLanguage() );
+ $this->assertEquals( false, $ctx->getDebug() );
+ $this->assertEquals( null, $ctx->getOnly() );
+ $this->assertEquals( 'fallback', $ctx->getSkin() );
+ $this->assertEquals( null, $ctx->getUser() );
+
+ // Misc
+ $this->assertEquals( 'ltr', $ctx->getDirection() );
+ $this->assertEquals( 'nl|fallback||||||||', $ctx->getHash() );
+ $this->assertInstanceOf( User::class, $ctx->getUserObj() );
+ }
+
+ public function testDummy() {
+ $this->assertInstanceOf(
+ ResourceLoaderContext::class,
+ ResourceLoaderContext::newDummyContext()
+ );
+ }
+
+ public function testAccessors() {
+ $ctx = new ResourceLoaderContext( $this->getResourceLoader(), new FauxRequest( [] ) );
+ $this->assertInstanceOf( WebRequest::class, $ctx->getRequest() );
+ $this->assertInstanceOf( \Psr\Log\LoggerInterface::class, $ctx->getLogger() );
+ }
+
+ public function testTypicalRequest() {
+ $ctx = new ResourceLoaderContext( $this->getResourceLoader(), new FauxRequest( [
+ 'debug' => 'false',
+ 'lang' => 'zh',
+ 'modules' => 'foo|foo.quux,baz,bar|baz.quux',
+ 'only' => 'styles',
+ 'skin' => 'fallback',
+ ] ) );
+
+ // Request parameters
+ $this->assertEquals(
+ $ctx->getModules(),
+ [ 'foo', 'foo.quux', 'foo.baz', 'foo.bar', 'baz.quux' ]
+ );
+ $this->assertEquals( false, $ctx->getDebug() );
+ $this->assertEquals( 'zh', $ctx->getLanguage() );
+ $this->assertEquals( 'styles', $ctx->getOnly() );
+ $this->assertEquals( 'fallback', $ctx->getSkin() );
+ $this->assertEquals( null, $ctx->getUser() );
+
+ // Misc
+ $this->assertEquals( 'ltr', $ctx->getDirection() );
+ $this->assertEquals( 'zh|fallback|||styles|||||', $ctx->getHash() );
+ }
+
+ public function testShouldInclude() {
+ $ctx = new ResourceLoaderContext( $this->getResourceLoader(), new FauxRequest( [] ) );
+ $this->assertTrue( $ctx->shouldIncludeScripts(), 'Scripts in combined' );
+ $this->assertTrue( $ctx->shouldIncludeStyles(), 'Styles in combined' );
+ $this->assertTrue( $ctx->shouldIncludeMessages(), 'Messages in combined' );
+
+ $ctx = new ResourceLoaderContext( $this->getResourceLoader(), new FauxRequest( [
+ 'only' => 'styles'
+ ] ) );
+ $this->assertFalse( $ctx->shouldIncludeScripts(), 'Scripts not in styles-only' );
+ $this->assertTrue( $ctx->shouldIncludeStyles(), 'Styles in styles-only' );
+ $this->assertFalse( $ctx->shouldIncludeMessages(), 'Messages not in styles-only' );
+
+ $ctx = new ResourceLoaderContext( $this->getResourceLoader(), new FauxRequest( [
+ 'only' => 'scripts'
+ ] ) );
+ $this->assertTrue( $ctx->shouldIncludeScripts(), 'Scripts in scripts-only' );
+ $this->assertFalse( $ctx->shouldIncludeStyles(), 'Styles not in scripts-only' );
+ $this->assertFalse( $ctx->shouldIncludeMessages(), 'Messages not in scripts-only' );
+ }
+
+ public function testGetUser() {
+ $ctx = new ResourceLoaderContext( $this->getResourceLoader(), new FauxRequest( [] ) );
+ $this->assertSame( null, $ctx->getUser() );
+ $this->assertTrue( $ctx->getUserObj()->isAnon() );
+
+ $ctx = new ResourceLoaderContext( $this->getResourceLoader(), new FauxRequest( [
+ 'user' => 'Example'
+ ] ) );
+ $this->assertSame( 'Example', $ctx->getUser() );
+ $this->assertEquals( 'Example', $ctx->getUserObj()->getName() );
+ }
+
+ public function testMsg() {
+ $ctx = new ResourceLoaderContext( $this->getResourceLoader(), new FauxRequest( [
+ 'lang' => 'en'
+ ] ) );
+ $msg = $ctx->msg( 'mainpage' );
+ $this->assertInstanceOf( Message::class, $msg );
+ $this->assertSame( 'Main Page', $msg->useDatabase( false )->plain() );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderFileModuleTest.php b/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderFileModuleTest.php
new file mode 100644
index 00000000..e82bab72
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderFileModuleTest.php
@@ -0,0 +1,353 @@
+<?php
+
+/**
+ * @group Database
+ * @group ResourceLoader
+ */
+class ResourceLoaderFileModuleTest extends ResourceLoaderTestCase {
+
+ protected function setUp() {
+ parent::setUp();
+
+ // The return value of the closure shouldn't matter since this test should
+ // never call it
+ SkinFactory::getDefaultInstance()->register(
+ 'fakeskin',
+ 'FakeSkin',
+ function () {
+ }
+ );
+ }
+
+ private static function getModules() {
+ $base = [
+ 'localBasePath' => realpath( __DIR__ ),
+ ];
+
+ return [
+ 'noTemplateModule' => [],
+
+ 'deprecatedModule' => $base + [
+ 'deprecated' => true,
+ ],
+ 'deprecatedTomorrow' => $base + [
+ 'deprecated' => 'Will be removed tomorrow.'
+ ],
+
+ 'htmlTemplateModule' => $base + [
+ 'templates' => [
+ 'templates/template.html',
+ 'templates/template2.html',
+ ]
+ ],
+
+ 'htmlTemplateUnknown' => $base + [
+ 'templates' => [
+ 'templates/notfound.html',
+ ]
+ ],
+
+ 'aliasedHtmlTemplateModule' => $base + [
+ 'templates' => [
+ 'foo.html' => 'templates/template.html',
+ 'bar.html' => 'templates/template2.html',
+ ]
+ ],
+
+ 'templateModuleHandlebars' => $base + [
+ 'templates' => [
+ 'templates/template_awesome.handlebars',
+ ],
+ ],
+
+ 'aliasFooFromBar' => $base + [
+ 'templates' => [
+ 'foo.foo' => 'templates/template.bar',
+ ],
+ ],
+ ];
+ }
+
+ public static function providerTemplateDependencies() {
+ $modules = self::getModules();
+
+ return [
+ [
+ $modules['noTemplateModule'],
+ [],
+ ],
+ [
+ $modules['htmlTemplateModule'],
+ [
+ 'mediawiki.template',
+ ],
+ ],
+ [
+ $modules['templateModuleHandlebars'],
+ [
+ 'mediawiki.template',
+ 'mediawiki.template.handlebars',
+ ],
+ ],
+ [
+ $modules['aliasFooFromBar'],
+ [
+ 'mediawiki.template',
+ 'mediawiki.template.foo',
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider providerTemplateDependencies
+ * @covers ResourceLoaderFileModule::__construct
+ * @covers ResourceLoaderFileModule::getDependencies
+ */
+ public function testTemplateDependencies( $module, $expected ) {
+ $rl = new ResourceLoaderFileModule( $module );
+ $rl->setName( 'testing' );
+ $this->assertEquals( $rl->getDependencies(), $expected );
+ }
+
+ public static function providerDeprecatedModules() {
+ return [
+ [
+ 'deprecatedModule',
+ 'mw.log.warn("This page is using the deprecated ResourceLoader module \"deprecatedModule\".");',
+ ],
+ [
+ 'deprecatedTomorrow',
+ 'mw.log.warn(' .
+ '"This page is using the deprecated ResourceLoader module \"deprecatedTomorrow\".\\n' .
+ "Will be removed tomorrow." .
+ '");'
+ ]
+ ];
+ }
+
+ /**
+ * @dataProvider providerDeprecatedModules
+ * @covers ResourceLoaderFileModule::getScript
+ */
+ public function testDeprecatedModules( $name, $expected ) {
+ $modules = self::getModules();
+ $module = new ResourceLoaderFileModule( $modules[$name] );
+ $module->setName( $name );
+ $ctx = $this->getResourceLoaderContext();
+ $this->assertEquals( $module->getScript( $ctx ), $expected );
+ }
+
+ /**
+ * @covers ResourceLoaderFileModule::getScript
+ */
+ public function testGetScript() {
+ $module = new ResourceLoaderFileModule( [
+ 'localBasePath' => __DIR__ . '/../../data/resourceloader',
+ 'scripts' => [ 'script-nosemi.js', 'script-comment.js' ],
+ ] );
+ $module->setName( 'testing' );
+ $ctx = $this->getResourceLoaderContext();
+ $this->assertEquals(
+ "/* eslint-disable */\nmw.foo()\n" .
+ "\n" .
+ "/* eslint-disable */\nmw.foo()\n// mw.bar();\n" .
+ "\n",
+ $module->getScript( $ctx ),
+ 'scripts are concatenated with a new-line'
+ );
+ }
+
+ /**
+ * @covers ResourceLoaderFileModule::getAllStyleFiles
+ * @covers ResourceLoaderFileModule::getAllSkinStyleFiles
+ * @covers ResourceLoaderFileModule::getSkinStyleFiles
+ */
+ public function testGetAllSkinStyleFiles() {
+ $baseParams = [
+ 'scripts' => [
+ 'foo.js',
+ 'bar.js',
+ ],
+ 'styles' => [
+ 'foo.css',
+ 'bar.css' => [ 'media' => 'print' ],
+ 'screen.less' => [ 'media' => 'screen' ],
+ 'screen-query.css' => [ 'media' => 'screen and (min-width: 400px)' ],
+ ],
+ 'skinStyles' => [
+ 'default' => 'quux-fallback.less',
+ 'fakeskin' => [
+ 'baz-vector.css',
+ 'quux-vector.less',
+ ],
+ ],
+ 'messages' => [
+ 'hello',
+ 'world',
+ ],
+ ];
+
+ $module = new ResourceLoaderFileModule( $baseParams );
+ $module->setName( 'testing' );
+
+ $this->assertEquals(
+ [
+ 'foo.css',
+ 'baz-vector.css',
+ 'quux-vector.less',
+ 'quux-fallback.less',
+ 'bar.css',
+ 'screen.less',
+ 'screen-query.css',
+ ],
+ array_map( 'basename', $module->getAllStyleFiles() )
+ );
+ }
+
+ /**
+ * Strip @noflip annotations from CSS code.
+ * @param string $css
+ * @return string
+ */
+ private static function stripNoflip( $css ) {
+ return str_replace( '/*@noflip*/ ', '', $css );
+ }
+
+ /**
+ * What happens when you mix @embed and @noflip?
+ * This really is an integration test, but oh well.
+ *
+ * @covers ResourceLoaderFileModule::getStyles
+ * @covers ResourceLoaderFileModule::getStyleFiles
+ */
+ public function testMixedCssAnnotations() {
+ $basePath = __DIR__ . '/../../data/css';
+ $testModule = new ResourceLoaderFileModule( [
+ 'localBasePath' => $basePath,
+ 'styles' => [ 'test.css' ],
+ ] );
+ $testModule->setName( 'testing' );
+ $expectedModule = new ResourceLoaderFileModule( [
+ 'localBasePath' => $basePath,
+ 'styles' => [ 'expected.css' ],
+ ] );
+ $expectedModule->setName( 'testing' );
+
+ $contextLtr = $this->getResourceLoaderContext( [
+ 'lang' => 'en',
+ 'dir' => 'ltr',
+ ] );
+ $contextRtl = $this->getResourceLoaderContext( [
+ 'lang' => 'he',
+ 'dir' => 'rtl',
+ ] );
+
+ // Since we want to compare the effect of @noflip+@embed against the effect of just @embed, and
+ // the @noflip annotations are always preserved, we need to strip them first.
+ $this->assertEquals(
+ $expectedModule->getStyles( $contextLtr ),
+ self::stripNoflip( $testModule->getStyles( $contextLtr ) ),
+ "/*@noflip*/ with /*@embed*/ gives correct results in LTR mode"
+ );
+ $this->assertEquals(
+ $expectedModule->getStyles( $contextLtr ),
+ self::stripNoflip( $testModule->getStyles( $contextRtl ) ),
+ "/*@noflip*/ with /*@embed*/ gives correct results in RTL mode"
+ );
+ }
+
+ public static function providerGetTemplates() {
+ $modules = self::getModules();
+
+ return [
+ [
+ $modules['noTemplateModule'],
+ [],
+ ],
+ [
+ $modules['templateModuleHandlebars'],
+ [
+ 'templates/template_awesome.handlebars' => "wow\n",
+ ],
+ ],
+ [
+ $modules['htmlTemplateModule'],
+ [
+ 'templates/template.html' => "<strong>hello</strong>\n",
+ 'templates/template2.html' => "<div>goodbye</div>\n",
+ ],
+ ],
+ [
+ $modules['aliasedHtmlTemplateModule'],
+ [
+ 'foo.html' => "<strong>hello</strong>\n",
+ 'bar.html' => "<div>goodbye</div>\n",
+ ],
+ ],
+ [
+ $modules['htmlTemplateUnknown'],
+ false,
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider providerGetTemplates
+ * @covers ResourceLoaderFileModule::getTemplates
+ */
+ public function testGetTemplates( $module, $expected ) {
+ $rl = new ResourceLoaderFileModule( $module );
+ $rl->setName( 'testing' );
+
+ if ( $expected === false ) {
+ $this->setExpectedException( MWException::class );
+ $rl->getTemplates();
+ } else {
+ $this->assertEquals( $rl->getTemplates(), $expected );
+ }
+ }
+
+ /**
+ * @covers ResourceLoaderFileModule::stripBom
+ */
+ public function testBomConcatenation() {
+ $basePath = __DIR__ . '/../../data/css';
+ $testModule = new ResourceLoaderFileModule( [
+ 'localBasePath' => $basePath,
+ 'styles' => [ 'bom.css' ],
+ ] );
+ $testModule->setName( 'testing' );
+ $this->assertEquals(
+ substr( file_get_contents( "$basePath/bom.css" ), 0, 10 ),
+ "\xef\xbb\xbf.efbbbf",
+ 'File has leading BOM'
+ );
+
+ $context = $this->getResourceLoaderContext();
+ $this->assertEquals(
+ $testModule->getStyles( $context ),
+ [ 'all' => ".efbbbf_bom_char_at_start_of_file {}\n" ],
+ 'Leading BOM removed when concatenating files'
+ );
+ }
+
+ /**
+ * @covers ResourceLoaderFileModule::getDefinitionSummary
+ */
+ public function testGetVersionHash() {
+ $context = $this->getResourceLoaderContext();
+
+ // Less variables
+ $module = new ResourceLoaderFileTestModule();
+ $version = $module->getVersionHash( $context );
+ $module = new ResourceLoaderFileTestModule( [], [
+ 'lessVars' => [ 'key' => 'value' ],
+ ] );
+ $this->assertNotEquals(
+ $version,
+ $module->getVersionHash( $context ),
+ 'Using less variables is significant'
+ );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderImageModuleTest.php b/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderImageModuleTest.php
new file mode 100644
index 00000000..3f5704d6
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderImageModuleTest.php
@@ -0,0 +1,265 @@
+<?php
+
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group ResourceLoader
+ */
+class ResourceLoaderImageModuleTest extends ResourceLoaderTestCase {
+
+ public static $commonImageData = [
+ 'abc' => 'abc.gif',
+ 'def' => [
+ 'file' => 'def.svg',
+ 'variants' => [ 'destructive' ],
+ ],
+ 'ghi' => [
+ 'file' => [
+ 'ltr' => 'ghi.svg',
+ 'rtl' => 'jkl.svg'
+ ],
+ ],
+ 'mno' => [
+ 'file' => [
+ 'ltr' => 'mno-ltr.svg',
+ 'rtl' => 'mno-rtl.svg',
+ 'lang' => [
+ 'he' => 'mno-ltr.svg',
+ ]
+ ],
+ ],
+ 'pqr' => [
+ 'file' => [
+ 'default' => 'pqr-a.svg',
+ 'lang' => [
+ 'en' => 'pqr-b.svg',
+ 'ar,de' => 'pqr-f.svg',
+ ]
+ ],
+ ]
+ ];
+
+ public static $commonImageVariants = [
+ 'invert' => [
+ 'color' => '#FFFFFF',
+ 'global' => true,
+ ],
+ 'primary' => [
+ 'color' => '#598AD1',
+ ],
+ 'constructive' => [
+ 'color' => '#00C697',
+ ],
+ 'destructive' => [
+ 'color' => '#E81915',
+ ],
+ ];
+
+ public static function providerGetModules() {
+ return [
+ [
+ [
+ 'class' => ResourceLoaderImageModule::class,
+ 'prefix' => 'oo-ui-icon',
+ 'variants' => self::$commonImageVariants,
+ 'images' => self::$commonImageData,
+ ],
+ '.oo-ui-icon-abc {
+ ...
+}
+.oo-ui-icon-abc-invert {
+ ...
+}
+.oo-ui-icon-def {
+ ...
+}
+.oo-ui-icon-def-invert {
+ ...
+}
+.oo-ui-icon-def-destructive {
+ ...
+}
+.oo-ui-icon-ghi {
+ ...
+}
+.oo-ui-icon-ghi-invert {
+ ...
+}
+.oo-ui-icon-mno {
+ ...
+}
+.oo-ui-icon-mno-invert {
+ ...
+}
+.oo-ui-icon-pqr {
+ ...
+}
+.oo-ui-icon-pqr-invert {
+ ...
+}',
+ ],
+ [
+ [
+ 'class' => ResourceLoaderImageModule::class,
+ 'selectorWithoutVariant' => '.mw-ui-icon-{name}:after, .mw-ui-icon-{name}:before',
+ 'selectorWithVariant' =>
+ '.mw-ui-icon-{name}-{variant}:after, .mw-ui-icon-{name}-{variant}:before',
+ 'variants' => self::$commonImageVariants,
+ 'images' => self::$commonImageData,
+ ],
+ '.mw-ui-icon-abc:after, .mw-ui-icon-abc:before {
+ ...
+}
+.mw-ui-icon-abc-invert:after, .mw-ui-icon-abc-invert:before {
+ ...
+}
+.mw-ui-icon-def:after, .mw-ui-icon-def:before {
+ ...
+}
+.mw-ui-icon-def-invert:after, .mw-ui-icon-def-invert:before {
+ ...
+}
+.mw-ui-icon-def-destructive:after, .mw-ui-icon-def-destructive:before {
+ ...
+}
+.mw-ui-icon-ghi:after, .mw-ui-icon-ghi:before {
+ ...
+}
+.mw-ui-icon-ghi-invert:after, .mw-ui-icon-ghi-invert:before {
+ ...
+}
+.mw-ui-icon-mno:after, .mw-ui-icon-mno:before {
+ ...
+}
+.mw-ui-icon-mno-invert:after, .mw-ui-icon-mno-invert:before {
+ ...
+}
+.mw-ui-icon-pqr:after, .mw-ui-icon-pqr:before {
+ ...
+}
+.mw-ui-icon-pqr-invert:after, .mw-ui-icon-pqr-invert:before {
+ ...
+}',
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider providerGetModules
+ * @covers ResourceLoaderImageModule::getStyles
+ */
+ public function testGetStyles( $module, $expected ) {
+ $module = new ResourceLoaderImageModuleTestable(
+ $module,
+ __DIR__ . '/../../data/resourceloader'
+ );
+ $styles = $module->getStyles( $this->getResourceLoaderContext() );
+ $this->assertEquals( $expected, $styles['all'] );
+ }
+
+ /**
+ * @covers ResourceLoaderContext::getImageObj
+ */
+ public function testContext() {
+ $context = new ResourceLoaderContext( new EmptyResourceLoader(), new FauxRequest() );
+ $this->assertFalse( $context->getImageObj(), 'Missing image parameter' );
+
+ $context = new ResourceLoaderContext( new EmptyResourceLoader(), new FauxRequest( [
+ 'image' => 'example',
+ ] ) );
+ $this->assertFalse( $context->getImageObj(), 'Missing module parameter' );
+
+ $context = new ResourceLoaderContext( new EmptyResourceLoader(), new FauxRequest( [
+ 'modules' => 'unknown',
+ 'image' => 'example',
+ ] ) );
+ $this->assertFalse( $context->getImageObj(), 'Not an image module' );
+
+ $rl = new EmptyResourceLoader();
+ $rl->register( 'test', [
+ 'class' => ResourceLoaderImageModule::class,
+ 'prefix' => 'test',
+ 'images' => [ 'example' => 'example.png' ],
+ ] );
+ $context = new ResourceLoaderContext( $rl, new FauxRequest( [
+ 'modules' => 'test',
+ 'image' => 'unknown',
+ ] ) );
+ $this->assertFalse( $context->getImageObj(), 'Unknown image' );
+
+ $rl = new EmptyResourceLoader();
+ $rl->register( 'test', [
+ 'class' => ResourceLoaderImageModule::class,
+ 'prefix' => 'test',
+ 'images' => [ 'example' => 'example.png' ],
+ ] );
+ $context = new ResourceLoaderContext( $rl, new FauxRequest( [
+ 'modules' => 'test',
+ 'image' => 'example',
+ ] ) );
+ $this->assertInstanceOf( ResourceLoaderImage::class, $context->getImageObj() );
+ }
+
+ public static function providerGetStyleDeclarations() {
+ return [
+ [
+ false,
+<<<TEXT
+background-image: url(rasterized.png);
+ background-image: linear-gradient(transparent, transparent), url(original.svg);
+TEXT
+ ],
+ [
+ 'data:image/svg+xml',
+<<<TEXT
+background-image: url(rasterized.png);
+ background-image: linear-gradient(transparent, transparent), url(data:image/svg+xml);
+TEXT
+ ],
+
+ ];
+ }
+
+ /**
+ * @dataProvider providerGetStyleDeclarations
+ * @covers ResourceLoaderImageModule::getStyleDeclarations
+ */
+ public function testGetStyleDeclarations( $dataUriReturnValue, $expected ) {
+ $module = TestingAccessWrapper::newFromObject( new ResourceLoaderImageModule() );
+ $context = $this->getResourceLoaderContext();
+ $image = $this->getImageMock( $context, $dataUriReturnValue );
+
+ $styles = $module->getStyleDeclarations(
+ $context,
+ $image,
+ 'load.php'
+ );
+
+ $this->assertEquals( $expected, $styles );
+ }
+
+ private function getImageMock( ResourceLoaderContext $context, $dataUriReturnValue ) {
+ $image = $this->getMockBuilder( ResourceLoaderImage::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $image->method( 'getDataUri' )
+ ->will( $this->returnValue( $dataUriReturnValue ) );
+ $image->expects( $this->any() )
+ ->method( 'getUrl' )
+ ->will( $this->returnValueMap( [
+ [ $context, 'load.php', null, 'original', 'original.svg' ],
+ [ $context, 'load.php', null, 'rasterized', 'rasterized.png' ],
+ ] ) );
+
+ return $image;
+ }
+}
+
+class ResourceLoaderImageModuleTestable extends ResourceLoaderImageModule {
+ /**
+ * Replace with a stub to make test cases easier to write.
+ */
+ protected function getCssDeclarations( $primary, $fallback ) {
+ return [ '...' ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderImageTest.php b/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderImageTest.php
new file mode 100644
index 00000000..35c3ef64
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderImageTest.php
@@ -0,0 +1,136 @@
+<?php
+
+/**
+ * @group ResourceLoader
+ */
+class ResourceLoaderImageTest extends ResourceLoaderTestCase {
+
+ protected $imagesPath;
+
+ protected function setUp() {
+ parent::setUp();
+ $this->imagesPath = __DIR__ . '/../../data/resourceloader';
+ }
+
+ protected function getTestImage( $name ) {
+ $options = ResourceLoaderImageModuleTest::$commonImageData[$name];
+ $fileDescriptor = is_string( $options ) ? $options : $options['file'];
+ $allowedVariants = ( is_array( $options ) && isset( $options['variants'] ) ) ?
+ $options['variants'] : [];
+ $variants = array_fill_keys( $allowedVariants, [ 'color' => 'red' ] );
+ return new ResourceLoaderImageTestable(
+ $name,
+ 'test',
+ $fileDescriptor,
+ $this->imagesPath,
+ $variants
+ );
+ }
+
+ public static function provideGetPath() {
+ return [
+ [ 'abc', 'en', 'abc.gif' ],
+ [ 'abc', 'he', 'abc.gif' ],
+ [ 'def', 'en', 'def.svg' ],
+ [ 'def', 'he', 'def.svg' ],
+ [ 'ghi', 'en', 'ghi.svg' ],
+ [ 'ghi', 'he', 'jkl.svg' ],
+ [ 'mno', 'en', 'mno-ltr.svg' ],
+ [ 'mno', 'ar', 'mno-rtl.svg' ],
+ [ 'mno', 'he', 'mno-ltr.svg' ],
+ [ 'pqr', 'en', 'pqr-b.svg' ],
+ [ 'pqr', 'en-gb', 'pqr-b.svg' ],
+ [ 'pqr', 'de', 'pqr-f.svg' ],
+ [ 'pqr', 'de-formal', 'pqr-f.svg' ],
+ [ 'pqr', 'ar', 'pqr-f.svg' ],
+ [ 'pqr', 'fr', 'pqr-a.svg' ],
+ [ 'pqr', 'he', 'pqr-a.svg' ],
+ ];
+ }
+
+ /**
+ * @covers ResourceLoaderImage::getPath
+ * @dataProvider provideGetPath
+ */
+ public function testGetPath( $imageName, $languageCode, $path ) {
+ static $dirMap = [
+ 'en' => 'ltr',
+ 'en-gb' => 'ltr',
+ 'de' => 'ltr',
+ 'de-formal' => 'ltr',
+ 'fr' => 'ltr',
+ 'he' => 'rtl',
+ 'ar' => 'rtl',
+ ];
+ static $contexts = [];
+
+ $image = $this->getTestImage( $imageName );
+ $context = $this->getResourceLoaderContext( [
+ 'lang' => $languageCode,
+ 'dir' => $dirMap[$languageCode],
+ ] );
+
+ $this->assertEquals( $image->getPath( $context ), $this->imagesPath . '/' . $path );
+ }
+
+ /**
+ * @covers ResourceLoaderImage::getExtension
+ * @covers ResourceLoaderImage::getMimeType
+ */
+ public function testGetExtension() {
+ $image = $this->getTestImage( 'def' );
+ $this->assertEquals( $image->getExtension(), 'svg' );
+ $this->assertEquals( $image->getExtension( 'original' ), 'svg' );
+ $this->assertEquals( $image->getExtension( 'rasterized' ), 'png' );
+ $image = $this->getTestImage( 'abc' );
+ $this->assertEquals( $image->getExtension(), 'gif' );
+ $this->assertEquals( $image->getExtension( 'original' ), 'gif' );
+ $this->assertEquals( $image->getExtension( 'rasterized' ), 'gif' );
+ }
+
+ /**
+ * @covers ResourceLoaderImage::getImageData
+ * @covers ResourceLoaderImage::variantize
+ * @covers ResourceLoaderImage::massageSvgPathdata
+ */
+ public function testGetImageData() {
+ $context = $this->getResourceLoaderContext();
+
+ $image = $this->getTestImage( 'def' );
+ $data = file_get_contents( $this->imagesPath . '/def.svg' );
+ $dataConstructive = file_get_contents( $this->imagesPath . '/def_variantize.svg' );
+ $this->assertEquals( $image->getImageData( $context, null, 'original' ), $data );
+ $this->assertEquals(
+ $image->getImageData( $context, 'destructive', 'original' ),
+ $dataConstructive
+ );
+ // Stub, since we don't know if we even have a SVG handler, much less what exactly it'll output
+ $this->assertEquals( $image->getImageData( $context, null, 'rasterized' ), 'RASTERIZESTUB' );
+
+ $image = $this->getTestImage( 'abc' );
+ $data = file_get_contents( $this->imagesPath . '/abc.gif' );
+ $this->assertEquals( $image->getImageData( $context, null, 'original' ), $data );
+ $this->assertEquals( $image->getImageData( $context, null, 'rasterized' ), $data );
+ }
+
+ /**
+ * @covers ResourceLoaderImage::massageSvgPathdata
+ */
+ public function testMassageSvgPathdata() {
+ $image = $this->getTestImage( 'ghi' );
+ $data = file_get_contents( $this->imagesPath . '/ghi.svg' );
+ $dataMassaged = file_get_contents( $this->imagesPath . '/ghi_massage.svg' );
+ $this->assertEquals( $image->massageSvgPathdata( $data ), $dataMassaged );
+ }
+}
+
+class ResourceLoaderImageTestable extends ResourceLoaderImage {
+ // Make some protected methods public
+ public function massageSvgPathdata( $svg ) {
+ return parent::massageSvgPathdata( $svg );
+ }
+ // Stub, since we don't know if we even have a SVG handler, much less what exactly it'll output
+ public function rasterize( $svg ) {
+ return 'RASTERIZESTUB';
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderModuleTest.php b/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderModuleTest.php
new file mode 100644
index 00000000..c917882a
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderModuleTest.php
@@ -0,0 +1,224 @@
+<?php
+
+class ResourceLoaderModuleTest extends ResourceLoaderTestCase {
+
+ /**
+ * @covers ResourceLoaderModule::getVersionHash
+ * @covers ResourceLoaderModule::getModifiedTime
+ * @covers ResourceLoaderModule::getModifiedHash
+ */
+ public function testGetVersionHash() {
+ $context = $this->getResourceLoaderContext();
+
+ $baseParams = [
+ 'scripts' => [ 'foo.js', 'bar.js' ],
+ 'dependencies' => [ 'jquery', 'mediawiki' ],
+ 'messages' => [ 'hello', 'world' ],
+ ];
+
+ $module = new ResourceLoaderFileModule( $baseParams );
+ $version = json_encode( $module->getVersionHash( $context ) );
+
+ // Exactly the same
+ $module = new ResourceLoaderFileModule( $baseParams );
+ $this->assertEquals(
+ $version,
+ json_encode( $module->getVersionHash( $context ) ),
+ 'Instance is insignificant'
+ );
+
+ // Re-order dependencies
+ $module = new ResourceLoaderFileModule( [
+ 'dependencies' => [ 'mediawiki', 'jquery' ],
+ ] + $baseParams );
+ $this->assertEquals(
+ $version,
+ json_encode( $module->getVersionHash( $context ) ),
+ 'Order of dependencies is insignificant'
+ );
+
+ // Re-order messages
+ $module = new ResourceLoaderFileModule( [
+ 'messages' => [ 'world', 'hello' ],
+ ] + $baseParams );
+ $this->assertEquals(
+ $version,
+ json_encode( $module->getVersionHash( $context ) ),
+ 'Order of messages is insignificant'
+ );
+
+ // Re-order scripts
+ $module = new ResourceLoaderFileModule( [
+ 'scripts' => [ 'bar.js', 'foo.js' ],
+ ] + $baseParams );
+ $this->assertNotEquals(
+ $version,
+ json_encode( $module->getVersionHash( $context ) ),
+ 'Order of scripts is significant'
+ );
+
+ // Subclass
+ $module = new ResourceLoaderFileModuleTestModule( $baseParams );
+ $this->assertNotEquals(
+ $version,
+ json_encode( $module->getVersionHash( $context ) ),
+ 'Class is significant'
+ );
+ }
+
+ /**
+ * @covers ResourceLoaderModule::validateScriptFile
+ */
+ public function testValidateScriptFile() {
+ $this->setMwGlobals( 'wgResourceLoaderValidateJS', true );
+
+ $context = $this->getResourceLoaderContext();
+
+ $module = new ResourceLoaderTestModule( [
+ 'script' => "var a = 'this is';\n {\ninvalid"
+ ] );
+ $this->assertEquals(
+ 'mw.log.error(' .
+ '"JavaScript parse error: Parse error: Unexpected token; ' .
+ 'token } expected in file \'input\' on line 3"' .
+ ');',
+ $module->getScript( $context ),
+ 'Replace invalid syntax with error logging'
+ );
+
+ $module = new ResourceLoaderTestModule( [
+ 'script' => "\n'valid';"
+ ] );
+ $this->assertEquals(
+ "\n'valid';",
+ $module->getScript( $context ),
+ 'Leave valid scripts as-is'
+ );
+ }
+
+ public static function provideBuildContentScripts() {
+ return [
+ [
+ "mw.foo()",
+ "mw.foo()\n",
+ ],
+ [
+ "mw.foo();",
+ "mw.foo();\n",
+ ],
+ [
+ "mw.foo();\n",
+ "mw.foo();\n",
+ ],
+ [
+ "mw.foo()\n",
+ "mw.foo()\n",
+ ],
+ [
+ "mw.foo()\n// mw.bar();",
+ "mw.foo()\n// mw.bar();\n",
+ ],
+ [
+ "mw.foo()\n// mw.bar()",
+ "mw.foo()\n// mw.bar()\n",
+ ],
+ [
+ "mw.foo()// mw.bar();",
+ "mw.foo()// mw.bar();\n",
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideBuildContentScripts
+ * @covers ResourceLoaderModule::buildContent
+ */
+ public function testBuildContentScripts( $raw, $build, $message = null ) {
+ $context = $this->getResourceLoaderContext();
+ $module = new ResourceLoaderTestModule( [
+ 'script' => $raw
+ ] );
+ $this->assertEquals( $raw, $module->getScript( $context ), 'Raw script' );
+ $this->assertEquals(
+ [ 'scripts' => $build ],
+ $module->getModuleContent( $context ),
+ $message
+ );
+ }
+
+ /**
+ * @covers ResourceLoaderModule::getRelativePaths
+ * @covers ResourceLoaderModule::expandRelativePaths
+ */
+ public function testPlaceholderize() {
+ $getRelativePaths = new ReflectionMethod( ResourceLoaderModule::class, 'getRelativePaths' );
+ $getRelativePaths->setAccessible( true );
+ $expandRelativePaths = new ReflectionMethod( ResourceLoaderModule::class, 'expandRelativePaths' );
+ $expandRelativePaths->setAccessible( true );
+
+ $this->setMwGlobals( [
+ 'IP' => '/srv/example/mediawiki/core',
+ ] );
+ $raw = [
+ '/srv/example/mediawiki/core/resources/foo.js',
+ '/srv/example/mediawiki/core/extensions/Example/modules/bar.js',
+ '/srv/example/mediawiki/skins/Example/baz.css',
+ '/srv/example/mediawiki/skins/Example/images/quux.png',
+ ];
+ $canonical = [
+ 'resources/foo.js',
+ 'extensions/Example/modules/bar.js',
+ '../skins/Example/baz.css',
+ '../skins/Example/images/quux.png',
+ ];
+ $this->assertEquals(
+ $canonical,
+ $getRelativePaths->invoke( null, $raw ),
+ 'Insert placeholders'
+ );
+ $this->assertEquals(
+ $raw,
+ $expandRelativePaths->invoke( null, $canonical ),
+ 'Substitute placeholders'
+ );
+ }
+
+ /**
+ * @covers ResourceLoaderModule::getHeaders
+ * @covers ResourceLoaderModule::getPreloadLinks
+ */
+ public function testGetHeaders() {
+ $context = $this->getResourceLoaderContext();
+
+ $module = new ResourceLoaderTestModule();
+ $this->assertSame( [], $module->getHeaders( $context ), 'Default' );
+
+ $module = $this->getMockBuilder( ResourceLoaderTestModule::class )
+ ->setMethods( [ 'getPreloadLinks' ] )->getMock();
+ $module->method( 'getPreloadLinks' )->willReturn( [
+ 'https://example.org/script.js' => [ 'as' => 'script' ],
+ ] );
+ $this->assertSame(
+ [
+ 'Link: <https://example.org/script.js>;rel=preload;as=script'
+ ],
+ $module->getHeaders( $context ),
+ 'Preload one resource'
+ );
+
+ $module = $this->getMockBuilder( ResourceLoaderTestModule::class )
+ ->setMethods( [ 'getPreloadLinks' ] )->getMock();
+ $module->method( 'getPreloadLinks' )->willReturn( [
+ 'https://example.org/script.js' => [ 'as' => 'script' ],
+ '/example.png' => [ 'as' => 'image' ],
+ ] );
+ $this->assertSame(
+ [
+ 'Link: <https://example.org/script.js>;rel=preload;as=script,' .
+ '</example.png>;rel=preload;as=image'
+ ],
+ $module->getHeaders( $context ),
+ 'Preload two resources'
+ );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderOOUIImageModuleTest.php b/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderOOUIImageModuleTest.php
new file mode 100644
index 00000000..ea220f11
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderOOUIImageModuleTest.php
@@ -0,0 +1,65 @@
+<?php
+
+/**
+ * @group ResourceLoader
+ */
+class ResourceLoaderOOUIImageModuleTest extends ResourceLoaderTestCase {
+
+ /**
+ * @covers ResourceLoaderOOUIImageModule::loadFromDefinition
+ */
+ public function testNonDefaultSkin() {
+ $module = new ResourceLoaderOOUIImageModule( [
+ 'class' => ResourceLoaderOOUIImageModule::class,
+ 'name' => 'icons',
+ 'rootPath' => 'tests/phpunit/data/resourceloader/oouiimagemodule',
+ ] );
+
+ // Pretend that 'fakemonobook' is a real skin using the Apex theme
+ SkinFactory::getDefaultInstance()->register(
+ 'fakemonobook',
+ 'FakeMonoBook',
+ function () {
+ }
+ );
+ $r = new ReflectionMethod( ExtensionRegistry::class, 'exportExtractedData' );
+ $r->setAccessible( true );
+ $r->invoke( ExtensionRegistry::getInstance(), [
+ 'globals' => [],
+ 'defines' => [],
+ 'callbacks' => [],
+ 'credits' => [],
+ 'autoloaderPaths' => [],
+ 'attributes' => [
+ 'SkinOOUIThemes' => [
+ 'fakemonobook' => 'Apex',
+ ],
+ ],
+ ] );
+
+ $styles = $module->getStyles( $this->getResourceLoaderContext( [ 'skin' => 'fakemonobook' ] ) );
+ $this->assertRegExp(
+ '/stu-apex/',
+ $styles['all'],
+ 'Generated styles use the non-default image (embed)'
+ );
+ $this->assertRegExp(
+ '/fakemonobook/',
+ $styles['all'],
+ 'Generated styles use the non-default image (link)'
+ );
+
+ $styles = $module->getStyles( $this->getResourceLoaderContext() );
+ $this->assertRegExp(
+ '/stu-wikimediaui/',
+ $styles['all'],
+ 'Generated styles use the default image (embed)'
+ );
+ $this->assertRegExp(
+ '/vector/',
+ $styles['all'],
+ 'Generated styles use the default image (link)'
+ );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderSkinModuleTest.php b/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderSkinModuleTest.php
new file mode 100644
index 00000000..a1b14220
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderSkinModuleTest.php
@@ -0,0 +1,207 @@
+<?php
+
+/**
+ * @group ResourceLoader
+ */
+class ResourceLoaderSkinModuleTest extends PHPUnit\Framework\TestCase {
+
+ use MediaWikiCoversValidator;
+
+ public static function provideGetStyles() {
+ // phpcs:disable Generic.Files.LineLength
+ return [
+ [
+ 'parent' => [],
+ 'logo' => '/logo.png',
+ 'expected' => [
+ 'all' => [ '.mw-wiki-logo { background-image: url(/logo.png); }' ],
+ ],
+ ],
+ [
+ 'parent' => [
+ 'screen' => '.example {}',
+ ],
+ 'logo' => '/logo.png',
+ 'expected' => [
+ 'screen' => [ '.example {}' ],
+ 'all' => [ '.mw-wiki-logo { background-image: url(/logo.png); }' ],
+ ],
+ ],
+ [
+ 'parent' => [],
+ 'logo' => [
+ '1x' => '/logo.png',
+ '1.5x' => '/logo@1.5x.png',
+ '2x' => '/logo@2x.png',
+ ],
+ 'expected' => [
+ 'all' => [ <<<CSS
+.mw-wiki-logo { background-image: url(/logo.png); }
+CSS
+ ],
+ '(-webkit-min-device-pixel-ratio: 1.5), (min--moz-device-pixel-ratio: 1.5), (min-resolution: 1.5dppx), (min-resolution: 144dpi)' => [ <<<CSS
+.mw-wiki-logo { background-image: url(/logo@1.5x.png);background-size: 135px auto; }
+CSS
+ ],
+ '(-webkit-min-device-pixel-ratio: 2), (min--moz-device-pixel-ratio: 2), (min-resolution: 2dppx), (min-resolution: 192dpi)' => [ <<<CSS
+.mw-wiki-logo { background-image: url(/logo@2x.png);background-size: 135px auto; }
+CSS
+ ],
+ ],
+ ],
+ [
+ 'parent' => [],
+ 'logo' => [
+ '1x' => '/logo.png',
+ 'svg' => '/logo.svg',
+ ],
+ 'expected' => [
+ 'all' => [ <<<CSS
+.mw-wiki-logo { background-image: url(/logo.png); }
+CSS
+ , <<<CSS
+.mw-wiki-logo { background-image: -webkit-linear-gradient(transparent, transparent), url(/logo.svg); background-image: linear-gradient(transparent, transparent), url(/logo.svg);background-size: 135px auto; }
+CSS
+ ],
+ ],
+ ],
+ ];
+ // phpcs:enable
+ }
+
+ /**
+ * @dataProvider provideGetStyles
+ * @covers ResourceLoaderSkinModule::normalizeStyles
+ * @covers ResourceLoaderSkinModule::getStyles
+ */
+ public function testGetStyles( $parent, $logo, $expected ) {
+ $module = $this->getMockBuilder( ResourceLoaderSkinModule::class )
+ ->disableOriginalConstructor()
+ ->setMethods( [ 'readStyleFiles', 'getConfig', 'getLogoData' ] )
+ ->getMock();
+ $module->expects( $this->once() )->method( 'readStyleFiles' )
+ ->willReturn( $parent );
+ $module->expects( $this->once() )->method( 'getConfig' )
+ ->willReturn( new HashConfig() );
+ $module->expects( $this->once() )->method( 'getLogoData' )
+ ->willReturn( $logo );
+
+ $ctx = $this->getMockBuilder( ResourceLoaderContext::class )
+ ->disableOriginalConstructor()->getMock();
+
+ $this->assertEquals(
+ $expected,
+ $module->getStyles( $ctx )
+ );
+ }
+
+ /**
+ * @covers ResourceLoaderSkinModule::isKnownEmpty
+ */
+ public function testIsKnownEmpty() {
+ $module = $this->getMockBuilder( ResourceLoaderSkinModule::class )
+ ->disableOriginalConstructor()->setMethods( null )->getMock();
+ $ctx = $this->getMockBuilder( ResourceLoaderContext::class )
+ ->disableOriginalConstructor()->getMock();
+
+ $this->assertFalse( $module->isKnownEmpty( $ctx ) );
+ }
+
+ /**
+ * @dataProvider provideGetLogo
+ * @covers ResourceLoaderSkinModule::getLogo
+ */
+ public function testGetLogo( $config, $expected, $baseDir = null ) {
+ if ( $baseDir ) {
+ $oldIP = $GLOBALS['IP'];
+ $GLOBALS['IP'] = $baseDir;
+ $teardown = new Wikimedia\ScopedCallback( function () use ( $oldIP ) {
+ $GLOBALS['IP'] = $oldIP;
+ } );
+ }
+
+ $this->assertEquals(
+ $expected,
+ ResourceLoaderSkinModule::getLogo( new HashConfig( $config ) )
+ );
+ }
+
+ public function provideGetLogo() {
+ return [
+ 'simple' => [
+ 'config' => [
+ 'ResourceBasePath' => '/w',
+ 'Logo' => '/img/default.png',
+ 'LogoHD' => false,
+ ],
+ 'expected' => '/img/default.png',
+ ],
+ 'default and 2x' => [
+ 'config' => [
+ 'ResourceBasePath' => '/w',
+ 'Logo' => '/img/default.png',
+ 'LogoHD' => [
+ '2x' => '/img/two-x.png',
+ ],
+ ],
+ 'expected' => [
+ '1x' => '/img/default.png',
+ '2x' => '/img/two-x.png',
+ ],
+ ],
+ 'default and all HiDPIs' => [
+ 'config' => [
+ 'ResourceBasePath' => '/w',
+ 'Logo' => '/img/default.png',
+ 'LogoHD' => [
+ '1.5x' => '/img/one-point-five.png',
+ '2x' => '/img/two-x.png',
+ ],
+ ],
+ 'expected' => [
+ '1x' => '/img/default.png',
+ '1.5x' => '/img/one-point-five.png',
+ '2x' => '/img/two-x.png',
+ ],
+ ],
+ 'default and SVG' => [
+ 'config' => [
+ 'ResourceBasePath' => '/w',
+ 'Logo' => '/img/default.png',
+ 'LogoHD' => [
+ 'svg' => '/img/vector.svg',
+ ],
+ ],
+ 'expected' => [
+ '1x' => '/img/default.png',
+ 'svg' => '/img/vector.svg',
+ ],
+ ],
+ 'everything' => [
+ 'config' => [
+ 'ResourceBasePath' => '/w',
+ 'Logo' => '/img/default.png',
+ 'LogoHD' => [
+ '1.5x' => '/img/one-point-five.png',
+ '2x' => '/img/two-x.png',
+ 'svg' => '/img/vector.svg',
+ ],
+ ],
+ 'expected' => [
+ '1x' => '/img/default.png',
+ 'svg' => '/img/vector.svg',
+ ],
+ ],
+ 'versioned url' => [
+ 'config' => [
+ 'ResourceBasePath' => '/w',
+ 'Logo' => '/w/test.jpg',
+ 'LogoHD' => false,
+ 'UploadPath' => '/w/images',
+ ],
+ 'expected' => '/w/test.jpg?edcf2',
+ 'baseDir' => dirname( dirname( __DIR__ ) ) . '/data/media',
+ ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderStartUpModuleTest.php b/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderStartUpModuleTest.php
new file mode 100644
index 00000000..564f50bc
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderStartUpModuleTest.php
@@ -0,0 +1,494 @@
+<?php
+
+class ResourceLoaderStartUpModuleTest extends ResourceLoaderTestCase {
+
+ protected static function expandPlaceholders( $text ) {
+ return strtr( $text, [
+ '{blankVer}' => self::BLANK_VERSION
+ ] );
+ }
+
+ public function provideGetModuleRegistrations() {
+ return [
+ [ [
+ 'msg' => 'Empty registry',
+ 'modules' => [],
+ 'out' => '
+mw.loader.addSource( {
+ "local": "/w/load.php"
+} );
+mw.loader.register( [] );'
+ ] ],
+ [ [
+ 'msg' => 'Basic registry',
+ 'modules' => [
+ 'test.blank' => new ResourceLoaderTestModule(),
+ ],
+ 'out' => '
+mw.loader.addSource( {
+ "local": "/w/load.php"
+} );
+mw.loader.register( [
+ [
+ "test.blank",
+ "{blankVer}"
+ ]
+] );',
+ ] ],
+ [ [
+ 'msg' => 'Omit raw modules from registry',
+ 'modules' => [
+ 'test.raw' => new ResourceLoaderTestModule( [ 'isRaw' => true ] ),
+ 'test.blank' => new ResourceLoaderTestModule(),
+ ],
+ 'out' => '
+mw.loader.addSource( {
+ "local": "/w/load.php"
+} );
+mw.loader.register( [
+ [
+ "test.blank",
+ "{blankVer}"
+ ]
+] );',
+ ] ],
+ [ [
+ 'msg' => 'Version falls back gracefully if getVersionHash throws',
+ 'modules' => [
+ 'test.fail' => (
+ ( $mock = $this->getMockBuilder( ResourceLoaderTestModule::class )
+ ->setMethods( [ 'getVersionHash' ] )->getMock() )
+ && $mock->method( 'getVersionHash' )->will(
+ $this->throwException( new Exception )
+ )
+ ) ? $mock : $mock
+ ],
+ 'out' => '
+mw.loader.addSource( {
+ "local": "/w/load.php"
+} );
+mw.loader.register( [
+ [
+ "test.fail",
+ ""
+ ]
+] );
+mw.loader.state( {
+ "test.fail": "error"
+} );',
+ ] ],
+ [ [
+ 'msg' => 'Use version from getVersionHash',
+ 'modules' => [
+ 'test.version' => (
+ ( $mock = $this->getMockBuilder( ResourceLoaderTestModule::class )
+ ->setMethods( [ 'getVersionHash' ] )->getMock() )
+ && $mock->method( 'getVersionHash' )->willReturn( '1234567' )
+ ) ? $mock : $mock
+ ],
+ 'out' => '
+mw.loader.addSource( {
+ "local": "/w/load.php"
+} );
+mw.loader.register( [
+ [
+ "test.version",
+ "1234567"
+ ]
+] );',
+ ] ],
+ [ [
+ 'msg' => 'Re-hash version from getVersionHash if too long',
+ 'modules' => [
+ 'test.version' => (
+ ( $mock = $this->getMockBuilder( ResourceLoaderTestModule::class )
+ ->setMethods( [ 'getVersionHash' ] )->getMock() )
+ && $mock->method( 'getVersionHash' )->willReturn( '12345678' )
+ ) ? $mock : $mock
+ ],
+ 'out' => '
+mw.loader.addSource( {
+ "local": "/w/load.php"
+} );
+mw.loader.register( [
+ [
+ "test.version",
+ "016es8l"
+ ]
+] );',
+ ] ],
+ [ [
+ 'msg' => 'Group signature',
+ 'modules' => [
+ 'test.blank' => new ResourceLoaderTestModule(),
+ 'test.group.foo' => new ResourceLoaderTestModule( [ 'group' => 'x-foo' ] ),
+ 'test.group.bar' => new ResourceLoaderTestModule( [ 'group' => 'x-bar' ] ),
+ ],
+ 'out' => '
+mw.loader.addSource( {
+ "local": "/w/load.php"
+} );
+mw.loader.register( [
+ [
+ "test.blank",
+ "{blankVer}"
+ ],
+ [
+ "test.group.foo",
+ "{blankVer}",
+ [],
+ "x-foo"
+ ],
+ [
+ "test.group.bar",
+ "{blankVer}",
+ [],
+ "x-bar"
+ ]
+] );'
+ ] ],
+ [ [
+ 'msg' => 'Different target (non-test should not be registered)',
+ 'modules' => [
+ 'test.blank' => new ResourceLoaderTestModule(),
+ 'test.target.foo' => new ResourceLoaderTestModule( [ 'targets' => [ 'x-foo' ] ] ),
+ ],
+ 'out' => '
+mw.loader.addSource( {
+ "local": "/w/load.php"
+} );
+mw.loader.register( [
+ [
+ "test.blank",
+ "{blankVer}"
+ ]
+] );'
+ ] ],
+ [ [
+ 'msg' => 'Foreign source',
+ 'sources' => [
+ 'example' => [
+ 'loadScript' => 'http://example.org/w/load.php',
+ 'apiScript' => 'http://example.org/w/api.php',
+ ],
+ ],
+ 'modules' => [
+ 'test.blank' => new ResourceLoaderTestModule( [ 'source' => 'example' ] ),
+ ],
+ 'out' => '
+mw.loader.addSource( {
+ "local": "/w/load.php",
+ "example": "http://example.org/w/load.php"
+} );
+mw.loader.register( [
+ [
+ "test.blank",
+ "{blankVer}",
+ [],
+ null,
+ "example"
+ ]
+] );'
+ ] ],
+ [ [
+ 'msg' => 'Conditional dependency function',
+ 'modules' => [
+ 'test.x.core' => new ResourceLoaderTestModule(),
+ 'test.x.polyfill' => new ResourceLoaderTestModule( [
+ 'skipFunction' => 'return true;'
+ ] ),
+ 'test.y.polyfill' => new ResourceLoaderTestModule( [
+ 'skipFunction' =>
+ 'return !!(' .
+ ' window.JSON &&' .
+ ' JSON.parse &&' .
+ ' JSON.stringify' .
+ ');'
+ ] ),
+ 'test.z.foo' => new ResourceLoaderTestModule( [
+ 'dependencies' => [
+ 'test.x.core',
+ 'test.x.polyfill',
+ 'test.y.polyfill',
+ ],
+ ] ),
+ ],
+ 'out' => '
+mw.loader.addSource( {
+ "local": "/w/load.php"
+} );
+mw.loader.register( [
+ [
+ "test.x.core",
+ "{blankVer}"
+ ],
+ [
+ "test.x.polyfill",
+ "{blankVer}",
+ [],
+ null,
+ null,
+ "return true;"
+ ],
+ [
+ "test.y.polyfill",
+ "{blankVer}",
+ [],
+ null,
+ null,
+ "return !!( window.JSON \u0026\u0026 JSON.parse \u0026\u0026 JSON.stringify);"
+ ],
+ [
+ "test.z.foo",
+ "{blankVer}",
+ [
+ 0,
+ 1,
+ 2
+ ]
+ ]
+] );',
+ ] ],
+ [ [
+ // This may seem like an edge case, but a plain MediaWiki core install
+ // with a few extensions installed is likely far more complex than this
+ // even, not to mention an install like Wikipedia.
+ // TODO: Make this even more realistic.
+ 'msg' => 'Advanced (everything combined)',
+ 'sources' => [
+ 'example' => [
+ 'loadScript' => 'http://example.org/w/load.php',
+ 'apiScript' => 'http://example.org/w/api.php',
+ ],
+ ],
+ 'modules' => [
+ 'test.blank' => new ResourceLoaderTestModule(),
+ 'test.x.core' => new ResourceLoaderTestModule(),
+ 'test.x.util' => new ResourceLoaderTestModule( [
+ 'dependencies' => [
+ 'test.x.core',
+ ],
+ ] ),
+ 'test.x.foo' => new ResourceLoaderTestModule( [
+ 'dependencies' => [
+ 'test.x.core',
+ ],
+ ] ),
+ 'test.x.bar' => new ResourceLoaderTestModule( [
+ 'dependencies' => [
+ 'test.x.core',
+ 'test.x.util',
+ ],
+ ] ),
+ 'test.x.quux' => new ResourceLoaderTestModule( [
+ 'dependencies' => [
+ 'test.x.foo',
+ 'test.x.bar',
+ 'test.x.util',
+ 'test.x.unknown',
+ ],
+ ] ),
+ 'test.group.foo.1' => new ResourceLoaderTestModule( [
+ 'group' => 'x-foo',
+ ] ),
+ 'test.group.foo.2' => new ResourceLoaderTestModule( [
+ 'group' => 'x-foo',
+ ] ),
+ 'test.group.bar.1' => new ResourceLoaderTestModule( [
+ 'group' => 'x-bar',
+ ] ),
+ 'test.group.bar.2' => new ResourceLoaderTestModule( [
+ 'group' => 'x-bar',
+ 'source' => 'example',
+ ] ),
+ 'test.target.foo' => new ResourceLoaderTestModule( [
+ 'targets' => [ 'x-foo' ],
+ ] ),
+ 'test.target.bar' => new ResourceLoaderTestModule( [
+ 'source' => 'example',
+ 'targets' => [ 'x-foo' ],
+ ] ),
+ ],
+ 'out' => '
+mw.loader.addSource( {
+ "local": "/w/load.php",
+ "example": "http://example.org/w/load.php"
+} );
+mw.loader.register( [
+ [
+ "test.blank",
+ "{blankVer}"
+ ],
+ [
+ "test.x.core",
+ "{blankVer}"
+ ],
+ [
+ "test.x.util",
+ "{blankVer}",
+ [
+ 1
+ ]
+ ],
+ [
+ "test.x.foo",
+ "{blankVer}",
+ [
+ 1
+ ]
+ ],
+ [
+ "test.x.bar",
+ "{blankVer}",
+ [
+ 2
+ ]
+ ],
+ [
+ "test.x.quux",
+ "{blankVer}",
+ [
+ 3,
+ 4,
+ "test.x.unknown"
+ ]
+ ],
+ [
+ "test.group.foo.1",
+ "{blankVer}",
+ [],
+ "x-foo"
+ ],
+ [
+ "test.group.foo.2",
+ "{blankVer}",
+ [],
+ "x-foo"
+ ],
+ [
+ "test.group.bar.1",
+ "{blankVer}",
+ [],
+ "x-bar"
+ ],
+ [
+ "test.group.bar.2",
+ "{blankVer}",
+ [],
+ "x-bar",
+ "example"
+ ]
+] );'
+ ] ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetModuleRegistrations
+ * @covers ResourceLoaderStartUpModule::getModuleRegistrations
+ * @covers ResourceLoaderStartUpModule::compileUnresolvedDependencies
+ * @covers ResourceLoader::makeLoaderRegisterScript
+ */
+ public function testGetModuleRegistrations( $case ) {
+ if ( isset( $case['sources'] ) ) {
+ $this->setMwGlobals( 'wgResourceLoaderSources', $case['sources'] );
+ }
+
+ $context = $this->getResourceLoaderContext();
+ $rl = $context->getResourceLoader();
+ $rl->register( $case['modules'] );
+ $module = new ResourceLoaderStartUpModule();
+ $out = ltrim( $case['out'], "\n" );
+
+ // Disable log from getModuleRegistrations via MWExceptionHandler
+ // for case where getVersionHash() is expected to throw.
+ $this->setLogger( 'exception', new Psr\Log\NullLogger() );
+
+ $this->assertEquals(
+ self::expandPlaceholders( $out ),
+ $module->getModuleRegistrations( $context ),
+ $case['msg']
+ );
+ }
+
+ public static function provideRegistrations() {
+ return [
+ [ [
+ 'test.blank' => new ResourceLoaderTestModule(),
+ 'test.min' => new ResourceLoaderTestModule( [
+ 'skipFunction' =>
+ 'return !!(' .
+ ' window.JSON &&' .
+ ' JSON.parse &&' .
+ ' JSON.stringify' .
+ ');',
+ 'dependencies' => [
+ 'test.blank',
+ ],
+ ] ),
+ ] ]
+ ];
+ }
+ /**
+ * @covers ResourceLoaderStartUpModule::getModuleRegistrations
+ * @dataProvider provideRegistrations
+ */
+ public function testRegistrationsMinified( $modules ) {
+ $this->setMwGlobals( 'wgResourceLoaderDebug', false );
+
+ $context = $this->getResourceLoaderContext();
+ $rl = $context->getResourceLoader();
+ $rl->register( $modules );
+ $module = new ResourceLoaderStartUpModule();
+ $out = 'mw.loader.addSource({"local":"/w/load.php"});' . "\n"
+ . 'mw.loader.register(['
+ . '["test.blank","{blankVer}"],'
+ . '["test.min","{blankVer}",[0],null,null,'
+ . '"return!!(window.JSON\u0026\u0026JSON.parse\u0026\u0026JSON.stringify);"'
+ . ']]);';
+
+ $this->assertEquals(
+ self::expandPlaceholders( $out ),
+ $module->getModuleRegistrations( $context ),
+ 'Minified output'
+ );
+ }
+
+ /**
+ * @covers ResourceLoaderStartUpModule::getModuleRegistrations
+ * @dataProvider provideRegistrations
+ */
+ public function testRegistrationsUnminified( $modules ) {
+ $context = $this->getResourceLoaderContext();
+ $rl = $context->getResourceLoader();
+ $rl->register( $modules );
+ $module = new ResourceLoaderStartUpModule();
+ $out =
+'mw.loader.addSource( {
+ "local": "/w/load.php"
+} );
+mw.loader.register( [
+ [
+ "test.blank",
+ "{blankVer}"
+ ],
+ [
+ "test.min",
+ "{blankVer}",
+ [
+ 0
+ ],
+ null,
+ null,
+ "return !!( window.JSON \u0026\u0026 JSON.parse \u0026\u0026 JSON.stringify);"
+ ]
+] );';
+
+ $this->assertEquals(
+ self::expandPlaceholders( $out ),
+ $module->getModuleRegistrations( $context ),
+ 'Unminified output'
+ );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderTest.php b/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderTest.php
new file mode 100644
index 00000000..4e9f5399
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderTest.php
@@ -0,0 +1,911 @@
+<?php
+
+use Wikimedia\TestingAccessWrapper;
+
+class ResourceLoaderTest extends ResourceLoaderTestCase {
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->setMwGlobals( [
+ 'wgResourceLoaderLESSImportPaths' => [
+ dirname( dirname( __DIR__ ) ) . '/data/less/common',
+ ],
+ 'wgResourceLoaderLESSVars' => [
+ 'foo' => '2px',
+ 'Foo' => '#eeeeee',
+ 'bar' => 5,
+ ],
+ // Clear ResourceLoaderGetConfigVars hooks (called by StartupModule)
+ // to avoid notices during testMakeModuleResponse for missing
+ // wgResourceLoaderLESSVars keys in extension hooks.
+ 'wgHooks' => [],
+ 'wgShowExceptionDetails' => true,
+ ] );
+ }
+
+ /**
+ * Ensure the ResourceLoaderRegisterModules hook is called.
+ *
+ * @covers ResourceLoader::__construct
+ */
+ public function testConstructRegistrationHook() {
+ $resourceLoaderRegisterModulesHook = false;
+
+ $this->setMwGlobals( 'wgHooks', [
+ 'ResourceLoaderRegisterModules' => [
+ function ( &$resourceLoader ) use ( &$resourceLoaderRegisterModulesHook ) {
+ $resourceLoaderRegisterModulesHook = true;
+ }
+ ]
+ ] );
+
+ $unused = new ResourceLoader();
+ $this->assertTrue(
+ $resourceLoaderRegisterModulesHook,
+ 'Hook ResourceLoaderRegisterModules called'
+ );
+ }
+
+ /**
+ * @covers ResourceLoader::register
+ * @covers ResourceLoader::getModule
+ */
+ public function testRegisterValidObject() {
+ $module = new ResourceLoaderTestModule();
+ $resourceLoader = new EmptyResourceLoader();
+ $resourceLoader->register( 'test', $module );
+ $this->assertEquals( $module, $resourceLoader->getModule( 'test' ) );
+ }
+
+ /**
+ * @covers ResourceLoader::register
+ * @covers ResourceLoader::getModule
+ */
+ public function testRegisterValidArray() {
+ $module = new ResourceLoaderTestModule();
+ $resourceLoader = new EmptyResourceLoader();
+ // Covers case of register() setting $rl->moduleInfos,
+ // but $rl->modules lazy-populated by getModule()
+ $resourceLoader->register( 'test', [ 'object' => $module ] );
+ $this->assertEquals( $module, $resourceLoader->getModule( 'test' ) );
+ }
+
+ /**
+ * @covers ResourceLoader::register
+ */
+ public function testRegisterEmptyString() {
+ $module = new ResourceLoaderTestModule();
+ $resourceLoader = new EmptyResourceLoader();
+ $resourceLoader->register( '', $module );
+ $this->assertEquals( $module, $resourceLoader->getModule( '' ) );
+ }
+
+ /**
+ * @covers ResourceLoader::register
+ */
+ public function testRegisterInvalidName() {
+ $resourceLoader = new EmptyResourceLoader();
+ $this->setExpectedException( MWException::class, "name 'test!invalid' is invalid" );
+ $resourceLoader->register( 'test!invalid', new ResourceLoaderTestModule() );
+ }
+
+ /**
+ * @covers ResourceLoader::register
+ */
+ public function testRegisterInvalidType() {
+ $resourceLoader = new EmptyResourceLoader();
+ $this->setExpectedException( MWException::class, 'ResourceLoader module info type error' );
+ $resourceLoader->register( 'test', new stdClass() );
+ }
+
+ /**
+ * @covers ResourceLoader::getModuleNames
+ */
+ public function testGetModuleNames() {
+ // Use an empty one so that core and extension modules don't get in.
+ $resourceLoader = new EmptyResourceLoader();
+ $resourceLoader->register( 'test.foo', new ResourceLoaderTestModule() );
+ $resourceLoader->register( 'test.bar', new ResourceLoaderTestModule() );
+ $this->assertEquals(
+ [ 'test.foo', 'test.bar' ],
+ $resourceLoader->getModuleNames()
+ );
+ }
+
+ public function provideTestIsFileModule() {
+ $fileModuleObj = $this->getMockBuilder( ResourceLoaderFileModule::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ return [
+ 'object' => [ false,
+ new ResourceLoaderTestModule()
+ ],
+ 'FileModule object' => [ false,
+ $fileModuleObj
+ ],
+ 'simple empty' => [ true,
+ []
+ ],
+ 'simple scripts' => [ true,
+ [ 'scripts' => 'example.js' ]
+ ],
+ 'simple scripts, raw and targets' => [ true, [
+ 'scripts' => [ 'a.js', 'b.js' ],
+ 'raw' => true,
+ 'targets' => [ 'desktop', 'mobile' ],
+ ] ],
+ 'FileModule' => [ true,
+ [ 'class' => ResourceLoaderFileModule::class, 'scripts' => 'example.js' ]
+ ],
+ 'TestModule' => [ false,
+ [ 'class' => ResourceLoaderTestModule::class, 'scripts' => 'example.js' ]
+ ],
+ 'SkinModule (FileModule subclass)' => [ true,
+ [ 'class' => ResourceLoaderSkinModule::class, 'scripts' => 'example.js' ]
+ ],
+ 'JqueryMsgModule (FileModule subclass)' => [ true, [
+ 'class' => ResourceLoaderJqueryMsgModule::class,
+ 'scripts' => 'example.js',
+ ] ],
+ 'WikiModule' => [ false, [
+ 'class' => ResourceLoaderWikiModule::class,
+ 'scripts' => [ 'MediaWiki:Example.js' ],
+ ] ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideTestIsFileModule
+ * @covers ResourceLoader::isFileModule
+ */
+ public function testIsFileModule( $expected, $module ) {
+ $rl = TestingAccessWrapper::newFromObject( new EmptyResourceLoader() );
+ $rl->register( 'test', $module );
+ $this->assertSame( $expected, $rl->isFileModule( 'test' ) );
+ }
+
+ /**
+ * @covers ResourceLoader::isFileModule
+ */
+ public function testIsFileModuleUnknown() {
+ $rl = TestingAccessWrapper::newFromObject( new EmptyResourceLoader() );
+ $this->assertSame( false, $rl->isFileModule( 'unknown' ) );
+ }
+
+ /**
+ * @covers ResourceLoader::isModuleRegistered
+ */
+ public function testIsModuleRegistered() {
+ $rl = new EmptyResourceLoader();
+ $rl->register( 'test', new ResourceLoaderTestModule() );
+ $this->assertTrue( $rl->isModuleRegistered( 'test' ) );
+ $this->assertFalse( $rl->isModuleRegistered( 'test.unknown' ) );
+ }
+
+ /**
+ * @covers ResourceLoader::getModule
+ */
+ public function testGetModuleUnknown() {
+ $rl = new EmptyResourceLoader();
+ $this->assertSame( null, $rl->getModule( 'test' ) );
+ }
+
+ /**
+ * @covers ResourceLoader::getModule
+ */
+ public function testGetModuleClass() {
+ $rl = new EmptyResourceLoader();
+ $rl->register( 'test', [ 'class' => ResourceLoaderTestModule::class ] );
+ $this->assertInstanceOf(
+ ResourceLoaderTestModule::class,
+ $rl->getModule( 'test' )
+ );
+ }
+
+ /**
+ * @covers ResourceLoader::getModule
+ */
+ public function testGetModuleFactory() {
+ $factory = function ( array $info ) {
+ $this->assertArrayHasKey( 'kitten', $info );
+ return new ResourceLoaderTestModule( $info );
+ };
+
+ $rl = new EmptyResourceLoader();
+ $rl->register( 'test', [ 'factory' => $factory, 'kitten' => 'little ball of fur' ] );
+ $this->assertInstanceOf(
+ ResourceLoaderTestModule::class,
+ $rl->getModule( 'test' )
+ );
+ }
+
+ /**
+ * @covers ResourceLoader::getModule
+ */
+ public function testGetModuleClassDefault() {
+ $rl = new EmptyResourceLoader();
+ $rl->register( 'test', [] );
+ $this->assertInstanceOf(
+ ResourceLoaderFileModule::class,
+ $rl->getModule( 'test' ),
+ 'Array-style module registrations default to FileModule'
+ );
+ }
+
+ /**
+ * @covers ResourceLoaderFileModule::compileLessFile
+ */
+ public function testLessFileCompilation() {
+ $context = $this->getResourceLoaderContext();
+ $basePath = __DIR__ . '/../../data/less/module';
+ $module = new ResourceLoaderFileModule( [
+ 'localBasePath' => $basePath,
+ 'styles' => [ 'styles.less' ],
+ ] );
+ $module->setName( 'test.less' );
+ $styles = $module->getStyles( $context );
+ $this->assertStringEqualsFile( $basePath . '/styles.css', $styles['all'] );
+ }
+
+ public static function providePackedModules() {
+ return [
+ [
+ 'Example from makePackedModulesString doc comment',
+ [ 'foo.bar', 'foo.baz', 'bar.baz', 'bar.quux' ],
+ 'foo.bar,baz|bar.baz,quux',
+ ],
+ [
+ 'Example from expandModuleNames doc comment',
+ [ 'jquery.foo', 'jquery.bar', 'jquery.ui.baz', 'jquery.ui.quux' ],
+ 'jquery.foo,bar|jquery.ui.baz,quux',
+ ],
+ [
+ 'Regression fixed in r87497 (7fee86c38e) with dotless names',
+ [ 'foo', 'bar', 'baz' ],
+ 'foo,bar,baz',
+ ],
+ [
+ 'Prefixless modules after a prefixed module',
+ [ 'single.module', 'foobar', 'foobaz' ],
+ 'single.module|foobar,foobaz',
+ ],
+ [
+ 'Ordering',
+ [ 'foo', 'foo.baz', 'baz.quux', 'foo.bar' ],
+ 'foo|foo.baz,bar|baz.quux',
+ [ 'foo', 'foo.baz', 'foo.bar', 'baz.quux' ],
+ ]
+ ];
+ }
+
+ /**
+ * @dataProvider providePackedModules
+ * @covers ResourceLoader::makePackedModulesString
+ */
+ public function testMakePackedModulesString( $desc, $modules, $packed ) {
+ $this->assertEquals( $packed, ResourceLoader::makePackedModulesString( $modules ), $desc );
+ }
+
+ /**
+ * @dataProvider providePackedModules
+ * @covers ResourceLoaderContext::expandModuleNames
+ */
+ public function testExpandModuleNames( $desc, $modules, $packed, $unpacked = null ) {
+ $this->assertEquals(
+ $unpacked ?: $modules,
+ ResourceLoaderContext::expandModuleNames( $packed ),
+ $desc
+ );
+ }
+
+ public static function provideAddSource() {
+ return [
+ [ 'foowiki', 'https://example.org/w/load.php', 'foowiki' ],
+ [ 'foowiki', [ 'loadScript' => 'https://example.org/w/load.php' ], 'foowiki' ],
+ [
+ [
+ 'foowiki' => 'https://example.org/w/load.php',
+ 'bazwiki' => 'https://example.com/w/load.php',
+ ],
+ null,
+ [ 'foowiki', 'bazwiki' ]
+ ]
+ ];
+ }
+
+ /**
+ * @dataProvider provideAddSource
+ * @covers ResourceLoader::addSource
+ * @covers ResourceLoader::getSources
+ */
+ public function testAddSource( $name, $info, $expected ) {
+ $rl = new ResourceLoader;
+ $rl->addSource( $name, $info );
+ if ( is_array( $expected ) ) {
+ foreach ( $expected as $source ) {
+ $this->assertArrayHasKey( $source, $rl->getSources() );
+ }
+ } else {
+ $this->assertArrayHasKey( $expected, $rl->getSources() );
+ }
+ }
+
+ /**
+ * @covers ResourceLoader::addSource
+ */
+ public function testAddSourceDupe() {
+ $rl = new ResourceLoader;
+ $this->setExpectedException(
+ MWException::class, 'ResourceLoader duplicate source addition error'
+ );
+ $rl->addSource( 'foo', 'https://example.org/w/load.php' );
+ $rl->addSource( 'foo', 'https://example.com/w/load.php' );
+ }
+
+ /**
+ * @covers ResourceLoader::addSource
+ */
+ public function testAddSourceInvalid() {
+ $rl = new ResourceLoader;
+ $this->setExpectedException( MWException::class, 'with no "loadScript" key' );
+ $rl->addSource( 'foo', [ 'x' => 'https://example.org/w/load.php' ] );
+ }
+
+ public static function provideLoaderImplement() {
+ return [
+ [ [
+ 'title' => 'Implement scripts, styles and messages',
+
+ 'name' => 'test.example',
+ 'scripts' => 'mw.example();',
+ 'styles' => [ 'css' => [ '.mw-example {}' ] ],
+ 'messages' => [ 'example' => '' ],
+ 'templates' => [],
+
+ 'expected' => 'mw.loader.implement( "test.example", function ( $, jQuery, require, module ) {
+mw.example();
+}, {
+ "css": [
+ ".mw-example {}"
+ ]
+}, {
+ "example": ""
+} );',
+ ] ],
+ [ [
+ 'title' => 'Implement scripts',
+
+ 'name' => 'test.example',
+ 'scripts' => 'mw.example();',
+ 'styles' => [],
+
+ 'expected' => 'mw.loader.implement( "test.example", function ( $, jQuery, require, module ) {
+mw.example();
+} );',
+ ] ],
+ [ [
+ 'title' => 'Implement styles',
+
+ 'name' => 'test.example',
+ 'scripts' => [],
+ 'styles' => [ 'css' => [ '.mw-example {}' ] ],
+
+ 'expected' => 'mw.loader.implement( "test.example", [], {
+ "css": [
+ ".mw-example {}"
+ ]
+} );',
+ ] ],
+ [ [
+ 'title' => 'Implement scripts and messages',
+
+ 'name' => 'test.example',
+ 'scripts' => 'mw.example();',
+ 'messages' => [ 'example' => '' ],
+
+ 'expected' => 'mw.loader.implement( "test.example", function ( $, jQuery, require, module ) {
+mw.example();
+}, {}, {
+ "example": ""
+} );',
+ ] ],
+ [ [
+ 'title' => 'Implement scripts and templates',
+
+ 'name' => 'test.example',
+ 'scripts' => 'mw.example();',
+ 'templates' => [ 'example.html' => '' ],
+
+ 'expected' => 'mw.loader.implement( "test.example", function ( $, jQuery, require, module ) {
+mw.example();
+}, {}, {}, {
+ "example.html": ""
+} );',
+ ] ],
+ [ [
+ 'title' => 'Implement unwrapped user script',
+
+ 'name' => 'user',
+ 'scripts' => 'mw.example( 1 );',
+ 'wrap' => false,
+
+ 'expected' => 'mw.loader.implement( "user", "mw.example( 1 );" );',
+ ] ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideLoaderImplement
+ * @covers ResourceLoader::makeLoaderImplementScript
+ * @covers ResourceLoader::trimArray
+ */
+ public function testMakeLoaderImplementScript( $case ) {
+ $case += [
+ 'wrap' => true,
+ 'styles' => [], 'templates' => [], 'messages' => new XmlJsCode( '{}' )
+ ];
+ ResourceLoader::clearCache();
+ $this->setMwGlobals( 'wgResourceLoaderDebug', true );
+
+ $rl = TestingAccessWrapper::newFromClass( ResourceLoader::class );
+ $this->assertEquals(
+ $case['expected'],
+ $rl->makeLoaderImplementScript(
+ $case['name'],
+ ( $case['wrap'] && is_string( $case['scripts'] ) )
+ ? new XmlJsCode( $case['scripts'] )
+ : $case['scripts'],
+ $case['styles'],
+ $case['messages'],
+ $case['templates']
+ )
+ );
+ }
+
+ /**
+ * @covers ResourceLoader::makeLoaderImplementScript
+ */
+ public function testMakeLoaderImplementScriptInvalid() {
+ $this->setExpectedException( MWException::class, 'Invalid scripts error' );
+ $rl = TestingAccessWrapper::newFromClass( ResourceLoader::class );
+ $rl->makeLoaderImplementScript(
+ 'test', // name
+ 123, // scripts
+ null, // styles
+ null, // messages
+ null // templates
+ );
+ }
+
+ /**
+ * @covers ResourceLoader::makeLoaderRegisterScript
+ */
+ public function testMakeLoaderRegisterScript() {
+ $this->assertEquals(
+ 'mw.loader.register( [
+ [
+ "test.name",
+ "1234567"
+ ]
+] );',
+ ResourceLoader::makeLoaderRegisterScript( [
+ [ 'test.name', '1234567' ],
+ ] ),
+ 'Nested array parameter'
+ );
+
+ $this->assertEquals(
+ 'mw.loader.register( "test.name", "1234567" );',
+ ResourceLoader::makeLoaderRegisterScript(
+ 'test.name',
+ '1234567'
+ ),
+ 'Variadic parameters'
+ );
+ }
+
+ /**
+ * @covers ResourceLoader::makeLoaderSourcesScript
+ */
+ public function testMakeLoaderSourcesScript() {
+ $this->assertEquals(
+ 'mw.loader.addSource( "local", "/w/load.php" );',
+ ResourceLoader::makeLoaderSourcesScript( 'local', '/w/load.php' )
+ );
+ $this->assertEquals(
+ 'mw.loader.addSource( {
+ "local": "/w/load.php"
+} );',
+ ResourceLoader::makeLoaderSourcesScript( [ 'local' => '/w/load.php' ] )
+ );
+ $this->assertEquals(
+ 'mw.loader.addSource( {
+ "local": "/w/load.php",
+ "example": "https://example.org/w/load.php"
+} );',
+ ResourceLoader::makeLoaderSourcesScript( [
+ 'local' => '/w/load.php',
+ 'example' => 'https://example.org/w/load.php'
+ ] )
+ );
+ $this->assertEquals(
+ 'mw.loader.addSource( [] );',
+ ResourceLoader::makeLoaderSourcesScript( [] )
+ );
+ }
+
+ private static function fakeSources() {
+ return [
+ 'examplewiki' => [
+ 'loadScript' => '//example.org/w/load.php',
+ 'apiScript' => '//example.org/w/api.php',
+ ],
+ 'example2wiki' => [
+ 'loadScript' => '//example.com/w/load.php',
+ 'apiScript' => '//example.com/w/api.php',
+ ],
+ ];
+ }
+
+ /**
+ * @covers ResourceLoader::getLoadScript
+ */
+ public function testGetLoadScript() {
+ $this->setMwGlobals( 'wgResourceLoaderSources', [] );
+ $rl = new ResourceLoader();
+ $sources = self::fakeSources();
+ $rl->addSource( $sources );
+ foreach ( [ 'examplewiki', 'example2wiki' ] as $name ) {
+ $this->assertEquals( $rl->getLoadScript( $name ), $sources[$name]['loadScript'] );
+ }
+
+ try {
+ $rl->getLoadScript( 'thiswasneverreigstered' );
+ $this->assertTrue( false, 'ResourceLoader::getLoadScript should have thrown an exception' );
+ } catch ( MWException $e ) {
+ $this->assertTrue( true );
+ }
+ }
+
+ protected function getFailFerryMock() {
+ $mock = $this->getMockBuilder( ResourceLoaderTestModule::class )
+ ->setMethods( [ 'getScript' ] )
+ ->getMock();
+ $mock->method( 'getScript' )->will( $this->throwException(
+ new Exception( 'Ferry not found' )
+ ) );
+ return $mock;
+ }
+
+ protected function getSimpleModuleMock( $script = '' ) {
+ $mock = $this->getMockBuilder( ResourceLoaderTestModule::class )
+ ->setMethods( [ 'getScript' ] )
+ ->getMock();
+ $mock->method( 'getScript' )->willReturn( $script );
+ return $mock;
+ }
+
+ /**
+ * @covers ResourceLoader::getCombinedVersion
+ */
+ public function testGetCombinedVersion() {
+ $rl = $this->getMockBuilder( EmptyResourceLoader::class )
+ // Disable log from outputErrorAndLog
+ ->setMethods( [ 'outputErrorAndLog' ] )->getMock();
+ $rl->register( [
+ 'foo' => self::getSimpleModuleMock(),
+ 'ferry' => self::getFailFerryMock(),
+ 'bar' => self::getSimpleModuleMock(),
+ ] );
+ $context = $this->getResourceLoaderContext( [], $rl );
+
+ $this->assertEquals(
+ '',
+ $rl->getCombinedVersion( $context, [] ),
+ 'empty list'
+ );
+
+ $this->assertEquals(
+ ResourceLoader::makeHash( self::BLANK_VERSION ),
+ $rl->getCombinedVersion( $context, [ 'foo' ] ),
+ 'compute foo'
+ );
+
+ // Verify that getCombinedVersion() does not throw when ferry fails.
+ // Instead it gracefully continues to combine the remaining modules.
+ $this->assertEquals(
+ ResourceLoader::makeHash( self::BLANK_VERSION . self::BLANK_VERSION ),
+ $rl->getCombinedVersion( $context, [ 'foo', 'ferry', 'bar' ] ),
+ 'compute foo+ferry+bar (T152266)'
+ );
+ }
+
+ public static function provideMakeModuleResponseConcat() {
+ $testcases = [
+ [
+ 'modules' => [
+ 'foo' => 'foo()',
+ ],
+ 'expected' => "foo()\n" . 'mw.loader.state( {
+ "foo": "ready"
+} );',
+ 'minified' => "foo()\n" . 'mw.loader.state({"foo":"ready"});',
+ 'message' => 'Script without semi-colon',
+ ],
+ [
+ 'modules' => [
+ 'foo' => 'foo()',
+ 'bar' => 'bar()',
+ ],
+ 'expected' => "foo()\nbar()\n" . 'mw.loader.state( {
+ "foo": "ready",
+ "bar": "ready"
+} );',
+ 'minified' => "foo()\nbar()\n" . 'mw.loader.state({"foo":"ready","bar":"ready"});',
+ 'message' => 'Two scripts without semi-colon',
+ ],
+ [
+ 'modules' => [
+ 'foo' => "foo()\n// bar();"
+ ],
+ 'expected' => "foo()\n// bar();\n" . 'mw.loader.state( {
+ "foo": "ready"
+} );',
+ 'minified' => "foo()\n" . 'mw.loader.state({"foo":"ready"});',
+ 'message' => 'Script with semi-colon in comment (T162719)',
+ ],
+ ];
+ $ret = [];
+ foreach ( $testcases as $i => $case ) {
+ $ret["#$i"] = [
+ $case['modules'],
+ $case['expected'],
+ true, // debug
+ $case['message'],
+ ];
+ $ret["#$i (minified)"] = [
+ $case['modules'],
+ $case['minified'],
+ false, // debug
+ $case['message'],
+ ];
+ }
+ return $ret;
+ }
+
+ /**
+ * Verify how multiple scripts and mw.loader.state() calls are concatenated.
+ *
+ * @dataProvider provideMakeModuleResponseConcat
+ * @covers ResourceLoader::makeModuleResponse
+ */
+ public function testMakeModuleResponseConcat( $scripts, $expected, $debug, $message = null ) {
+ $rl = new EmptyResourceLoader();
+ $modules = array_map( function ( $script ) {
+ return self::getSimpleModuleMock( $script );
+ }, $scripts );
+ $rl->register( $modules );
+
+ $this->setMwGlobals( 'wgResourceLoaderDebug', $debug );
+ $context = $this->getResourceLoaderContext(
+ [
+ 'modules' => implode( '|', array_keys( $modules ) ),
+ 'only' => 'scripts',
+ ],
+ $rl
+ );
+
+ $response = $rl->makeModuleResponse( $context, $modules );
+ $this->assertSame( [], $rl->getErrors(), 'Errors' );
+ $this->assertEquals( $expected, $response, $message ?: 'Response' );
+ }
+
+ /**
+ * Verify that when building module content in a load.php response,
+ * an exception from one module will not break script output from
+ * other modules.
+ *
+ * @covers ResourceLoader::makeModuleResponse
+ */
+ public function testMakeModuleResponseError() {
+ $modules = [
+ 'foo' => self::getSimpleModuleMock( 'foo();' ),
+ 'ferry' => self::getFailFerryMock(),
+ 'bar' => self::getSimpleModuleMock( 'bar();' ),
+ ];
+ $rl = new EmptyResourceLoader();
+ $rl->register( $modules );
+ $context = $this->getResourceLoaderContext(
+ [
+ 'modules' => 'foo|ferry|bar',
+ 'only' => 'scripts',
+ ],
+ $rl
+ );
+
+ // Disable log from makeModuleResponse via outputErrorAndLog
+ $this->setLogger( 'exception', new Psr\Log\NullLogger() );
+
+ $response = $rl->makeModuleResponse( $context, $modules );
+ $errors = $rl->getErrors();
+
+ $this->assertCount( 1, $errors );
+ $this->assertRegExp( '/Ferry not found/', $errors[0] );
+ $this->assertEquals(
+ "foo();\nbar();\n" . 'mw.loader.state( {
+ "ferry": "error",
+ "foo": "ready",
+ "bar": "ready"
+} );',
+ $response
+ );
+ }
+
+ /**
+ * Verify that when building the startup module response,
+ * an exception from one module class will not break the entire
+ * startup module response. See T152266.
+ *
+ * @covers ResourceLoader::makeModuleResponse
+ */
+ public function testMakeModuleResponseStartupError() {
+ $rl = new EmptyResourceLoader();
+ $rl->register( [
+ 'foo' => self::getSimpleModuleMock( 'foo();' ),
+ 'ferry' => self::getFailFerryMock(),
+ 'bar' => self::getSimpleModuleMock( 'bar();' ),
+ 'startup' => [ 'class' => ResourceLoaderStartUpModule::class ],
+ ] );
+ $context = $this->getResourceLoaderContext(
+ [
+ 'modules' => 'startup',
+ 'only' => 'scripts',
+ ],
+ $rl
+ );
+
+ $this->assertEquals(
+ [ 'foo', 'ferry', 'bar', 'startup' ],
+ $rl->getModuleNames(),
+ 'getModuleNames'
+ );
+
+ // Disable log from makeModuleResponse via outputErrorAndLog
+ $this->setLogger( 'exception', new Psr\Log\NullLogger() );
+
+ $modules = [ 'startup' => $rl->getModule( 'startup' ) ];
+ $response = $rl->makeModuleResponse( $context, $modules );
+ $errors = $rl->getErrors();
+
+ $this->assertRegExp( '/Ferry not found/', $errors[0] );
+ $this->assertCount( 1, $errors );
+ $this->assertRegExp(
+ '/isCompatible.*function startUp/s',
+ $response,
+ 'startup response undisrupted (T152266)'
+ );
+ $this->assertRegExp(
+ '/register\([^)]+"ferry",\s*""/s',
+ $response,
+ 'startup response registers broken module'
+ );
+ $this->assertRegExp(
+ '/state\([^)]+"ferry":\s*"error"/s',
+ $response,
+ 'startup response sets state to error'
+ );
+ }
+
+ /**
+ * Integration test for modules sending extra HTTP response headers.
+ *
+ * @covers ResourceLoaderModule::getHeaders
+ * @covers ResourceLoaderModule::buildContent
+ * @covers ResourceLoader::makeModuleResponse
+ */
+ public function testMakeModuleResponseExtraHeaders() {
+ $module = $this->getMockBuilder( ResourceLoaderTestModule::class )
+ ->setMethods( [ 'getPreloadLinks' ] )->getMock();
+ $module->method( 'getPreloadLinks' )->willReturn( [
+ 'https://example.org/script.js' => [ 'as' => 'script' ],
+ ] );
+
+ $rl = new EmptyResourceLoader();
+ $rl->register( [
+ 'foo' => $module,
+ ] );
+ $context = $this->getResourceLoaderContext(
+ [ 'modules' => 'foo', 'only' => 'scripts' ],
+ $rl
+ );
+
+ $modules = [ 'foo' => $rl->getModule( 'foo' ) ];
+ $response = $rl->makeModuleResponse( $context, $modules );
+ $extraHeaders = TestingAccessWrapper::newFromObject( $rl )->extraHeaders;
+
+ $this->assertEquals(
+ [
+ 'Link: <https://example.org/script.js>;rel=preload;as=script'
+ ],
+ $extraHeaders,
+ 'Extra headers'
+ );
+ }
+
+ /**
+ * @covers ResourceLoaderModule::getHeaders
+ * @covers ResourceLoaderModule::buildContent
+ * @covers ResourceLoader::makeModuleResponse
+ */
+ public function testMakeModuleResponseExtraHeadersMulti() {
+ $foo = $this->getMockBuilder( ResourceLoaderTestModule::class )
+ ->setMethods( [ 'getPreloadLinks' ] )->getMock();
+ $foo->method( 'getPreloadLinks' )->willReturn( [
+ 'https://example.org/script.js' => [ 'as' => 'script' ],
+ ] );
+
+ $bar = $this->getMockBuilder( ResourceLoaderTestModule::class )
+ ->setMethods( [ 'getPreloadLinks' ] )->getMock();
+ $bar->method( 'getPreloadLinks' )->willReturn( [
+ '/example.png' => [ 'as' => 'image' ],
+ '/example.jpg' => [ 'as' => 'image' ],
+ ] );
+
+ $rl = new EmptyResourceLoader();
+ $rl->register( [ 'foo' => $foo, 'bar' => $bar ] );
+ $context = $this->getResourceLoaderContext(
+ [ 'modules' => 'foo|bar', 'only' => 'scripts' ],
+ $rl
+ );
+
+ $modules = [ 'foo' => $rl->getModule( 'foo' ), 'bar' => $rl->getModule( 'bar' ) ];
+ $response = $rl->makeModuleResponse( $context, $modules );
+ $extraHeaders = TestingAccessWrapper::newFromObject( $rl )->extraHeaders;
+ $this->assertEquals(
+ [
+ 'Link: <https://example.org/script.js>;rel=preload;as=script',
+ 'Link: </example.png>;rel=preload;as=image,</example.jpg>;rel=preload;as=image'
+ ],
+ $extraHeaders,
+ 'Extra headers'
+ );
+ }
+
+ /**
+ * @covers ResourceLoader::respond
+ */
+ public function testRespond() {
+ $rl = $this->getMockBuilder( EmptyResourceLoader::class )
+ ->setMethods( [
+ 'tryRespondNotModified',
+ 'sendResponseHeaders',
+ 'measureResponseTime',
+ ] )
+ ->getMock();
+ $context = $this->getResourceLoaderContext( [ 'modules' => '' ], $rl );
+
+ $rl->expects( $this->once() )->method( 'measureResponseTime' );
+ $this->expectOutputRegex( '/no modules were requested/' );
+
+ $rl->respond( $context );
+ }
+
+ /**
+ * @covers ResourceLoader::measureResponseTime
+ */
+ public function testMeasureResponseTime() {
+ $stats = $this->getMockBuilder( NullStatsdDataFactory::class )
+ ->setMethods( [ 'timing' ] )->getMock();
+ $this->setService( 'StatsdDataFactory', $stats );
+
+ $stats->expects( $this->once() )->method( 'timing' )
+ ->with( 'resourceloader.responseTime', $this->anything() );
+
+ $timing = new Timing();
+ $timing->mark( 'requestShutdown' );
+ $rl = TestingAccessWrapper::newFromObject( new EmptyResourceLoader );
+ $rl->measureResponseTime( $timing );
+ DeferredUpdates::doUpdates();
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderWikiModuleTest.php b/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderWikiModuleTest.php
new file mode 100644
index 00000000..0aa37d23
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/resourceloader/ResourceLoaderWikiModuleTest.php
@@ -0,0 +1,380 @@
+<?php
+
+use MediaWiki\MediaWikiServices;
+use Wikimedia\Rdbms\IDatabase;
+use Wikimedia\TestingAccessWrapper;
+
+class ResourceLoaderWikiModuleTest extends ResourceLoaderTestCase {
+
+ /**
+ * @covers ResourceLoaderWikiModule::__construct
+ * @dataProvider provideConstructor
+ */
+ public function testConstructor( $params ) {
+ $module = new ResourceLoaderWikiModule( $params );
+ $this->assertInstanceOf( ResourceLoaderWikiModule::class, $module );
+ }
+
+ public static function provideConstructor() {
+ return [
+ // Nothing
+ [ null ],
+ [ [] ],
+ // Unrecognized settings
+ [ [ 'foo' => 'baz' ] ],
+ // Real settings
+ [ [ 'scripts' => [ 'MediaWiki:Common.js' ] ] ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideGetPages
+ * @covers ResourceLoaderWikiModule::getPages
+ */
+ public function testGetPages( $params, Config $config, $expected ) {
+ $module = new ResourceLoaderWikiModule( $params );
+ $module->setConfig( $config );
+
+ // Because getPages is protected..
+ $getPages = new ReflectionMethod( $module, 'getPages' );
+ $getPages->setAccessible( true );
+ $out = $getPages->invoke( $module, ResourceLoaderContext::newDummyContext() );
+ $this->assertEquals( $expected, $out );
+ }
+
+ public static function provideGetPages() {
+ $settings = self::getSettings() + [
+ 'UseSiteJs' => true,
+ 'UseSiteCss' => true,
+ ];
+
+ $params = [
+ 'styles' => [ 'MediaWiki:Common.css' ],
+ 'scripts' => [ 'MediaWiki:Common.js' ],
+ ];
+
+ return [
+ [ [], new HashConfig( $settings ), [] ],
+ [ $params, new HashConfig( $settings ), [
+ 'MediaWiki:Common.js' => [ 'type' => 'script' ],
+ 'MediaWiki:Common.css' => [ 'type' => 'style' ]
+ ] ],
+ [ $params, new HashConfig( [ 'UseSiteCss' => false ] + $settings ), [
+ 'MediaWiki:Common.js' => [ 'type' => 'script' ],
+ ] ],
+ [ $params, new HashConfig( [ 'UseSiteJs' => false ] + $settings ), [
+ 'MediaWiki:Common.css' => [ 'type' => 'style' ],
+ ] ],
+ [ $params,
+ new HashConfig(
+ [ 'UseSiteJs' => false, 'UseSiteCss' => false ]
+ ),
+ []
+ ],
+ ];
+ }
+
+ /**
+ * @covers ResourceLoaderWikiModule::getGroup
+ * @dataProvider provideGetGroup
+ */
+ public function testGetGroup( $params, $expected ) {
+ $module = new ResourceLoaderWikiModule( $params );
+ $this->assertEquals( $expected, $module->getGroup() );
+ }
+
+ public static function provideGetGroup() {
+ return [
+ // No group specified
+ [ [], null ],
+ // A random group
+ [ [ 'group' => 'foobar' ], 'foobar' ],
+ ];
+ }
+
+ /**
+ * @covers ResourceLoaderWikiModule::isKnownEmpty
+ * @dataProvider provideIsKnownEmpty
+ */
+ public function testIsKnownEmpty( $titleInfo, $group, $expected ) {
+ $module = $this->getMockBuilder( ResourceLoaderWikiModule::class )
+ ->setMethods( [ 'getTitleInfo', 'getGroup' ] )
+ ->getMock();
+ $module->expects( $this->any() )
+ ->method( 'getTitleInfo' )
+ ->will( $this->returnValue( $titleInfo ) );
+ $module->expects( $this->any() )
+ ->method( 'getGroup' )
+ ->will( $this->returnValue( $group ) );
+ $context = $this->getMockBuilder( ResourceLoaderContext::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $this->assertEquals( $expected, $module->isKnownEmpty( $context ) );
+ }
+
+ public static function provideIsKnownEmpty() {
+ return [
+ // No valid pages
+ [ [], 'test1', true ],
+ // 'site' module with a non-empty page
+ [
+ [ 'MediaWiki:Common.js' => [ 'page_len' => 1234 ] ],
+ 'site',
+ false,
+ ],
+ // 'site' module with an empty page
+ [
+ [ 'MediaWiki:Foo.js' => [ 'page_len' => 0 ] ],
+ 'site',
+ false,
+ ],
+ // 'user' module with a non-empty page
+ [
+ [ 'User:Example/common.js' => [ 'page_len' => 25 ] ],
+ 'user',
+ false,
+ ],
+ // 'user' module with an empty page
+ [
+ [ 'User:Example/foo.js' => [ 'page_len' => 0 ] ],
+ 'user',
+ true,
+ ],
+ ];
+ }
+
+ /**
+ * @covers ResourceLoaderWikiModule::getTitleInfo
+ */
+ public function testGetTitleInfo() {
+ $pages = [
+ 'MediaWiki:Common.css' => [ 'type' => 'styles' ],
+ 'mediawiki: fallback.css' => [ 'type' => 'styles' ],
+ ];
+ $titleInfo = [
+ 'MediaWiki:Common.css' => [ 'page_len' => 1234 ],
+ 'MediaWiki:Fallback.css' => [ 'page_len' => 0 ],
+ ];
+ $expected = $titleInfo;
+
+ $module = $this->getMockBuilder( TestResourceLoaderWikiModule::class )
+ ->setMethods( [ 'getPages' ] )
+ ->getMock();
+ $module->method( 'getPages' )->willReturn( $pages );
+ // Can't mock static methods
+ $module::$returnFetchTitleInfo = $titleInfo;
+
+ $context = $this->getMockBuilder( ResourceLoaderContext::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $module = TestingAccessWrapper::newFromObject( $module );
+ $this->assertEquals( $expected, $module->getTitleInfo( $context ), 'Title info' );
+ }
+
+ /**
+ * @covers ResourceLoaderWikiModule::getTitleInfo
+ * @covers ResourceLoaderWikiModule::setTitleInfo
+ * @covers ResourceLoaderWikiModule::preloadTitleInfo
+ */
+ public function testGetPreloadedTitleInfo() {
+ $pages = [
+ 'MediaWiki:Common.css' => [ 'type' => 'styles' ],
+ // Regression against T145673. It's impossible to statically declare page names in
+ // a canonical way since the canonical prefix is localised. As such, the preload
+ // cache computed the right cache key, but failed to find the results when
+ // doing an intersect on the canonical result, producing an empty array.
+ 'mediawiki: fallback.css' => [ 'type' => 'styles' ],
+ ];
+ $titleInfo = [
+ 'MediaWiki:Common.css' => [ 'page_len' => 1234 ],
+ 'MediaWiki:Fallback.css' => [ 'page_len' => 0 ],
+ ];
+ $expected = $titleInfo;
+
+ $module = $this->getMockBuilder( TestResourceLoaderWikiModule::class )
+ ->setMethods( [ 'getPages' ] )
+ ->getMock();
+ $module->method( 'getPages' )->willReturn( $pages );
+ // Can't mock static methods
+ $module::$returnFetchTitleInfo = $titleInfo;
+
+ $rl = new EmptyResourceLoader();
+ $rl->register( 'testmodule', $module );
+ $context = new ResourceLoaderContext( $rl, new FauxRequest() );
+
+ TestResourceLoaderWikiModule::invalidateModuleCache(
+ Title::newFromText( 'MediaWiki:Common.css' ),
+ null,
+ null,
+ wfWikiID()
+ );
+ TestResourceLoaderWikiModule::preloadTitleInfo(
+ $context,
+ wfGetDB( DB_REPLICA ),
+ [ 'testmodule' ]
+ );
+
+ $module = TestingAccessWrapper::newFromObject( $module );
+ $this->assertEquals( $expected, $module->getTitleInfo( $context ), 'Title info' );
+ }
+
+ /**
+ * @covers ResourceLoaderWikiModule::preloadTitleInfo
+ */
+ public function testGetPreloadedBadTitle() {
+ // Mock values
+ $pages = [
+ // Covers else branch for invalid page name
+ '[x]' => [ 'type' => 'styles' ],
+ ];
+ $titleInfo = [];
+
+ // Set up objects
+ $module = $this->getMockBuilder( TestResourceLoaderWikiModule::class )
+ ->setMethods( [ 'getPages' ] ) ->getMock();
+ $module->method( 'getPages' )->willReturn( $pages );
+ $module::$returnFetchTitleInfo = $titleInfo;
+ $rl = new EmptyResourceLoader();
+ $rl->register( 'testmodule', $module );
+ $context = new ResourceLoaderContext( $rl, new FauxRequest() );
+
+ // Act
+ TestResourceLoaderWikiModule::preloadTitleInfo(
+ $context,
+ wfGetDB( DB_REPLICA ),
+ [ 'testmodule' ]
+ );
+
+ // Assert
+ $module = TestingAccessWrapper::newFromObject( $module );
+ $this->assertEquals( $titleInfo, $module->getTitleInfo( $context ), 'Title info' );
+ }
+
+ /**
+ * @covers ResourceLoaderWikiModule::preloadTitleInfo
+ */
+ public function testGetPreloadedTitleInfoEmpty() {
+ $context = new ResourceLoaderContext( new EmptyResourceLoader(), new FauxRequest() );
+ // Covers early return
+ $this->assertSame(
+ null,
+ ResourceLoaderWikiModule::preloadTitleInfo(
+ $context,
+ wfGetDB( DB_REPLICA ),
+ []
+ )
+ );
+ }
+
+ public static function provideGetContent() {
+ return [
+ 'Bad title' => [ null, '[x]' ],
+ 'Dead redirect' => [ null, [
+ 'text' => 'Dead redirect',
+ 'title' => 'Dead_redirect',
+ 'redirect' => 1,
+ ] ],
+ 'Bad content model' => [ null, [
+ 'text' => 'MediaWiki:Wikitext',
+ 'ns' => NS_MEDIAWIKI,
+ 'title' => 'Wikitext',
+ ] ],
+ 'No JS content found' => [ null, [
+ 'text' => 'MediaWiki:Script.js',
+ 'ns' => NS_MEDIAWIKI,
+ 'title' => 'Script.js',
+ ] ],
+ 'No CSS content found' => [ null, [
+ 'text' => 'MediaWiki:Styles.css',
+ 'ns' => NS_MEDIAWIKI,
+ 'title' => 'Script.css',
+ ] ],
+ ];
+ }
+
+ /**
+ * @covers ResourceLoaderWikiModule::getContent
+ * @dataProvider provideGetContent
+ */
+ public function testGetContent( $expected, $title ) {
+ $context = $this->getResourceLoaderContext( [], new EmptyResourceLoader );
+ $module = $this->getMockBuilder( ResourceLoaderWikiModule::class )
+ ->setMethods( [ 'getContentObj' ] ) ->getMock();
+ $module->expects( $this->any() )
+ ->method( 'getContentObj' )->willReturn( null );
+
+ if ( is_array( $title ) ) {
+ $title += [ 'ns' => NS_MAIN, 'id' => 1, 'len' => 1, 'redirect' => 0 ];
+ $titleText = $title['text'];
+ // Mock Title db access via LinkCache
+ MediaWikiServices::getInstance()->getLinkCache()->addGoodLinkObj(
+ $title['id'],
+ new TitleValue( $title['ns'], $title['title'] ),
+ $title['len'],
+ $title['redirect']
+ );
+ } else {
+ $titleText = $title;
+ }
+
+ $module = TestingAccessWrapper::newFromObject( $module );
+ $this->assertEquals(
+ $expected,
+ $module->getContent( $titleText )
+ );
+ }
+
+ /**
+ * @covers ResourceLoaderWikiModule::getContent
+ */
+ public function testGetContentForRedirects() {
+ // Set up context and module object
+ $context = $this->getResourceLoaderContext( [], new EmptyResourceLoader );
+ $module = $this->getMockBuilder( ResourceLoaderWikiModule::class )
+ ->setMethods( [ 'getPages', 'getContentObj' ] )
+ ->getMock();
+ $module->expects( $this->any() )
+ ->method( 'getPages' )
+ ->will( $this->returnValue( [
+ 'MediaWiki:Redirect.js' => [ 'type' => 'script' ]
+ ] ) );
+ $module->expects( $this->any() )
+ ->method( 'getContentObj' )
+ ->will( $this->returnCallback( function ( Title $title ) {
+ if ( $title->getPrefixedText() === 'MediaWiki:Redirect.js' ) {
+ $handler = new JavaScriptContentHandler();
+ return $handler->makeRedirectContent(
+ Title::makeTitle( NS_MEDIAWIKI, 'Target.js' )
+ );
+ } elseif ( $title->getPrefixedText() === 'MediaWiki:Target.js' ) {
+ return new JavaScriptContent( 'target;' );
+ } else {
+ return null;
+ }
+ } ) );
+
+ // Mock away Title's db queries with LinkCache
+ MediaWikiServices::getInstance()->getLinkCache()->addGoodLinkObj(
+ 1, // id
+ new TitleValue( NS_MEDIAWIKI, 'Redirect.js' ),
+ 1, // len
+ 1 // redirect
+ );
+
+ $this->assertEquals(
+ "/*\nMediaWiki:Redirect.js\n*/\ntarget;\n",
+ $module->getScript( $context ),
+ 'Redirect resolved by getContent'
+ );
+ }
+}
+
+class TestResourceLoaderWikiModule extends ResourceLoaderWikiModule {
+ public static $returnFetchTitleInfo = null;
+ protected static function fetchTitleInfo( IDatabase $db, array $pages, $fname = null ) {
+ $ret = self::$returnFetchTitleInfo;
+ self::$returnFetchTitleInfo = null;
+ return $ret;
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/resourceloader/templates/template.html b/www/wiki/tests/phpunit/includes/resourceloader/templates/template.html
new file mode 100644
index 00000000..1f6a7d22
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/resourceloader/templates/template.html
@@ -0,0 +1 @@
+<strong>hello</strong>
diff --git a/www/wiki/tests/phpunit/includes/resourceloader/templates/template2.html b/www/wiki/tests/phpunit/includes/resourceloader/templates/template2.html
new file mode 100644
index 00000000..a322f67d
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/resourceloader/templates/template2.html
@@ -0,0 +1 @@
+<div>goodbye</div>
diff --git a/www/wiki/tests/phpunit/includes/resourceloader/templates/template_awesome.handlebars b/www/wiki/tests/phpunit/includes/resourceloader/templates/template_awesome.handlebars
new file mode 100644
index 00000000..5f5c07d5
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/resourceloader/templates/template_awesome.handlebars
@@ -0,0 +1 @@
+wow