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