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