1<?php
2
3use Psr\Container\ContainerInterface;
4use Wikimedia\ObjectFactory;
5
6/**
7 * @group ResourceLoader
8 */
9class ResourceLoaderFileModuleTest extends ResourceLoaderTestCase {
10
11	protected function setUp() : void {
12		parent::setUp();
13
14		$skinFactory = new SkinFactory( new ObjectFactory(
15			$this->createMock( ContainerInterface::class )
16		) );
17		// The empty spec shouldn't matter since this test should never call it
18		$skinFactory->register(
19			'fakeskin',
20			'FakeSkin',
21			[]
22		);
23		$this->setService( 'SkinFactory', $skinFactory );
24
25		// This test is not expected to query any database
26		MediaWiki\MediaWikiServices::disableStorageBackend();
27	}
28
29	private static function getModules() {
30		$base = [
31			'localBasePath' => __DIR__,
32		];
33
34		return [
35			'noTemplateModule' => [],
36
37			'deprecatedModule' => $base + [
38				'deprecated' => true,
39			],
40			'deprecatedTomorrow' => $base + [
41				'deprecated' => 'Will be removed tomorrow.'
42			],
43
44			'htmlTemplateModule' => $base + [
45				'templates' => [
46					'templates/template.html',
47					'templates/template2.html',
48				]
49			],
50
51			'htmlTemplateUnknown' => $base + [
52				'templates' => [
53					'templates/notfound.html',
54				]
55			],
56
57			'aliasedHtmlTemplateModule' => $base + [
58				'templates' => [
59					'foo.html' => 'templates/template.html',
60					'bar.html' => 'templates/template2.html',
61				]
62			],
63
64			'templateModuleHandlebars' => $base + [
65				'templates' => [
66					'templates/template_awesome.handlebars',
67				],
68			],
69
70			'aliasFooFromBar' => $base + [
71				'templates' => [
72					'foo.foo' => 'templates/template.bar',
73				],
74			],
75		];
76	}
77
78	public static function providerTemplateDependencies() {
79		$modules = self::getModules();
80
81		return [
82			[
83				$modules['noTemplateModule'],
84				[],
85			],
86			[
87				$modules['htmlTemplateModule'],
88				[
89					'mediawiki.template',
90				],
91			],
92			[
93				$modules['templateModuleHandlebars'],
94				[
95					'mediawiki.template',
96					'mediawiki.template.handlebars',
97				],
98			],
99			[
100				$modules['aliasFooFromBar'],
101				[
102					'mediawiki.template',
103					'mediawiki.template.foo',
104				],
105			],
106		];
107	}
108
109	/**
110	 * @dataProvider providerTemplateDependencies
111	 * @covers ResourceLoaderFileModule::__construct
112	 * @covers ResourceLoaderFileModule::getDependencies
113	 */
114	public function testTemplateDependencies( $module, $expected ) {
115		$rl = new ResourceLoaderFileModule( $module );
116		$rl->setName( 'testing' );
117		$this->assertEquals( $rl->getDependencies(), $expected );
118	}
119
120	public static function providerDeprecatedModules() {
121		return [
122			[
123				'deprecatedModule',
124				'mw.log.warn("This page is using the deprecated ResourceLoader module \"deprecatedModule\".");',
125			],
126			[
127				'deprecatedTomorrow',
128				'mw.log.warn(' .
129					'"This page is using the deprecated ResourceLoader module \"deprecatedTomorrow\".\\n' .
130					"Will be removed tomorrow." .
131					'");'
132			]
133		];
134	}
135
136	/**
137	 * @dataProvider providerDeprecatedModules
138	 * @covers ResourceLoaderFileModule::getScript
139	 */
140	public function testDeprecatedModules( $name, $expected ) {
141		$modules = self::getModules();
142		$module = new ResourceLoaderFileModule( $modules[$name] );
143		$module->setName( $name );
144		$ctx = $this->getResourceLoaderContext();
145		$this->assertEquals( $module->getScript( $ctx ), $expected );
146	}
147
148	/**
149	 * @covers ResourceLoaderFileModule::getScript
150	 * @covers ResourceLoaderFileModule::getScriptFiles
151	 * @covers ResourceLoaderFileModule::readScriptFiles
152	 */
153	public function testGetScript() {
154		$module = new ResourceLoaderFileModule( [
155			'localBasePath' => __DIR__ . '/../../data/resourceloader',
156			'scripts' => [ 'script-nosemi.js', 'script-comment.js' ],
157		] );
158		$module->setName( 'testing' );
159		$ctx = $this->getResourceLoaderContext();
160		$this->assertEquals(
161			"/* eslint-disable */\nmw.foo()\n" .
162			"\n" .
163			"/* eslint-disable */\nmw.foo()\n// mw.bar();\n" .
164			"\n",
165			$module->getScript( $ctx ),
166			'scripts are concatenated with a new-line'
167		);
168	}
169
170	/**
171	 * @covers ResourceLoaderFileModule
172	 */
173	public function testGetAllSkinStyleFiles() {
174		$baseParams = [
175			'scripts' => [
176				'foo.js',
177				'bar.js',
178			],
179			'styles' => [
180				'foo.css',
181				'bar.css' => [ 'media' => 'print' ],
182				'screen.less' => [ 'media' => 'screen' ],
183				'screen-query.css' => [ 'media' => 'screen and (min-width: 400px)' ],
184			],
185			'skinStyles' => [
186				'default' => 'quux-fallback.less',
187				'fakeskin' => [
188					'baz-vector.css',
189					'quux-vector.less',
190				],
191			],
192			'messages' => [
193				'hello',
194				'world',
195			],
196		];
197
198		$module = new ResourceLoaderFileModule( $baseParams );
199		$module->setName( 'testing' );
200
201		$this->assertEquals(
202			[
203				'foo.css',
204				'baz-vector.css',
205				'quux-vector.less',
206				'quux-fallback.less',
207				'bar.css',
208				'screen.less',
209				'screen-query.css',
210			],
211			array_map( 'basename', $module->getAllStyleFiles() )
212		);
213	}
214
215	/**
216	 * Strip @noflip annotations from CSS code.
217	 * @param string $css
218	 * @return string
219	 */
220	private static function stripNoflip( $css ) {
221		return str_replace( '/*@noflip*/ ', '', $css );
222	}
223
224	/**
225	 * What happens when you mix @embed and @noflip?
226	 * This really is an integration test, but oh well.
227	 *
228	 * @covers ResourceLoaderFileModule::getStyles
229	 * @covers ResourceLoaderFileModule::getStyleFiles
230	 * @covers ResourceLoaderFileModule::readStyleFiles
231	 * @covers ResourceLoaderFileModule::readStyleFile
232	 */
233	public function testMixedCssAnnotations() {
234		$basePath = __DIR__ . '/../../data/css';
235		$testModule = new ResourceLoaderFileTestModule( [
236			'localBasePath' => $basePath,
237			'styles' => [ 'test.css' ],
238		] );
239		$testModule->setName( 'testing' );
240		$expectedModule = new ResourceLoaderFileTestModule( [
241			'localBasePath' => $basePath,
242			'styles' => [ 'expected.css' ],
243		] );
244		$expectedModule->setName( 'testing' );
245
246		$contextLtr = $this->getResourceLoaderContext( [
247			'lang' => 'en',
248			'dir' => 'ltr',
249		] );
250		$contextRtl = $this->getResourceLoaderContext( [
251			'lang' => 'he',
252			'dir' => 'rtl',
253		] );
254
255		// Since we want to compare the effect of @noflip+@embed against the effect of just @embed, and
256		// the @noflip annotations are always preserved, we need to strip them first.
257		$this->assertEquals(
258			$expectedModule->getStyles( $contextLtr ),
259			self::stripNoflip( $testModule->getStyles( $contextLtr ) ),
260			"/*@noflip*/ with /*@embed*/ gives correct results in LTR mode"
261		);
262		$this->assertEquals(
263			$expectedModule->getStyles( $contextLtr ),
264			self::stripNoflip( $testModule->getStyles( $contextRtl ) ),
265			"/*@noflip*/ with /*@embed*/ gives correct results in RTL mode"
266		);
267	}
268
269	/**
270	 * @covers ResourceLoaderFileModule
271	 */
272	public function testCssFlipping() {
273		$plain = new ResourceLoaderFileTestModule( [
274			'localBasePath' => __DIR__ . '/../../data/resourceloader',
275			'styles' => [ 'direction.css' ],
276		] );
277		$plain->setName( 'test' );
278
279		$context = $this->getResourceLoaderContext( [ 'lang' => 'en', 'dir' => 'ltr' ] );
280		$this->assertEquals(
281			$plain->getStyles( $context ),
282			[ 'all' => ".example { text-align: left; }\n" ],
283			'Unchanged styles in LTR mode'
284		);
285		$context = $this->getResourceLoaderContext( [ 'lang' => 'he', 'dir' => 'rtl' ] );
286		$this->assertEquals(
287			$plain->getStyles( $context ),
288			[ 'all' => ".example { text-align: right; }\n" ],
289			'Flipped styles in RTL mode'
290		);
291
292		$noflip = new ResourceLoaderFileTestModule( [
293			'localBasePath' => __DIR__ . '/../../data/resourceloader',
294			'styles' => [ 'direction.css' ],
295			'noflip' => true,
296		] );
297		$noflip->setName( 'test' );
298		$this->assertEquals(
299			$plain->getStyles( $context ),
300			[ 'all' => ".example { text-align: right; }\n" ],
301			'Unchanged styles in RTL mode with noflip at module level'
302		);
303	}
304
305	/**
306	 * Test reading files from elsewhere than localBasePath using ResourceLoaderFilePath.
307	 *
308	 * This mimics modules modified by skins using 'ResourceModuleSkinStyles' and 'OOUIThemePaths'
309	 * skin attributes.
310	 *
311	 * @covers ResourceLoaderFilePath::getLocalBasePath
312	 * @covers ResourceLoaderFilePath::getRemoteBasePath
313	 */
314	public function testResourceLoaderFilePath() {
315		$basePath = __DIR__ . '/../../data/blahblah';
316		$filePath = __DIR__ . '/../../data/rlfilepath';
317		$testModule = new ResourceLoaderFileModule( [
318			'localBasePath' => $basePath,
319			'remoteBasePath' => 'blahblah',
320			'styles' => new ResourceLoaderFilePath( 'style.css', $filePath, 'rlfilepath' ),
321			'skinStyles' => [
322				'vector' => new ResourceLoaderFilePath( 'skinStyle.css', $filePath, 'rlfilepath' ),
323			],
324			'scripts' => new ResourceLoaderFilePath( 'script.js', $filePath, 'rlfilepath' ),
325			'templates' => new ResourceLoaderFilePath( 'template.html', $filePath, 'rlfilepath' ),
326		] );
327		$expectedModule = new ResourceLoaderFileModule( [
328			'localBasePath' => $filePath,
329			'remoteBasePath' => 'rlfilepath',
330			'styles' => 'style.css',
331			'skinStyles' => [
332				'vector' => 'skinStyle.css',
333			],
334			'scripts' => 'script.js',
335			'templates' => 'template.html',
336		] );
337
338		$context = $this->getResourceLoaderContext();
339		$this->assertEquals(
340			$expectedModule->getModuleContent( $context ),
341			$testModule->getModuleContent( $context ),
342			"Using ResourceLoaderFilePath works correctly"
343		);
344	}
345
346	public static function providerGetTemplates() {
347		$modules = self::getModules();
348
349		return [
350			[
351				$modules['noTemplateModule'],
352				[],
353			],
354			[
355				$modules['templateModuleHandlebars'],
356				[
357					'templates/template_awesome.handlebars' => "wow\n",
358				],
359			],
360			[
361				$modules['htmlTemplateModule'],
362				[
363					'templates/template.html' => "<strong>hello</strong>\n",
364					'templates/template2.html' => "<div>goodbye</div>\n",
365				],
366			],
367			[
368				$modules['aliasedHtmlTemplateModule'],
369				[
370					'foo.html' => "<strong>hello</strong>\n",
371					'bar.html' => "<div>goodbye</div>\n",
372				],
373			],
374			[
375				$modules['htmlTemplateUnknown'],
376				false,
377			],
378		];
379	}
380
381	/**
382	 * @dataProvider providerGetTemplates
383	 * @covers ResourceLoaderFileModule::getTemplates
384	 */
385	public function testGetTemplates( $module, $expected ) {
386		$rl = new ResourceLoaderFileModule( $module );
387		$rl->setName( 'testing' );
388
389		if ( $expected === false ) {
390			$this->expectException( RuntimeException::class );
391			$rl->getTemplates();
392		} else {
393			$this->assertEquals( $rl->getTemplates(), $expected );
394		}
395	}
396
397	/**
398	 * @covers ResourceLoaderFileModule::stripBom
399	 */
400	public function testBomConcatenation() {
401		$basePath = __DIR__ . '/../../data/css';
402		$testModule = new ResourceLoaderFileTestModule( [
403			'localBasePath' => $basePath,
404			'styles' => [ 'bom.css' ],
405		] );
406		$testModule->setName( 'testing' );
407		$this->assertEquals(
408			substr( file_get_contents( "$basePath/bom.css" ), 0, 10 ),
409			"\xef\xbb\xbf.efbbbf",
410			'File has leading BOM'
411		);
412
413		$context = $this->getResourceLoaderContext();
414		$this->assertEquals(
415			$testModule->getStyles( $context ),
416			[ 'all' => ".efbbbf_bom_char_at_start_of_file {}\n" ],
417			'Leading BOM removed when concatenating files'
418		);
419	}
420
421	/**
422	 * @covers ResourceLoaderFileModule
423	 */
424	public function testLessFileCompilation() {
425		$context = $this->getResourceLoaderContext();
426		$basePath = __DIR__ . '/../../data/less/module';
427		$module = new ResourceLoaderFileTestModule( [
428			'localBasePath' => $basePath,
429			'styles' => [ 'styles.less' ],
430			'lessVars' => [ 'foo' => '2px', 'Foo' => '#eeeeee' ]
431		] );
432		$module->setName( 'test.less' );
433		$styles = $module->getStyles( $context );
434		$this->assertStringEqualsFile( $basePath . '/styles.css', $styles['all'] );
435	}
436
437	public function provideGetVersionHash() {
438		$a = [];
439		$b = [
440			'lessVars' => [ 'key' => 'value' ],
441		];
442		yield 'with and without Less variables' => [ $a, $b, false ];
443
444		$a = [
445			'lessVars' => [ 'key' => 'value1' ],
446		];
447		$b = [
448			'lessVars' => [ 'key' => 'value2' ],
449		];
450		yield 'different Less variables' => [ $a, $b, false ];
451
452		$x = [
453			'lessVars' => [ 'key' => 'value' ],
454		];
455		yield 'identical Less variables' => [ $x, $x, true ];
456
457		$a = [
458			'packageFiles' => [ [ 'name' => 'data.json', 'callback' => function () {
459				return [ 'aaa' ];
460			} ] ]
461		];
462		$b = [
463			'packageFiles' => [ [ 'name' => 'data.json', 'callback' => function () {
464				return [ 'bbb' ];
465			} ] ]
466		];
467		yield 'packageFiles with different callback' => [ $a, $b, false ];
468
469		$a = [
470			'packageFiles' => [ [ 'name' => 'aaa.json', 'callback' => function () {
471				return [ 'x' ];
472			} ] ]
473		];
474		$b = [
475			'packageFiles' => [ [ 'name' => 'bbb.json', 'callback' => function () {
476				return [ 'x' ];
477			} ] ]
478		];
479		yield 'packageFiles with different file name and a callback' => [ $a, $b, false ];
480
481		$a = [
482			'packageFiles' => [ [ 'name' => 'data.json', 'versionCallback' => function () {
483				return [ 'A-version' ];
484			}, 'callback' => function () {
485				throw new Exception( 'Unexpected computation' );
486			} ] ]
487		];
488		$b = [
489			'packageFiles' => [ [ 'name' => 'data.json', 'versionCallback' => function () {
490				return [ 'B-version' ];
491			}, 'callback' => function () {
492				throw new Exception( 'Unexpected computation' );
493			} ] ]
494		];
495		yield 'packageFiles with different versionCallback' => [ $a, $b, false ];
496
497		$a = [
498			'packageFiles' => [ [ 'name' => 'aaa.json',
499				'versionCallback' => function () {
500					return [ 'X-version' ];
501				},
502				'callback' => function () {
503					throw new Exception( 'Unexpected computation' );
504				}
505			] ]
506		];
507		$b = [
508			'packageFiles' => [ [ 'name' => 'bbb.json',
509				'versionCallback' => function () {
510					return [ 'X-version' ];
511				},
512				'callback' => function () {
513					throw new Exception( 'Unexpected computation' );
514				}
515			] ]
516		];
517		yield 'packageFiles with different file name and a versionCallback' => [ $a, $b, false ];
518	}
519
520	/**
521	 * @dataProvider provideGetVersionHash
522	 * @covers ResourceLoaderFileModule::getDefinitionSummary
523	 * @covers ResourceLoaderFileModule::getFileHashes
524	 */
525	public function testGetVersionHash( $a, $b, $isEqual ) {
526		$context = $this->getResourceLoaderContext();
527
528		$moduleA = new ResourceLoaderFileTestModule( $a );
529		$versionA = $moduleA->getVersionHash( $context );
530		$moduleB = new ResourceLoaderFileTestModule( $b );
531		$versionB = $moduleB->getVersionHash( $context );
532
533		$this->assertSame(
534			$isEqual,
535			( $versionA === $versionB ),
536			'Whether versions hashes are equal'
537		);
538	}
539
540	public function provideGetScriptPackageFiles() {
541		$basePath = __DIR__ . '/../../data/resourceloader';
542		$base = [ 'localBasePath' => $basePath ];
543		$commentScript = file_get_contents( "$basePath/script-comment.js" );
544		$nosemiScript = file_get_contents( "$basePath/script-nosemi.js" );
545		$vueComponentDebug = trim( file_get_contents( "$basePath/vue-component-output-debug.js.txt" ) );
546		$vueComponentNonDebug = trim( file_get_contents( "$basePath/vue-component-output-nondebug.js.txt" ) );
547		$config = RequestContext::getMain()->getConfig();
548		return [
549			[
550				$base + [
551					'packageFiles' => [
552						'script-comment.js',
553						'script-nosemi.js'
554					]
555				],
556				[
557					'files' => [
558						'script-comment.js' => [
559							'type' => 'script',
560							'content' => $commentScript,
561						],
562						'script-nosemi.js' => [
563							'type' => 'script',
564							'content' => $nosemiScript
565						]
566					],
567					'main' => 'script-comment.js'
568				]
569			],
570			[
571				$base + [
572					'packageFiles' => [
573						'script-comment.js',
574						[ 'name' => 'script-nosemi.js', 'main' => true ]
575					],
576					'deprecated' => 'Deprecation test',
577					'name' => 'test-deprecated'
578				],
579				[
580					'files' => [
581						'script-comment.js' => [
582							'type' => 'script',
583							'content' => $commentScript,
584						],
585						'script-nosemi.js' => [
586							'type' => 'script',
587							'content' => 'mw.log.warn(' .
588								'"This page is using the deprecated ResourceLoader module \"test-deprecated\".\\n' .
589								"Deprecation test" .
590								'");' .
591								$nosemiScript
592						]
593					],
594					'main' => 'script-nosemi.js'
595				]
596			],
597			[
598				$base + [
599					'packageFiles' => [
600						[ 'name' => 'init.js', 'file' => 'script-comment.js', 'main' => true ],
601						[ 'name' => 'nosemi.js', 'file' => 'script-nosemi.js' ],
602					]
603				],
604				[
605					'files' => [
606						'init.js' => [
607							'type' => 'script',
608							'content' => $commentScript,
609						],
610						'nosemi.js' => [
611							'type' => 'script',
612							'content' => $nosemiScript
613						]
614					],
615					'main' => 'init.js'
616				]
617			],
618			'package file with callback' => [
619				$base + [
620					'packageFiles' => [
621						[ 'name' => 'foo.json', 'content' => [ 'Hello' => 'world' ] ],
622						'sample.json',
623						[ 'name' => 'bar.js', 'content' => "console.log('Hello');" ],
624						[
625							'name' => 'data.json',
626							'callback' => function ( $context, $config, $extra ) {
627								return [ 'langCode' => $context->getLanguage(), 'extra' => $extra ];
628							},
629							'callbackParam' => [ 'a' => 'b' ],
630						],
631						[ 'name' => 'config.json', 'config' => [
632							'Sitename',
633							'server' => 'ServerName',
634						] ],
635					]
636				],
637				[
638					'files' => [
639						'foo.json' => [
640							'type' => 'data',
641							'content' => [ 'Hello' => 'world' ],
642						],
643						'sample.json' => [
644							'type' => 'data',
645							'content' => (object)[ 'foo' => 'bar', 'answer' => 42 ],
646						],
647						'bar.js' => [
648							'type' => 'script',
649							'content' => "console.log('Hello');",
650						],
651						'data.json' => [
652							'type' => 'data',
653							'content' => [ 'langCode' => 'fy', 'extra' => [ 'a' => 'b' ] ],
654						],
655						'config.json' => [
656							'type' => 'data',
657							'content' => [
658								'Sitename' => $config->get( 'Sitename' ),
659								'server' => $config->get( 'ServerName' ),
660							]
661						]
662					],
663					'main' => 'bar.js'
664				],
665				[
666					'lang' => 'fy'
667				]
668			],
669			'package file with callback and versionCallback' => [
670				$base + [
671					'packageFiles' => [
672						[ 'name' => 'bar.js', 'content' => "console.log('Hello');" ],
673						[
674							'name' => 'data.json',
675							'versionCallback' => function ( $context ) {
676								return 'x';
677							},
678							'callback' => function ( $context, $config, $extra ) {
679								return [ 'langCode' => $context->getLanguage(), 'extra' => $extra ];
680							},
681							'callbackParam' => [ 'A', 'B' ]
682						],
683					]
684				],
685				[
686					'files' => [
687						'bar.js' => [
688							'type' => 'script',
689							'content' => "console.log('Hello');",
690						],
691						'data.json' => [
692							'type' => 'data',
693							'content' => [ 'langCode' => 'fy', 'extra' => [ 'A', 'B' ] ],
694						],
695					],
696					'main' => 'bar.js'
697				],
698				[
699					'lang' => 'fy'
700				]
701			],
702			'package file with callback that returns a file (1)' => [
703				$base + [
704					'packageFiles' => [
705						[ 'name' => 'dynamic.js', 'callback' => function ( $context ) {
706							$file = $context->getLanguage() === 'fy' ? 'script-comment.js' : 'script-nosemi.js';
707							return new ResourceLoaderFilePath( $file );
708						} ]
709					]
710				],
711				[
712					'files' => [
713						'dynamic.js' => [
714							'type' => 'script',
715							'content' => $commentScript,
716						]
717					],
718					'main' => 'dynamic.js'
719				],
720				[
721					'lang' => 'fy'
722				]
723			],
724			'package file with callback that returns a file (2)' => [
725				$base + [
726					'packageFiles' => [
727						[ 'name' => 'dynamic.js', 'callback' => function ( $context ) {
728							$file = $context->getLanguage() === 'fy' ? 'script-comment.js' : 'script-nosemi.js';
729							return new ResourceLoaderFilePath( $file );
730						} ]
731					]
732				],
733				[
734					'files' => [
735						'dynamic.js' => [
736							'type' => 'script',
737							'content' => $nosemiScript,
738						]
739					],
740					'main' => 'dynamic.js'
741				],
742				[
743					'lang' => 'nl'
744				]
745			],
746			'.vue file in debug mode' => [
747				$base + [
748					'packageFiles' => [
749						'vue-component.vue'
750					]
751				],
752				[
753					'files' => [
754						'vue-component.vue' => [
755							'type' => 'script',
756							'content' => $vueComponentDebug
757						]
758					],
759					'main' => 'vue-component.vue',
760				],
761				[
762					'debug' => 'true'
763				]
764			],
765			'.vue file in non-debug mode' => [
766				$base + [
767					'packageFiles' => [
768						'vue-component.vue'
769					],
770					'name' => 'nondebug',
771				],
772				[
773					'files' => [
774						'vue-component.vue' => [
775							'type' => 'script',
776							'content' => $vueComponentNonDebug
777						]
778					],
779					'main' => 'vue-component.vue'
780				],
781				[
782					'debug' => 'false'
783				]
784			],
785			[
786				$base + [
787					'packageFiles' => [
788						[ 'file' => 'script-comment.js' ]
789					]
790				],
791				LogicException::class
792			],
793			'package file with invalid callback' => [
794				$base + [
795					'packageFiles' => [
796						[ 'name' => 'foo.json', 'callback' => 'functionThatDoesNotExist142857' ]
797					]
798				],
799				LogicException::class
800			],
801			[
802				// 'config' not valid for 'script' type
803				$base + [
804					'packageFiles' => [
805						'foo.json' => [ 'type' => 'script', 'config' => [ 'Sitename' ] ]
806					]
807				],
808				LogicException::class
809			],
810			[
811				// 'config' not valid for '*.js' file
812				$base + [
813					'packageFiles' => [
814						[ 'name' => 'foo.js', 'config' => 'Sitename' ]
815					]
816				],
817				LogicException::class
818			],
819			[
820				// missing type/name/file.
821				$base + [
822					'packageFiles' => [
823						'foo.js' => [ 'garbage' => 'data' ]
824					]
825				],
826				LogicException::class
827			],
828			[
829				$base + [
830					'packageFiles' => [
831						'filethatdoesnotexist142857.js'
832					]
833				],
834				RuntimeException::class
835			],
836			[
837				// JSON can't be a main file
838				$base + [
839					'packageFiles' => [
840						'script-nosemi.js',
841						[ 'name' => 'foo.json', 'content' => [ 'Hello' => 'world' ], 'main' => true ]
842					]
843				],
844				LogicException::class
845			]
846		];
847	}
848
849	/**
850	 * @dataProvider provideGetScriptPackageFiles
851	 * @covers ResourceLoaderFileModule::getScript
852	 * @covers ResourceLoaderFileModule::getPackageFiles
853	 * @covers ResourceLoaderFileModule::expandPackageFiles
854	 */
855	public function testGetScriptPackageFiles( $moduleDefinition, $expected, $contextOptions = [] ) {
856		$module = new ResourceLoaderFileModule( $moduleDefinition );
857		$context = $this->getResourceLoaderContext( $contextOptions );
858		if ( isset( $moduleDefinition['name'] ) ) {
859			$module->setName( $moduleDefinition['name'] );
860		}
861		if ( is_string( $expected ) ) {
862			// Class name of expected exception
863			$this->expectException( $expected );
864			$module->getScript( $context );
865		} else {
866			// Array of expected return value
867			$this->assertEquals( $expected, $module->getScript( $context ) );
868		}
869	}
870}
871