diff options
Diffstat (limited to 'www/wiki/tests/phpunit/includes/resourceloader')
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&lang=nl&modules=test.styles.pure&only=styles&skin=fallback"/>' . "\n" + . '<style>.private{}</style>' . "\n" + . '<script async="" src="/w/load.php?debug=false&lang=nl&modules=startup&only=scripts&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&lang=nl&modules=startup&only=scripts&skin=fallback&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&lang=nl&modules=startup&only=scripts&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&lang=nl&modules=test.scripts.raw&only=scripts&skin=fallback"></script>', + ], + [ + 'context' => [ 'sync' => true ], + 'modules' => [ 'test.scripts.raw' ], + 'only' => ResourceLoaderModule::TYPE_SCRIPTS, + 'output' => '<script src="/w/load.php?debug=false&lang=nl&modules=test.scripts.raw&only=scripts&skin=fallback&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&lang=nl&modules=test.styles.mixed&only=styles&skin=fallback"/>' . "\n" + . '<link rel="stylesheet" href="/w/load.php?debug=true&lang=nl&modules=test.styles.pure&only=styles&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&lang=nl&modules=test.styles.mixed%2Cpure&only=styles&skin=fallback"/>', + ], + [ + 'context' => [], + 'modules' => [ 'test.styles.noscript' ], + 'only' => ResourceLoaderModule::TYPE_STYLES, + 'output' => '<noscript><link rel="stylesheet" href="/w/load.php?debug=false&lang=nl&modules=test.styles.noscript&only=styles&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&lang=nl&modules=test.styles.pure&only=styles&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&lang=nl&modules=test.ordering.a%2Cb&only=styles&skin=fallback"/>' . "\n" + . '<style>.orderingC{}.orderingD{}</style>' . "\n" + . '<link rel="stylesheet" href="/w/load.php?debug=false&lang=nl&modules=test.ordering.e&only=styles&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 |