1<?php
2
3use Wikimedia\TestingAccessWrapper;
4
5/**
6 * @covers ExtensionProcessor
7 */
8class ExtensionProcessorTest extends MediaWikiIntegrationTestCase {
9
10	private $dir, $dirname;
11
12	protected function setUp(): void {
13		parent::setUp();
14		$this->dir = $this->getCurrentDir();
15		$this->dirname = dirname( $this->dir );
16	}
17
18	private function getCurrentDir() {
19		return __DIR__ . '/FooBar/extension.json';
20	}
21
22	/**
23	 * 'name' is absolutely required
24	 *
25	 * @var array
26	 */
27	public static $default = [
28		'name' => 'FooBar',
29	];
30
31	/**
32	 * 'name' is absolutely required, and sometimes we require two distinct ones...
33	 * @var array
34	 */
35	public static $default2 = [
36		'name' => 'FooBar2',
37	];
38
39	public function testExtractInfo() {
40		// Test that attributes that begin with @ are ignored
41		$processor = new ExtensionProcessor();
42		$processor->extractInfo( $this->dir, self::$default + [
43			'@metadata' => [ 'foobarbaz' ],
44			'AnAttribute' => [ 'omg' ],
45			'AutoloadClasses' => [ 'FooBar' => 'includes/FooBar.php' ],
46			'SpecialPages' => [ 'Foo' => 'SpecialFoo' ],
47			'callback' => 'FooBar::onRegistration',
48		], 1 );
49
50		$extracted = $processor->getExtractedInfo();
51		$attributes = $extracted['attributes'];
52		$this->assertArrayHasKey( 'AnAttribute', $attributes );
53		$this->assertArrayNotHasKey( '@metadata', $attributes );
54		$this->assertArrayNotHasKey( 'AutoloadClasses', $attributes );
55		$this->assertSame(
56			[ 'FooBar' => 'FooBar::onRegistration' ],
57			$extracted['callbacks']
58		);
59		$this->assertSame(
60			[ 'Foo' => 'SpecialFoo' ],
61			$extracted['globals']['wgSpecialPages']
62		);
63	}
64
65	public function testExtractSkins() {
66		$this->expectDeprecation();
67		$processor = new ExtensionProcessor();
68		$processor->extractInfo( $this->dir, self::$default + [
69			'ValidSkinNames' => [
70				'test-vector' => [
71					'class' => 'SkinTestVector',
72				],
73				'test-vector-empty-args' => [
74					'class' => 'SkinTestVector',
75					'args' => []
76				],
77				'test-vector-empty-options' => [
78					'class' => 'SkinTestVector',
79					'args' => [
80						[]
81					]
82				],
83				'test-vector-core-relative' => [
84					'class' => 'SkinTestVector',
85					'args' => [
86						[
87							'templateDirectory' => 'skins/Vector/templates',
88						]
89					]
90				],
91				'test-vector-skin-relative' => [
92					'class' => 'SkinTestVector',
93					'args' => [
94						[
95							'templateDirectory' => 'templates',
96						]
97					]
98				],
99			]
100		], 1 );
101		$extracted = $processor->getExtractedInfo();
102		$validSkins = $extracted['globals']['wgValidSkinNames'];
103
104		$this->assertArrayHasKey( 'test-vector', $validSkins );
105		$this->assertArrayHasKey( 'test-vector-core-relative', $validSkins );
106		$this->assertArrayHasKey( 'test-vector-empty-args', $validSkins );
107		$this->assertArrayHasKey( 'test-vector-skin-relative', $validSkins );
108		$this->assertSame(
109			$this->dirname . '/templates',
110			$validSkins['test-vector-empty-options']['args'][0]['templateDirectory'],
111			'A sensible default is provided.'
112		);
113		$this->assertSame(
114			'skins/Vector/templates',
115			$validSkins['test-vector-core-relative']['args'][0]['templateDirectory'],
116			'unmodified'
117		);
118		$this->assertSame(
119			$this->dirname . '/templates',
120			$validSkins['test-vector-skin-relative']['args'][0]['templateDirectory'],
121			'modified'
122		);
123	}
124
125	public function testExtractNamespaces() {
126		// Test that namespace IDs defined in extension.json can be overwritten locally
127		if ( !defined( 'MW_EXTENSION_PROCESSOR_TEST_EXTRACT_INFO_X' ) ) {
128			define( 'MW_EXTENSION_PROCESSOR_TEST_EXTRACT_INFO_X', 123456 );
129		}
130
131		$processor = new ExtensionProcessor();
132		$processor->extractInfo( $this->dir, self::$default + [
133			'namespaces' => [
134				[
135					'id' => 332200,
136					'constant' => 'MW_EXTENSION_PROCESSOR_TEST_EXTRACT_INFO_A',
137					'name' => 'Test_A',
138					'defaultcontentmodel' => 'TestModel',
139					'gender' => [
140						'male' => 'Male test',
141						'female' => 'Female test',
142					],
143					'subpages' => true,
144					'content' => true,
145					'protection' => 'userright',
146				],
147				[ // Test_X will use ID 123456 not 334400
148					'id' => 334400,
149					'constant' => 'MW_EXTENSION_PROCESSOR_TEST_EXTRACT_INFO_X',
150					'name' => 'Test_X',
151					'defaultcontentmodel' => 'TestModel'
152				],
153			]
154		], 1 );
155
156		$extracted = $processor->getExtractedInfo();
157
158		$this->assertArrayHasKey(
159			'MW_EXTENSION_PROCESSOR_TEST_EXTRACT_INFO_A',
160			$extracted['defines']
161		);
162		$this->assertArrayHasKey(
163			'MW_EXTENSION_PROCESSOR_TEST_EXTRACT_INFO_X',
164			$extracted['defines']
165		);
166
167		$this->assertSame(
168			123456,
169			$extracted['defines']['MW_EXTENSION_PROCESSOR_TEST_EXTRACT_INFO_X']
170		);
171
172		$this->assertSame(
173			332200,
174			$extracted['defines']['MW_EXTENSION_PROCESSOR_TEST_EXTRACT_INFO_A']
175		);
176
177		$this->assertArrayHasKey( 'ExtensionNamespaces', $extracted['attributes'] );
178		$this->assertArrayHasKey( 123456, $extracted['attributes']['ExtensionNamespaces'] );
179		$this->assertArrayHasKey( 332200, $extracted['attributes']['ExtensionNamespaces'] );
180		$this->assertArrayNotHasKey( 334400, $extracted['attributes']['ExtensionNamespaces'] );
181
182		$this->assertSame( 'Test_X', $extracted['attributes']['ExtensionNamespaces'][123456] );
183		$this->assertSame( 'Test_A', $extracted['attributes']['ExtensionNamespaces'][332200] );
184		$this->assertSame(
185			[ 'male' => 'Male test', 'female' => 'Female test' ],
186			$extracted['globals']['wgExtraGenderNamespaces'][332200]
187		);
188		// A has subpages, X does not
189		$this->assertTrue( $extracted['globals']['wgNamespacesWithSubpages'][332200] );
190		$this->assertArrayNotHasKey( 123456, $extracted['globals']['wgNamespacesWithSubpages'] );
191	}
192
193	public function provideMixedStyleHooks() {
194		// Format:
195		// Content in extension.json
196		// Expected wgHooks
197		// Expected Hooks
198		return [
199			[
200				[
201					'Hooks' => [ 'FooBaz' => [
202						[ 'handler' => 'HandlerObjectCallback' ],
203						[ 'handler' => 'HandlerObjectCallback', 'deprecated' => true ],
204						'HandlerObjectCallback',
205						[ 'FooClass', 'FooMethod' ],
206						'GlobalLegacyFunction',
207						'FooClass',
208						"FooClass::staticMethod"
209					] ],
210					'HookHandlers' => [
211						'HandlerObjectCallback' => [ 'class' => 'FooClass', 'services' => [] ]
212					]
213				] + self::$default,
214				[
215					'FooBaz' => [
216						[ 'FooClass', 'FooMethod' ],
217						'GlobalLegacyFunction',
218						'FooClass',
219						'FooClass::staticMethod'
220					]
221				] + [ ExtensionRegistry::MERGE_STRATEGY => 'array_merge_recursive' ],
222				[
223					'FooBaz' => [
224						[
225							'handler' => [
226								'class' => 'FooClass',
227								'services' => [],
228								'name' => 'FooBar-HandlerObjectCallback'
229							],
230							'extensionPath' => $this->getCurrentDir()
231						],
232						[
233							'handler' => [
234								'class' => 'FooClass',
235								'services' => [],
236								'name' => 'FooBar-HandlerObjectCallback'
237							],
238							'deprecated' => true,
239							'extensionPath' => $this->getCurrentDir()
240						],
241						[
242							'handler' => [
243								'class' => 'FooClass',
244								'services' => [],
245								'name' => 'FooBar-HandlerObjectCallback'
246							],
247							'extensionPath' => $this->getCurrentDir()
248						]
249					]
250				]
251			]
252		];
253	}
254
255	public function provideNonLegacyHooks() {
256		// Format:
257		// Current Hooks attribute
258		// Content in extension.json
259		// Expected Hooks attribute
260		return [
261			// Hook for "FooBaz": object with handler attribute
262			[
263				[ 'FooBaz' => [ 'PriorCallback' ] ],
264				[
265					'Hooks' => [ 'FooBaz' => [ 'handler' => 'HandlerObjectCallback', 'deprecated' => true ] ],
266					'HookHandlers' => [
267						'HandlerObjectCallback' => [
268							'class' => 'FooClass',
269							'services' => [],
270							'name' => 'FooBar-HandlerObjectCallback'
271						]
272					]
273				] + self::$default,
274				[ 'FooBaz' =>
275					[
276						'PriorCallback',
277						[
278							'handler' => [
279								'class' => 'FooClass',
280								'services' => [],
281								'name' => 'FooBar-HandlerObjectCallback'
282							],
283							'deprecated' => true,
284							'extensionPath' => $this->getCurrentDir()
285						]
286					]
287				],
288				[]
289			],
290			// Hook for "FooBaz": string corresponding to a handler definition
291			[
292				[ 'FooBaz' => [ 'PriorCallback' ] ],
293				[
294					'Hooks' => [ 'FooBaz' => [ 'HandlerObjectCallback' ] ],
295					'HookHandlers' => [
296						'HandlerObjectCallback' => [ 'class' => 'FooClass', 'services' => [] ],
297					]
298				] + self::$default,
299				[ 'FooBaz' =>
300					[
301						'PriorCallback',
302						[
303							'handler' => [
304								'class' => 'FooClass',
305								'services' => [],
306								'name' => 'FooBar-HandlerObjectCallback'
307							],
308							'extensionPath' => $this->getCurrentDir()
309						],
310					]
311				],
312				[]
313			],
314			// Hook for "FooBaz", string corresponds to handler def. and object with handler attribute
315			[
316				[ 'FooBaz' => [ 'PriorCallback' ] ],
317				[
318					'Hooks' => [ 'FooBaz' => [
319						[ 'handler' => 'HandlerObjectCallback', 'deprecated' => true ],
320						'HandlerObjectCallback2'
321					] ],
322					'HookHandlers' => [
323						'HandlerObjectCallback2' => [ 'class' => 'FooClass', 'services' => [] ],
324						'HandlerObjectCallback' => [ 'class' => 'FooClass', 'services' => [] ],
325					]
326				] + self::$default,
327				[ 'FooBaz' =>
328					[
329						'PriorCallback',
330						[
331							'handler' => [
332								'name' => 'FooBar-HandlerObjectCallback',
333								'class' => 'FooClass',
334								'services' => []
335							],
336							'deprecated' => true,
337							'extensionPath' => $this->getCurrentDir()
338						],
339						[
340							'handler' => [
341								'name' => 'FooBar-HandlerObjectCallback2',
342								'class' => 'FooClass',
343								'services' => [],
344							],
345							'extensionPath' => $this->getCurrentDir()
346						]
347					]
348				],
349				[]
350			],
351			// Hook for "FooBaz": string corresponding to a new-style handler definition
352			// and legacy style object and method array
353			[
354				[ 'FooBaz' => [ 'PriorCallback' ] ],
355				[
356					'Hooks' => [ 'FooBaz' => [
357						'HandlerObjectCallback',
358						[ 'FooClass', 'FooMethod ' ]
359					] ],
360					'HookHandlers' => [
361						'HandlerObjectCallback' => [ 'class' => 'FooClass', 'services' => [] ],
362					]
363				] + self::$default,
364				[ 'FooBaz' =>
365					[
366						'PriorCallback',
367						[
368							'handler' => [
369								'name' => 'FooBar-HandlerObjectCallback',
370								'class' => 'FooClass',
371								'services' => []
372							],
373							'extensionPath' => $this->getCurrentDir()
374						],
375					]
376				],
377				[ 'FooClass', 'FooMethod' ]
378			]
379		];
380	}
381
382	public function provideLegacyHooks() {
383		$merge = [ ExtensionRegistry::MERGE_STRATEGY => 'array_merge_recursive' ];
384		// Format:
385		// Current $wgHooks
386		// Content in extension.json
387		// Expected value of $wgHooks
388		return [
389			// No hooks
390			[
391				[],
392				self::$default,
393				$merge,
394			],
395			// No current hooks, adding one for "FooBaz" in string format
396			[
397				[],
398				[ 'Hooks' => [ 'FooBaz' => 'FooBazCallback' ] ] + self::$default,
399				[ 'FooBaz' => [ 'FooBazCallback' ] ] + $merge,
400			],
401			// Hook for "FooBaz", adding another one
402			[
403				[ 'FooBaz' => [ 'PriorCallback' ] ],
404				[ 'Hooks' => [ 'FooBaz' => 'FooBazCallback' ] ] + self::$default,
405				[ 'FooBaz' => [ 'PriorCallback', 'FooBazCallback' ] ] + $merge,
406			],
407			// No current hooks, adding one for "FooBaz" in verbose array format
408			[
409				[],
410				[ 'Hooks' => [ 'FooBaz' => [ 'FooBazCallback' ] ] ] + self::$default,
411				[ 'FooBaz' => [ 'FooBazCallback' ] ] + $merge,
412			],
413			// Hook for "BarBaz", adding one for "FooBaz"
414			[
415				[ 'BarBaz' => [ 'BarBazCallback' ] ],
416				[ 'Hooks' => [ 'FooBaz' => 'FooBazCallback' ] ] + self::$default,
417				[
418					'BarBaz' => [ 'BarBazCallback' ],
419					'FooBaz' => [ 'FooBazCallback' ],
420				] + $merge,
421			],
422			// Callbacks for FooBaz wrapped in an array
423			[
424				[],
425				[ 'Hooks' => [ 'FooBaz' => [ 'Callback1' ] ] ] + self::$default,
426				[
427					'FooBaz' => [ 'Callback1' ],
428				] + $merge,
429			],
430			// Multiple callbacks for FooBaz hook
431			[
432				[],
433				[ 'Hooks' => [ 'FooBaz' => [ 'Callback1', 'Callback2' ] ] ] + self::$default,
434				[
435					'FooBaz' => [ 'Callback1', 'Callback2' ],
436				] + $merge,
437			],
438		];
439	}
440
441	/**
442	 * @dataProvider provideNonLegacyHooks
443	 */
444	public function testNonLegacyHooks( $pre, $info, $expected ) {
445		$processor = new MockExtensionProcessor( [ 'attributes' => [ 'Hooks' => $pre ] ] );
446		$processor->extractInfo( $this->dir, $info, 1 );
447		$extracted = $processor->getExtractedInfo();
448		$this->assertEquals( $expected, $extracted['attributes']['Hooks'] );
449	}
450
451	/**
452	 * @dataProvider provideMixedStyleHooks
453	 */
454	public function testMixedStyleHooks( $info, $expectedWgHooks, $expectedNewHooks ) {
455		$processor = new MockExtensionProcessor();
456		$processor->extractInfo( $this->dir, $info, 1 );
457		$extracted = $processor->getExtractedInfo();
458		$this->assertEquals( $expectedWgHooks, $extracted['globals']['wgHooks'] );
459		$this->assertEquals( $expectedNewHooks, $extracted['attributes']['Hooks'] );
460	}
461
462	/**
463	 * @dataProvider provideLegacyHooks
464	 */
465	public function testLegacyHooks( $pre, $info, $expected ) {
466		$preset = [ 'globals' => [ 'wgHooks' => $pre ] ];
467		$processor = new MockExtensionProcessor( $preset );
468		$processor->extractInfo( $this->dir, $info, 1 );
469		$extracted = $processor->getExtractedInfo();
470		$this->assertEquals( $expected, $extracted['globals']['wgHooks'] );
471	}
472
473	public function testRegisterHandlerWithoutDefinition() {
474		$info = [
475			'Hooks' => [ 'FooBaz' => [ 'handler' => 'NoHandlerDefinition' ] ],
476			'HookHandlers' => []
477		] + self::$default;
478		$processor = new MockExtensionProcessor();
479		$this->expectException( UnexpectedValueException::class );
480		$this->expectExceptionMessage(
481			'Missing handler definition for FooBaz in HookHandlers attribute'
482		);
483		$processor->extractInfo( $this->dir, $info, 1 );
484		$processor->getExtractedInfo();
485	}
486
487	public function testExtractConfig1() {
488		$processor = new ExtensionProcessor();
489		$info = [
490			'config' => [
491				'Bar' => 'somevalue',
492				'Foo' => 10,
493				'@IGNORED' => 'yes',
494			],
495		] + self::$default;
496		$info2 = [
497			'config' => [
498				'_prefix' => 'eg',
499				'Bar' => 'somevalue'
500			],
501		] + self::$default2;
502		$processor->extractInfo( $this->dir, $info, 1 );
503		$processor->extractInfo( $this->dir, $info2, 1 );
504		$extracted = $processor->getExtractedInfo();
505		$this->assertEquals( 'somevalue', $extracted['globals']['wgBar'] );
506		$this->assertEquals( 10, $extracted['globals']['wgFoo'] );
507		$this->assertArrayNotHasKey( 'wg@IGNORED', $extracted['globals'] );
508		// Custom prefix:
509		$this->assertEquals( 'somevalue', $extracted['globals']['egBar'] );
510	}
511
512	public function testExtractConfig2() {
513		$processor = new ExtensionProcessor();
514		$info = [
515			'config' => [
516				'Bar' => [ 'value' => 'somevalue' ],
517				'Foo' => [ 'value' => 10 ],
518				'Path' => [ 'value' => 'foo.txt', 'path' => true ],
519				'PathArray' => [ 'value' => [ 'foo.bar', 'bar.foo', 'bar/foo.txt' ], 'path' => true ],
520				'Namespaces' => [
521					'value' => [
522						'10' => true,
523						'12' => false,
524					],
525					'merge_strategy' => 'array_plus',
526				],
527			],
528		] + self::$default;
529		$info2 = [
530			'config' => [
531				'Bar' => [ 'value' => 'somevalue' ],
532			],
533			'config_prefix' => 'eg',
534		] + self::$default2;
535		$processor->extractInfo( $this->dir, $info, 2 );
536		$processor->extractInfo( $this->dir, $info2, 2 );
537		$extracted = $processor->getExtractedInfo();
538		$this->assertEquals( 'somevalue', $extracted['globals']['wgBar'] );
539		$this->assertEquals( 10, $extracted['globals']['wgFoo'] );
540		$this->assertEquals( "{$this->dirname}/foo.txt", $extracted['globals']['wgPath'] );
541		$this->assertEquals(
542			[
543				"{$this->dirname}/foo.bar",
544				"{$this->dirname}/bar.foo",
545				"{$this->dirname}/bar/foo.txt"
546			],
547			$extracted['globals']['wgPathArray']
548		);
549		// Custom prefix:
550		$this->assertEquals( 'somevalue', $extracted['globals']['egBar'] );
551		$this->assertSame(
552			[ 10 => true, 12 => false, ExtensionRegistry::MERGE_STRATEGY => 'array_plus' ],
553			$extracted['globals']['wgNamespaces']
554		);
555	}
556
557	public function testDuplicateConfigKey1() {
558		$processor = new ExtensionProcessor();
559		$info = [
560			'config' => [
561				'Bar' => '',
562			]
563		] + self::$default;
564		$info2 = [
565			'config' => [
566				'Bar' => 'g',
567			],
568		] + self::$default2;
569		$this->expectException( RuntimeException::class );
570		$processor->extractInfo( $this->dir, $info, 1 );
571		$processor->extractInfo( $this->dir, $info2, 1 );
572	}
573
574	public function testDuplicateConfigKey2() {
575		$processor = new ExtensionProcessor();
576		$info = [
577			'config' => [
578				'Bar' => [ 'value' => 'somevalue' ],
579			]
580		] + self::$default;
581		$info2 = [
582			'config' => [
583				'Bar' => [ 'value' => 'somevalue' ],
584			],
585		] + self::$default2;
586		$this->expectException( RuntimeException::class );
587		$processor->extractInfo( $this->dir, $info, 2 );
588		$processor->extractInfo( $this->dir, $info2, 2 );
589	}
590
591	public static function provideExtractExtensionMessagesFiles() {
592		$dir = __DIR__ . '/FooBar/';
593		return [
594			[
595				[ 'ExtensionMessagesFiles' => [ 'FooBarAlias' => 'FooBar.alias.php' ] ],
596				[ 'wgExtensionMessagesFiles' => [ 'FooBarAlias' => $dir . 'FooBar.alias.php' ] ]
597			],
598			[
599				[
600					'ExtensionMessagesFiles' => [
601						'FooBarAlias' => 'FooBar.alias.php',
602						'FooBarMagic' => 'FooBar.magic.i18n.php',
603					],
604				],
605				[
606					'wgExtensionMessagesFiles' => [
607						'FooBarAlias' => $dir . 'FooBar.alias.php',
608						'FooBarMagic' => $dir . 'FooBar.magic.i18n.php',
609					],
610				],
611			],
612		];
613	}
614
615	/**
616	 * @dataProvider provideExtractExtensionMessagesFiles
617	 */
618	public function testExtractExtensionMessagesFiles( $input, $expected ) {
619		$processor = new ExtensionProcessor();
620		$processor->extractInfo( $this->dir, $input + self::$default, 1 );
621		$out = $processor->getExtractedInfo();
622		foreach ( $expected as $key => $value ) {
623			$this->assertEquals( $value, $out['globals'][$key] );
624		}
625	}
626
627	public static function provideExtractMessagesDirs() {
628		$dir = __DIR__ . '/FooBar/';
629		return [
630			[
631				[ 'MessagesDirs' => [ 'VisualEditor' => 'i18n' ] ],
632				[ 'wgMessagesDirs' => [ 'VisualEditor' => [ $dir . 'i18n' ] ] ]
633			],
634			[
635				[ 'MessagesDirs' => [ 'VisualEditor' => [ 'i18n', 'foobar' ] ] ],
636				[ 'wgMessagesDirs' => [ 'VisualEditor' => [ $dir . 'i18n', $dir . 'foobar' ] ] ]
637			],
638		];
639	}
640
641	/**
642	 * @dataProvider provideExtractMessagesDirs
643	 */
644	public function testExtractMessagesDirs( $input, $expected ) {
645		$processor = new ExtensionProcessor();
646		$processor->extractInfo( $this->dir, $input + self::$default, 1 );
647		$out = $processor->getExtractedInfo();
648		foreach ( $expected as $key => $value ) {
649			$this->assertEquals( $value, $out['globals'][$key] );
650		}
651	}
652
653	public function testExtractCredits() {
654		$processor = new ExtensionProcessor();
655		$processor->extractInfo( $this->dir, self::$default, 1 );
656		$this->expectException( Exception::class );
657		$processor->extractInfo( $this->dir, self::$default, 1 );
658	}
659
660	/**
661	 * @dataProvider provideExtractResourceLoaderModules
662	 */
663	public function testExtractResourceLoaderModules(
664		$input,
665		array $expectedGlobals,
666		array $expectedAttribs = []
667	) {
668		$processor = new ExtensionProcessor();
669		$processor->extractInfo( $this->dir, $input + self::$default, 1 );
670		$out = $processor->getExtractedInfo();
671		foreach ( $expectedGlobals as $key => $value ) {
672			$this->assertEquals( $value, $out['globals'][$key] );
673		}
674		foreach ( $expectedAttribs as $key => $value ) {
675			$this->assertEquals( $value, $out['attributes'][$key] );
676		}
677	}
678
679	public static function provideExtractResourceLoaderModules() {
680		$dir = __DIR__ . '/FooBar';
681		return [
682			// Generic module with localBasePath/remoteExtPath specified
683			[
684				// Input
685				[
686					'ResourceModules' => [
687						'test.foo' => [
688							'styles' => 'foobar.js',
689							'localBasePath' => '',
690							'remoteExtPath' => 'FooBar',
691						],
692					],
693				],
694				// Expected
695				[],
696				[
697					'ResourceModules' => [
698						'test.foo' => [
699							'styles' => 'foobar.js',
700							'localBasePath' => $dir,
701							'remoteExtPath' => 'FooBar',
702						],
703					],
704				],
705			],
706			// ResourceFileModulePaths specified:
707			[
708				// Input
709				[
710					'ResourceFileModulePaths' => [
711						'localBasePath' => 'modules',
712						'remoteExtPath' => 'FooBar/modules',
713					],
714					'ResourceModules' => [
715						// No paths
716						'test.foo' => [
717							'styles' => 'foo.js',
718						],
719						// Different paths set
720						'test.bar' => [
721							'styles' => 'bar.js',
722							'localBasePath' => 'subdir',
723							'remoteExtPath' => 'FooBar/subdir',
724						],
725						// Custom class with no paths set
726						'test.class' => [
727							'class' => 'FooBarModule',
728							'extra' => 'argument',
729						],
730						// Custom class with a localBasePath
731						'test.class.with.path' => [
732							'class' => 'FooBarPathModule',
733							'extra' => 'argument',
734							'localBasePath' => '',
735						]
736					],
737				],
738				// Expected
739				[],
740				[
741					'ResourceModules' => [
742						'test.foo' => [
743							'styles' => 'foo.js',
744							'localBasePath' => "$dir/modules",
745							'remoteExtPath' => 'FooBar/modules',
746						],
747						'test.bar' => [
748							'styles' => 'bar.js',
749							'localBasePath' => "$dir/subdir",
750							'remoteExtPath' => 'FooBar/subdir',
751						],
752						'test.class' => [
753							'class' => 'FooBarModule',
754							'extra' => 'argument',
755							'localBasePath' => "$dir/modules",
756							'remoteExtPath' => 'FooBar/modules',
757						],
758						'test.class.with.path' => [
759							'class' => 'FooBarPathModule',
760							'extra' => 'argument',
761							'localBasePath' => $dir,
762							'remoteExtPath' => 'FooBar/modules',
763						]
764					],
765				],
766			],
767			// ResourceModuleSkinStyles with file module paths
768			[
769				// Input
770				[
771					'ResourceFileModulePaths' => [
772						'localBasePath' => '',
773						'remoteSkinPath' => 'FooBar',
774					],
775					'ResourceModuleSkinStyles' => [
776						'foobar' => [
777							'test.foo' => 'foo.css',
778						]
779					],
780				],
781				// Expected
782				[],
783				[
784					'ResourceModuleSkinStyles' => [
785						'foobar' => [
786							'test.foo' => 'foo.css',
787							'localBasePath' => $dir,
788							'remoteSkinPath' => 'FooBar',
789						],
790					],
791				],
792			],
793			// ResourceModuleSkinStyles with file module paths and an override
794			[
795				// Input
796				[
797					'ResourceFileModulePaths' => [
798						'localBasePath' => '',
799						'remoteSkinPath' => 'FooBar',
800					],
801					'ResourceModuleSkinStyles' => [
802						'foobar' => [
803							'test.foo' => 'foo.css',
804							'remoteSkinPath' => 'BarFoo'
805						],
806					],
807				],
808				// Expected
809				[],
810				[
811					'ResourceModuleSkinStyles' => [
812						'foobar' => [
813							'test.foo' => 'foo.css',
814							'localBasePath' => $dir,
815							'remoteSkinPath' => 'BarFoo',
816						],
817					],
818				],
819			],
820			'QUnit test module' => [
821				// Input
822				[
823					'QUnitTestModule' => [
824						'localBasePath' => '',
825						'remoteExtPath' => 'Foo',
826						'scripts' => 'bar.js',
827					],
828				],
829				// Expected
830				[],
831				[
832					'QUnitTestModules' => [
833						'test.FooBar' => [
834							'localBasePath' => $dir,
835							'remoteExtPath' => 'Foo',
836							'scripts' => 'bar.js',
837						],
838					],
839				],
840			],
841		];
842	}
843
844	public static function provideSetToGlobal() {
845		return [
846			[
847				[ 'wgAPIModules', 'wgAvailableRights' ],
848				[],
849				[
850					'APIModules' => [ 'foobar' => 'ApiFooBar' ],
851					'AvailableRights' => [ 'foobar', 'unfoobar' ],
852				],
853				[
854					'wgAPIModules' => [ 'foobar' => 'ApiFooBar' ],
855					'wgAvailableRights' => [ 'foobar', 'unfoobar' ],
856				],
857			],
858			[
859				[ 'wgAPIModules', 'wgAvailableRights' ],
860				[
861					'wgAPIModules' => [ 'barbaz' => 'ApiBarBaz' ],
862					'wgAvailableRights' => [ 'barbaz' ]
863				],
864				[
865					'APIModules' => [ 'foobar' => 'ApiFooBar' ],
866					'AvailableRights' => [ 'foobar', 'unfoobar' ],
867				],
868				[
869					'wgAPIModules' => [ 'barbaz' => 'ApiBarBaz', 'foobar' => 'ApiFooBar' ],
870					'wgAvailableRights' => [ 'barbaz', 'foobar', 'unfoobar' ],
871				],
872			],
873			[
874				[ 'wgGroupPermissions' ],
875				[
876					'wgGroupPermissions' => [
877						'sysop' => [ 'delete' ]
878					],
879				],
880				[
881					'GroupPermissions' => [
882						'sysop' => [ 'undelete' ],
883						'user' => [ 'edit' ]
884					],
885				],
886				[
887					'wgGroupPermissions' => [
888						'sysop' => [ 'delete', 'undelete' ],
889						'user' => [ 'edit' ]
890					],
891				]
892			]
893		];
894	}
895
896	/**
897	 * Attributes under manifest_version 2
898	 */
899	public function testExtractAttributes() {
900		$processor = new ExtensionProcessor();
901		// Load FooBar extension
902		$processor->extractInfo( $this->dir, self::$default, 2 );
903		$processor->extractInfo(
904			$this->dir,
905			[
906				'name' => 'Baz',
907				'attributes' => [
908					// Loaded
909					'FooBar' => [
910						'Plugins' => [
911							'ext.baz.foobar',
912						],
913					],
914					// Not loaded
915					'FizzBuzz' => [
916						'MorePlugins' => [
917							'ext.baz.fizzbuzz',
918						],
919					],
920				],
921			],
922			2
923		);
924
925		$info = $processor->getExtractedInfo();
926		$this->assertArrayHasKey( 'FooBarPlugins', $info['attributes'] );
927		$this->assertSame( [ 'ext.baz.foobar' ], $info['attributes']['FooBarPlugins'] );
928		$this->assertArrayNotHasKey( 'FizzBuzzMorePlugins', $info['attributes'] );
929	}
930
931	/**
932	 * Attributes under manifest_version 1
933	 */
934	public function testAttributes1() {
935		$processor = new ExtensionProcessor();
936		$processor->extractInfo(
937			$this->dir,
938			[
939				'FooBarPlugins' => [
940					'ext.baz.foobar',
941				],
942				'FizzBuzzMorePlugins' => [
943					'ext.baz.fizzbuzz',
944				],
945			] + self::$default,
946			1
947		);
948		$processor->extractInfo(
949			$this->dir,
950			[
951				'FizzBuzzMorePlugins' => [
952					'ext.bar.fizzbuzz',
953				]
954			] + self::$default2,
955			1
956		);
957
958		$info = $processor->getExtractedInfo();
959		$this->assertArrayHasKey( 'FooBarPlugins', $info['attributes'] );
960		$this->assertSame( [ 'ext.baz.foobar' ], $info['attributes']['FooBarPlugins'] );
961		$this->assertArrayHasKey( 'FizzBuzzMorePlugins', $info['attributes'] );
962		$this->assertSame(
963			[ 'ext.baz.fizzbuzz', 'ext.bar.fizzbuzz' ],
964			$info['attributes']['FizzBuzzMorePlugins']
965		);
966	}
967
968	public function testAttributes1_notarray() {
969		$processor = new ExtensionProcessor();
970		$this->expectException( InvalidArgumentException::class );
971		$this->expectExceptionMessage(
972			"The value for 'FooBarPlugins' should be an array (from {$this->dir})"
973		);
974		$processor->extractInfo(
975			$this->dir,
976			[
977				'FooBarPlugins' => 'ext.baz.foobar',
978			] + self::$default,
979			1
980		);
981	}
982
983	public function testExtractPathBasedGlobal() {
984		$processor = new ExtensionProcessor();
985		$processor->extractInfo(
986			$this->dir,
987			[
988				'ParserTestFiles' => [
989					'tests/parserTests.txt',
990					'tests/extraParserTests.txt',
991				],
992				'ServiceWiringFiles' => [
993					'includes/ServiceWiring.php'
994				],
995			] + self::$default,
996			1
997		);
998		$globals = $processor->getExtractedInfo()['globals'];
999		$this->assertArrayHasKey( 'wgParserTestFiles', $globals );
1000		$this->assertSame( [
1001			"{$this->dirname}/tests/parserTests.txt",
1002			"{$this->dirname}/tests/extraParserTests.txt"
1003		], $globals['wgParserTestFiles'] );
1004		$this->assertArrayHasKey( 'wgServiceWiringFiles', $globals );
1005		$this->assertSame( [
1006			"{$this->dirname}/includes/ServiceWiring.php"
1007		], $globals['wgServiceWiringFiles'] );
1008	}
1009
1010	public function testGetRequirements() {
1011		$info = self::$default + [
1012			'requires' => [
1013				'MediaWiki' => '>= 1.25.0',
1014				'platform' => [
1015					'php' => '>= 5.5.9'
1016				],
1017				'extensions' => [
1018					'Bar' => '*'
1019				]
1020			]
1021		];
1022		$processor = new ExtensionProcessor();
1023		$this->assertSame(
1024			$info['requires'],
1025			$processor->getRequirements( $info, false )
1026		);
1027		$this->assertSame(
1028			[],
1029			$processor->getRequirements( [], false )
1030		);
1031	}
1032
1033	public function testGetDevRequirements() {
1034		$info = self::$default + [
1035			'dev-requires' => [
1036				'MediaWiki' => '>= 1.31.0',
1037				'platform' => [
1038					'ext-foo' => '*',
1039				],
1040				'skins' => [
1041					'Baz' => '*',
1042				],
1043				'extensions' => [
1044					'Biz' => '*',
1045				],
1046			],
1047		];
1048		$processor = new ExtensionProcessor();
1049		$this->assertSame(
1050			$info['dev-requires'],
1051			$processor->getRequirements( $info, true )
1052		);
1053		// Set some standard requirements, so we can test merging
1054		$info['requires'] = [
1055			'MediaWiki' => '>= 1.25.0',
1056			'platform' => [
1057				'php' => '>= 5.5.9'
1058			],
1059			'extensions' => [
1060				'Bar' => '*'
1061			]
1062		];
1063		$this->assertSame(
1064			[
1065				'MediaWiki' => '>= 1.25.0 >= 1.31.0',
1066				'platform' => [
1067					'php' => '>= 5.5.9',
1068					'ext-foo' => '*',
1069				],
1070				'extensions' => [
1071					'Bar' => '*',
1072					'Biz' => '*',
1073				],
1074				'skins' => [
1075					'Baz' => '*',
1076				],
1077			],
1078			$processor->getRequirements( $info, true )
1079		);
1080
1081		// If there's no dev-requires, it just returns requires
1082		unset( $info['dev-requires'] );
1083		$this->assertSame(
1084			$info['requires'],
1085			$processor->getRequirements( $info, true )
1086		);
1087	}
1088
1089	public function testGetExtraAutoloaderPaths() {
1090		$processor = new ExtensionProcessor();
1091		$this->assertSame(
1092			[ "{$this->dirname}/vendor/autoload.php" ],
1093			$processor->getExtraAutoloaderPaths( $this->dirname, [
1094				'load_composer_autoloader' => true,
1095			] )
1096		);
1097	}
1098
1099	/**
1100	 * Verify that extension.schema.json is in sync with ExtensionProcessor
1101	 *
1102	 * @coversNothing
1103	 */
1104	public function testGlobalSettingsDocumentedInSchema() {
1105		global $IP;
1106		$globalSettings = TestingAccessWrapper::newFromClass(
1107			ExtensionProcessor::class )->globalSettings;
1108
1109		$version = ExtensionRegistry::MANIFEST_VERSION;
1110		$schema = FormatJson::decode(
1111			file_get_contents( "$IP/docs/extension.schema.v$version.json" ),
1112			true
1113		);
1114		$missing = [];
1115		foreach ( $globalSettings as $global ) {
1116			if ( !isset( $schema['properties'][$global] ) ) {
1117				$missing[] = $global;
1118			}
1119		}
1120
1121		$this->assertEquals( [], $missing,
1122			"The following global settings are not documented in docs/extension.schema.json" );
1123	}
1124
1125	public function testGetCoreAttribsMerging() {
1126		$processor = new ExtensionProcessor();
1127
1128		$info = self::$default + [
1129			'TrackingCategories' => [
1130				'Foo'
1131			]
1132		];
1133
1134		$info2 = self::$default2 + [
1135			'TrackingCategories' => [
1136				'Bar'
1137			]
1138		];
1139
1140		$processor->extractInfo( $this->dir, $info, 2 );
1141		$processor->extractInfo( $this->dir, $info2, 2 );
1142
1143		$attributes = $processor->getExtractedInfo()['attributes'];
1144
1145		$this->assertEquals(
1146			[ 'Foo', 'Bar' ],
1147			$attributes['TrackingCategories']
1148		);
1149	}
1150}
1151
1152/**
1153 * Allow overriding the default value of $this->globals and $this->attributes
1154 * so we can test merging and hook extraction
1155 */
1156class MockExtensionProcessor extends ExtensionProcessor {
1157
1158	public function __construct( $preset = [] ) {
1159		if ( isset( $preset['globals'] ) ) {
1160			$this->globals = $preset['globals'] + $this->globals;
1161		}
1162		if ( isset( $preset['attributes'] ) ) {
1163			$this->attributes = $preset['attributes'] + $this->attributes;
1164		}
1165	}
1166}
1167