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