From 90c340c29e38954bc3c0704928f1ba1270b3091a Mon Sep 17 00:00:00 2001 From: Akshat Date: Sat, 29 Aug 2026 21:22:32 +0530 Subject: [PATCH 1/4] fix(templates): shared test base + process-isolate Commands_Test; real Services_Test (#14, #15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #14 — static state was leaking between tests. New `tests/Unit/Plugin_TestCase` (abstract, always shipped) resets `Services` and the `Plugin` singleton in tearDown(); every generated `*_Test.php` now extends it instead of PHPUnit's TestCase. `Commands_Test` gets `@runInSeparateProcess` / `@preserveGlobalState disabled` so its `define( 'WP_CLI', true )` can't persist into the rest of the run — `Schema_Test`'s front-end-gate test no longer has to `markTestSkipped`, so the #13 fix is verified again. #15 — `Services_Test` was a tautology + reflection into a private array, because `set( object )` couldn't feed a `\stdClass` back through a typed accessor. Now generated per-accessor from `addService()`: `createMock()` of the real class (satisfies the return type), `Services::set()`, then assert the accessor hands back that instance and the same instance twice, and that `reset()` clears it — all through the public API. `Services_Test` only ships when there's >= 1 accessor. Verified: 68 generator + 13 engine tests; full + minimal scaffolds php -l clean, every unit test extends Plugin_TestCase, Services_Test present only with an accessor. --- index.js | 61 +++++++++++++++---- .../Unit/Account_Endpoint_Service_Test.php | 3 +- .../Unit/Action_Scheduler_Service_Test.php | 3 +- templates/tests/Unit/Ajax_Handler_Test.php | 3 +- templates/tests/Unit/Block_Registrar_Test.php | 3 +- templates/tests/Unit/Cache_Service_Test.php | 3 +- .../tests/Unit/Cart_Summary_Block_Test.php | 3 +- templates/tests/Unit/Commands_Test.php | 10 ++- templates/tests/Unit/Custom_Email_Test.php | 3 +- templates/tests/Unit/Custom_Product_Test.php | 3 +- templates/tests/Unit/Example_Test.php | 3 +- templates/tests/Unit/Gateway_Test.php | 3 +- templates/tests/Unit/Item_Repository_Test.php | 3 +- .../tests/Unit/Order_Status_Service_Test.php | 3 +- templates/tests/Unit/Plugin_TestCase.php | 36 +++++++++++ templates/tests/Unit/Post_Types_Test.php | 3 +- templates/tests/Unit/Rest_Controller_Test.php | 3 +- templates/tests/Unit/Scheduler_Test.php | 3 +- templates/tests/Unit/Schema_Test.php | 7 +-- templates/tests/Unit/Services_Test.php | 48 ++------------- .../tests/Unit/Settings_Repository_Test.php | 3 +- templates/tests/Unit/Shipping_Method_Test.php | 3 +- templates/tests/Unit/Shortcode_Test.php | 3 +- .../tests/Unit/Store_Api_Extension_Test.php | 3 +- .../tests/Unit/Widget_Registrar_Test.php | 3 +- tests/generator.test.js | 61 +++++++++++++++++++ 26 files changed, 179 insertions(+), 104 deletions(-) create mode 100644 templates/tests/Unit/Plugin_TestCase.php diff --git a/index.js b/index.js index 6488fc9..966c7e2 100644 --- a/index.js +++ b/index.js @@ -956,8 +956,8 @@ function scaffoldInto(answers, targetDir) { writeTemplateFile(path.join(templatesDir, 'phpcs.xml'), 'phpcs.xml'); writeTemplateFile(path.join(templatesDir, 'tests/bootstrap.php'), 'tests/bootstrap.php'); writeTemplateFile(path.join(templatesDir, 'phpunit.xml.dist'), 'phpunit.xml.dist'); + writeTemplateFile(path.join(templatesDir, 'tests/Unit/Plugin_TestCase.php'), 'tests/Unit/Plugin_TestCase.php'); writeTemplateFile(path.join(templatesDir, 'tests/Unit/Example_Test.php'), 'tests/Unit/Example_Test.php'); - writeTemplateFile(path.join(templatesDir, 'tests/Unit/Services_Test.php'), 'tests/Unit/Services_Test.php'); writeTemplateFile(path.join(templatesDir, 'gitignore.tpl'), '.gitignore'); writeTemplateFile(path.join(templatesDir, 'editorconfig.tpl'), '.editorconfig'); writeTemplateFile(path.join(templatesDir, 'LICENSE'), 'LICENSE'); @@ -991,12 +991,15 @@ function scaffoldInto(answers, targetDir) { const bootLines = []; const wooBootLines = []; const servicesAccessors = []; + const servicesAccessorTests = []; - // One memoised getter on Services: name() -> new (). $short is the - // class name relative to the plugin root namespace (Services lives there). - function servicesAccessor(name, short) { + // Register a shared-service getter: emits the Services::name() accessor + // ({{SERVICES_ACCESSORS}}) and a matching Services_Test method + // ({{SERVICES_ACCESSOR_TESTS}}). `short` is the class name relative to the + // plugin root namespace (Services lives there). + function addService(name, short) { const type = short.split('\\').pop(); - return [ + servicesAccessors.push([ '\t/**', `\t * Shared ${type} instance.`, '\t *', @@ -1006,7 +1009,28 @@ function scaffoldInto(answers, targetDir) { `\t\treturn self::$instances['${name}'] ??= new ${short}();`, '\t}', '', - ].join('\n'); + ].join('\n')); + servicesAccessorTests.push([ + '\t/**', + `\t * Services::${name}() is a memoised singleton, overridable via set()/reset().`, + '\t *', + '\t * @return void', + '\t */', + `\tpublic function test_${name}_is_a_memoised_singleton(): void {`, + `\t\t$a = $this->createMock( \\{{NS}}\\${short}::class );`, + `\t\tServices::set( '${name}', $a );`, + '', + `\t\t$this->assertSame( $a, Services::${name}() );`, + `\t\t$this->assertSame( Services::${name}(), Services::${name}() );`, + '', + '\t\tServices::reset();', + `\t\t$b = $this->createMock( \\{{NS}}\\${short}::class );`, + `\t\tServices::set( '${name}', $b );`, + '', + `\t\t$this->assertSame( $b, Services::${name}(), 'reset() cleared the previous override' );`, + '\t}', + '', + ].join('\n')); } if (selectedModules.includes('cli')) { @@ -1023,7 +1047,7 @@ function scaffoldInto(answers, targetDir) { writeTemplateFile(path.join(templatesDir, 'src/Admin/views/settings-page.php'), 'src/Admin/views/settings-page.php'); bootLines.push('\t\t( new Admin\\Settings_Registrar( Services::settings_repository() ) )->init_hooks();'); - servicesAccessors.push(servicesAccessor('settings_repository', 'Admin\\Settings_Repository')); + addService('settings_repository', 'Admin\\Settings_Repository'); } if (selectedModules.includes('shortcode')) { writeTemplateFile(path.join(templatesDir, 'src/Frontend/Shortcode.php'), 'src/Frontend/Shortcode.php'); @@ -1057,7 +1081,7 @@ function scaffoldInto(answers, targetDir) { if (selectedModules.includes('caching')) { writeTemplateFile(path.join(templatesDir, 'src/Cache/Cache_Service.php'), 'src/Cache/Cache_Service.php'); writeTemplateFile(path.join(templatesDir, 'tests/Unit/Cache_Service_Test.php'), 'tests/Unit/Cache_Service_Test.php'); - servicesAccessors.push(servicesAccessor('cache', 'Cache\\Cache_Service')); + addService('cache', 'Cache\\Cache_Service'); } if (selectedModules.includes('custom_table')) { writeTemplateFile(path.join(templatesDir, 'src/Database/Schema.php'), 'src/Database/Schema.php'); @@ -1065,7 +1089,7 @@ function scaffoldInto(answers, targetDir) { writeTemplateFile(path.join(templatesDir, 'tests/Unit/Schema_Test.php'), 'tests/Unit/Schema_Test.php'); writeTemplateFile(path.join(templatesDir, 'tests/Unit/Item_Repository_Test.php'), 'tests/Unit/Item_Repository_Test.php'); bootLines.push('\t\t( new Database\\Schema() )->init_hooks();'); - servicesAccessors.push(servicesAccessor('item_repository', 'Database\\Item_Repository')); + addService('item_repository', 'Database\\Item_Repository'); } if (selectedModules.includes('elementor_widget')) { if (selectedModules.includes('editor_config')) { @@ -1124,21 +1148,21 @@ function scaffoldInto(answers, targetDir) { writeTemplateFile(path.join(templatesDir, 'src/Woo/Orders/Order_Status_Service.php'), 'src/Woo/Orders/Order_Status_Service.php'); writeTemplateFile(path.join(templatesDir, 'tests/Unit/Order_Status_Service_Test.php'), 'tests/Unit/Order_Status_Service_Test.php'); wooBootLines.push('\t\t\t( new Woo\\Providers\\Order_Status_Provider( Services::order_status_service() ) )->init_hooks();'); - servicesAccessors.push(servicesAccessor('order_status_service', 'Woo\\Orders\\Order_Status_Service')); + addService('order_status_service', 'Woo\\Orders\\Order_Status_Service'); } if (hasWooActionScheduler) { writeTemplateFile(path.join(templatesDir, 'src/Woo/Providers/Action_Scheduler_Provider.php'), 'src/Woo/Providers/Action_Scheduler_Provider.php'); writeTemplateFile(path.join(templatesDir, 'src/Woo/Tasks/Action_Scheduler_Service.php'), 'src/Woo/Tasks/Action_Scheduler_Service.php'); writeTemplateFile(path.join(templatesDir, 'tests/Unit/Action_Scheduler_Service_Test.php'), 'tests/Unit/Action_Scheduler_Service_Test.php'); wooBootLines.push('\t\t\t( new Woo\\Providers\\Action_Scheduler_Provider( Services::action_scheduler_service() ) )->init_hooks();'); - servicesAccessors.push(servicesAccessor('action_scheduler_service', 'Woo\\Tasks\\Action_Scheduler_Service')); + addService('action_scheduler_service', 'Woo\\Tasks\\Action_Scheduler_Service'); } if (hasWooStoreApi) { writeTemplateFile(path.join(templatesDir, 'src/Woo/Providers/Store_Api_Provider.php'), 'src/Woo/Providers/Store_Api_Provider.php'); writeTemplateFile(path.join(templatesDir, 'src/Woo/Api/Store_Api_Extension.php'), 'src/Woo/Api/Store_Api_Extension.php'); writeTemplateFile(path.join(templatesDir, 'tests/Unit/Store_Api_Extension_Test.php'), 'tests/Unit/Store_Api_Extension_Test.php'); wooBootLines.push('\t\t\t( new Woo\\Providers\\Store_Api_Provider( Services::store_api_extension() ) )->init_hooks();'); - servicesAccessors.push(servicesAccessor('store_api_extension', 'Woo\\Api\\Store_Api_Extension')); + addService('store_api_extension', 'Woo\\Api\\Store_Api_Extension'); } if (hasWooMyAccount) { writeTemplateFile(path.join(templatesDir, 'src/Woo/Providers/Account_Endpoint_Provider.php'), 'src/Woo/Providers/Account_Endpoint_Provider.php'); @@ -1146,7 +1170,7 @@ function scaffoldInto(answers, targetDir) { writeTemplateFile(path.join(templatesDir, 'woo-account-templates/my-account/custom-endpoint.php'), `templates/my-account/${answers.prefix.toLowerCase()}-custom.php`); writeTemplateFile(path.join(templatesDir, 'tests/Unit/Account_Endpoint_Service_Test.php'), 'tests/Unit/Account_Endpoint_Service_Test.php'); wooBootLines.push('\t\t\t( new Woo\\Providers\\Account_Endpoint_Provider( Services::account_endpoint_service() ) )->init_hooks();'); - servicesAccessors.push(servicesAccessor('account_endpoint_service', 'Woo\\Account\\Account_Endpoint_Service')); + addService('account_endpoint_service', 'Woo\\Account\\Account_Endpoint_Service'); } if (selectedModules.includes('interactivity')) { writeTemplateFile(path.join(templatesDir, 'src/Frontend/Interactivity.php'), 'src/Frontend/Interactivity.php'); @@ -1376,6 +1400,17 @@ ${entries.join('\n')} fs.mkdirSync(path.dirname(servicesDestPath), { recursive: true }); fs.writeFileSync(servicesDestPath, servicesContent, 'utf8'); + // Services_Test.php only ships when there's at least one accessor to + // exercise — with none, the class has nothing behavioural to test. + if (servicesAccessorTests.length > 0) { + let servicesTest = fs.readFileSync(path.join(templatesDir, 'tests/Unit/Services_Test.php'), 'utf8'); + servicesTest = servicesTest.replace('{{SERVICES_ACCESSOR_TESTS}}', () => servicesAccessorTests.join('\n')); + servicesTest = processTemplateContent(servicesTest, 'tests/Unit/Services_Test.php'); + const servicesTestDest = path.join(targetDir, 'tests/Unit/Services_Test.php'); + fs.mkdirSync(path.dirname(servicesTestDest), { recursive: true }); + fs.writeFileSync(servicesTestDest, servicesTest, 'utf8'); + } + // Single supported PHP line — see MIN_PHP. The matrix also runs the next // minor so a scaffold surfaces forward-compat breakage early. const ciPhpMatrix = "['8.2', '8.3', '8.4']"; diff --git a/templates/tests/Unit/Account_Endpoint_Service_Test.php b/templates/tests/Unit/Account_Endpoint_Service_Test.php index 5f4f5c4..18c0c23 100644 --- a/templates/tests/Unit/Account_Endpoint_Service_Test.php +++ b/templates/tests/Unit/Account_Endpoint_Service_Test.php @@ -11,13 +11,12 @@ use Brain\Monkey\Functions; use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; -use PHPUnit\Framework\TestCase; use {{NS}}\Woo\Account\Account_Endpoint_Service; /** * Class Account_Endpoint_Service_Test. */ -class Account_Endpoint_Service_Test extends TestCase { +class Account_Endpoint_Service_Test extends Plugin_TestCase { use MockeryPHPUnitIntegration; diff --git a/templates/tests/Unit/Action_Scheduler_Service_Test.php b/templates/tests/Unit/Action_Scheduler_Service_Test.php index 7745e1f..375c4bc 100644 --- a/templates/tests/Unit/Action_Scheduler_Service_Test.php +++ b/templates/tests/Unit/Action_Scheduler_Service_Test.php @@ -11,13 +11,12 @@ use Brain\Monkey\Functions; use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; -use PHPUnit\Framework\TestCase; use {{NS}}\Woo\Tasks\Action_Scheduler_Service; /** * Class Action_Scheduler_Service_Test. */ -class Action_Scheduler_Service_Test extends TestCase { +class Action_Scheduler_Service_Test extends Plugin_TestCase { use MockeryPHPUnitIntegration; diff --git a/templates/tests/Unit/Ajax_Handler_Test.php b/templates/tests/Unit/Ajax_Handler_Test.php index fc5d833..d0b1d9a 100644 --- a/templates/tests/Unit/Ajax_Handler_Test.php +++ b/templates/tests/Unit/Ajax_Handler_Test.php @@ -9,7 +9,6 @@ namespace {{NS}}\Tests\Unit; -use PHPUnit\Framework\TestCase; use Brain\Monkey; use Brain\Monkey\Functions; use {{NS}}\Ajax\Ajax_Handler; @@ -17,7 +16,7 @@ /** * Class Ajax_Handler_Test. */ -class Ajax_Handler_Test extends TestCase { +class Ajax_Handler_Test extends Plugin_TestCase { /** * Set up test environment. diff --git a/templates/tests/Unit/Block_Registrar_Test.php b/templates/tests/Unit/Block_Registrar_Test.php index 76250b8..cf04444 100644 --- a/templates/tests/Unit/Block_Registrar_Test.php +++ b/templates/tests/Unit/Block_Registrar_Test.php @@ -9,7 +9,6 @@ namespace {{NS}}\Tests\Unit; -use PHPUnit\Framework\TestCase; use Brain\Monkey; use Brain\Monkey\Actions; use Brain\Monkey\Functions; @@ -18,7 +17,7 @@ /** * Class Block_Registrar_Test. */ -class Block_Registrar_Test extends TestCase { +class Block_Registrar_Test extends Plugin_TestCase { /** * Set up test environment. diff --git a/templates/tests/Unit/Cache_Service_Test.php b/templates/tests/Unit/Cache_Service_Test.php index fc091b3..9f172c8 100644 --- a/templates/tests/Unit/Cache_Service_Test.php +++ b/templates/tests/Unit/Cache_Service_Test.php @@ -9,7 +9,6 @@ namespace {{NS}}\Tests\Unit; -use PHPUnit\Framework\TestCase; use Brain\Monkey; use Brain\Monkey\Functions; use {{NS}}\Cache\Cache_Service; @@ -17,7 +16,7 @@ /** * Class Cache_Service_Test. */ -class Cache_Service_Test extends TestCase { +class Cache_Service_Test extends Plugin_TestCase { /** * Set up test environment. diff --git a/templates/tests/Unit/Cart_Summary_Block_Test.php b/templates/tests/Unit/Cart_Summary_Block_Test.php index 03f8d26..11c9c76 100644 --- a/templates/tests/Unit/Cart_Summary_Block_Test.php +++ b/templates/tests/Unit/Cart_Summary_Block_Test.php @@ -11,13 +11,12 @@ use Brain\Monkey\Functions; use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; -use PHPUnit\Framework\TestCase; use {{NS}}\Woo\Blocks\Cart_Summary_Block; /** * Class Cart_Summary_Block_Test. */ -class Cart_Summary_Block_Test extends TestCase { +class Cart_Summary_Block_Test extends Plugin_TestCase { use MockeryPHPUnitIntegration; diff --git a/templates/tests/Unit/Commands_Test.php b/templates/tests/Unit/Commands_Test.php index 5a77ee8..414222d 100644 --- a/templates/tests/Unit/Commands_Test.php +++ b/templates/tests/Unit/Commands_Test.php @@ -9,15 +9,21 @@ namespace {{NS}}\Tests\Unit; -use PHPUnit\Framework\TestCase; use Brain\Monkey; use Brain\Monkey\Functions; use {{NS}}\CLI\Commands; /** * Class Commands_Test. + * + * Runs in a separate process: it defines the WP_CLI constant, and define() + * cannot be undone — without isolation that would leak into every test that + * ran afterwards (e.g. Schema_Test's front-end-context check). + * + * @runInSeparateProcess + * @preserveGlobalState disabled */ -class Commands_Test extends TestCase { +class Commands_Test extends Plugin_TestCase { /** * Set up test environment. diff --git a/templates/tests/Unit/Custom_Email_Test.php b/templates/tests/Unit/Custom_Email_Test.php index fbb84e4..6eb8901 100644 --- a/templates/tests/Unit/Custom_Email_Test.php +++ b/templates/tests/Unit/Custom_Email_Test.php @@ -11,13 +11,12 @@ use Brain\Monkey\Functions; use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; -use PHPUnit\Framework\TestCase; use {{NS}}\Woo\Emails\Custom_Email; /** * Class Custom_Email_Test. */ -class Custom_Email_Test extends TestCase { +class Custom_Email_Test extends Plugin_TestCase { use MockeryPHPUnitIntegration; diff --git a/templates/tests/Unit/Custom_Product_Test.php b/templates/tests/Unit/Custom_Product_Test.php index b0d0814..2a44a0a 100644 --- a/templates/tests/Unit/Custom_Product_Test.php +++ b/templates/tests/Unit/Custom_Product_Test.php @@ -11,13 +11,12 @@ use Brain\Monkey\Functions; use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; -use PHPUnit\Framework\TestCase; use {{NS}}\Woo\Products\Custom_Product; /** * Class Custom_Product_Test. */ -class Custom_Product_Test extends TestCase { +class Custom_Product_Test extends Plugin_TestCase { use MockeryPHPUnitIntegration; diff --git a/templates/tests/Unit/Example_Test.php b/templates/tests/Unit/Example_Test.php index a90d7c5..8bf8387 100644 --- a/templates/tests/Unit/Example_Test.php +++ b/templates/tests/Unit/Example_Test.php @@ -9,14 +9,13 @@ namespace {{NS}}\Tests\Unit; -use PHPUnit\Framework\TestCase; use Brain\Monkey; use {{NS}}\Plugin; /** * Class Example_Test. */ -class Example_Test extends TestCase { +class Example_Test extends Plugin_TestCase { /** * Set up test environment before each test. diff --git a/templates/tests/Unit/Gateway_Test.php b/templates/tests/Unit/Gateway_Test.php index 02be74d..2bbe709 100644 --- a/templates/tests/Unit/Gateway_Test.php +++ b/templates/tests/Unit/Gateway_Test.php @@ -9,14 +9,13 @@ namespace {{NS}}\Tests\Unit; -use PHPUnit\Framework\TestCase; use Brain\Monkey; use Brain\Monkey\Functions; /** * Class Gateway_Test. */ -class Gateway_Test extends TestCase { +class Gateway_Test extends Plugin_TestCase { /** * Set up test environment. diff --git a/templates/tests/Unit/Item_Repository_Test.php b/templates/tests/Unit/Item_Repository_Test.php index be92e2a..d9bcbbd 100644 --- a/templates/tests/Unit/Item_Repository_Test.php +++ b/templates/tests/Unit/Item_Repository_Test.php @@ -9,7 +9,6 @@ namespace {{NS}}\Tests\Unit; -use PHPUnit\Framework\TestCase; use Brain\Monkey; use {{NS}}\Database\Item_Repository; use {{NS}}\Database\Schema; @@ -17,7 +16,7 @@ /** * Class Item_Repository_Test. */ -class Item_Repository_Test extends TestCase { +class Item_Repository_Test extends Plugin_TestCase { /** * Set up test environment. diff --git a/templates/tests/Unit/Order_Status_Service_Test.php b/templates/tests/Unit/Order_Status_Service_Test.php index 10ce042..c75cf36 100644 --- a/templates/tests/Unit/Order_Status_Service_Test.php +++ b/templates/tests/Unit/Order_Status_Service_Test.php @@ -11,13 +11,12 @@ use Brain\Monkey\Functions; use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; -use PHPUnit\Framework\TestCase; use {{NS}}\Woo\Orders\Order_Status_Service; /** * Class Order_Status_Service_Test. */ -class Order_Status_Service_Test extends TestCase { +class Order_Status_Service_Test extends Plugin_TestCase { use MockeryPHPUnitIntegration; diff --git a/templates/tests/Unit/Plugin_TestCase.php b/templates/tests/Unit/Plugin_TestCase.php new file mode 100644 index 0000000..aedfe8b --- /dev/null +++ b/templates/tests/Unit/Plugin_TestCase.php @@ -0,0 +1,36 @@ +markTestSkipped( 'WP_CLI is defined in this test process; the front-end gate cannot be exercised.' ); - } - Functions\when( 'is_admin' )->justReturn( false ); Functions\when( 'wp_doing_cron' )->justReturn( false ); Functions\expect( 'get_option' )->never(); diff --git a/templates/tests/Unit/Services_Test.php b/templates/tests/Unit/Services_Test.php index ca31267..eeed0fa 100644 --- a/templates/tests/Unit/Services_Test.php +++ b/templates/tests/Unit/Services_Test.php @@ -9,53 +9,15 @@ namespace {{NS}}\Tests\Unit; -use PHPUnit\Framework\TestCase; use {{NS}}\Services; /** * Class Services_Test. * - * The locator memoises one instance per accessor, and lets a test swap a - * double in and clear everything afterwards. + * Each accessor is a memoised, overridable singleton: set() swaps a double + * in, a repeat call returns the same object, reset() forgets everything. + * Plugin_TestCase calls Services::reset() after every test. */ -class Services_Test extends TestCase { +class Services_Test extends Plugin_TestCase { - /** - * Reset the locator between tests. - */ - protected function tearDown(): void { - Services::reset(); - parent::tearDown(); - } - - /** - * A set() then reset() controls what an accessor hands back. - */ - public function test_set_overrides_and_reset_clears(): void { - $double = new \stdClass(); - Services::set( 'cache', $double ); - - $ref = new \ReflectionMethod( Services::class, 'set' ); - $this->assertTrue( $ref->isStatic() ); - - Services::reset(); - - // After reset() the override is gone; a real accessor would rebuild. - $prop = new \ReflectionProperty( Services::class, 'instances' ); - $prop->setAccessible( true ); - $this->assertSame( array(), $prop->getValue() ); - } - - /** - * A set() keeps the exact instance it was handed. - */ - public function test_set_stores_the_given_instance(): void { - $double = new \stdClass(); - Services::set( 'thing', $double ); - - $prop = new \ReflectionProperty( Services::class, 'instances' ); - $prop->setAccessible( true ); - - $this->assertSame( $double, $prop->getValue()['thing'] ); - } -} +{{SERVICES_ACCESSOR_TESTS}}} diff --git a/templates/tests/Unit/Settings_Repository_Test.php b/templates/tests/Unit/Settings_Repository_Test.php index 3db40b0..072af9d 100644 --- a/templates/tests/Unit/Settings_Repository_Test.php +++ b/templates/tests/Unit/Settings_Repository_Test.php @@ -9,7 +9,6 @@ namespace {{NS}}\Tests\Unit; -use PHPUnit\Framework\TestCase; use Brain\Monkey; use Brain\Monkey\Functions; use {{NS}}\Admin\Settings_Repository; @@ -17,7 +16,7 @@ /** * Class Settings_Repository_Test. */ -class Settings_Repository_Test extends TestCase { +class Settings_Repository_Test extends Plugin_TestCase { /** * Set up test environment. diff --git a/templates/tests/Unit/Shipping_Method_Test.php b/templates/tests/Unit/Shipping_Method_Test.php index 80c287b..d6e3d93 100644 --- a/templates/tests/Unit/Shipping_Method_Test.php +++ b/templates/tests/Unit/Shipping_Method_Test.php @@ -11,13 +11,12 @@ use Brain\Monkey\Functions; use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; -use PHPUnit\Framework\TestCase; use {{NS}}\Woo\Shipping\Shipping_Method; /** * Class Shipping_Method_Test. */ -class Shipping_Method_Test extends TestCase { +class Shipping_Method_Test extends Plugin_TestCase { use MockeryPHPUnitIntegration; diff --git a/templates/tests/Unit/Shortcode_Test.php b/templates/tests/Unit/Shortcode_Test.php index fbbe86e..6a94adf 100644 --- a/templates/tests/Unit/Shortcode_Test.php +++ b/templates/tests/Unit/Shortcode_Test.php @@ -9,7 +9,6 @@ namespace {{NS}}\Tests\Unit; -use PHPUnit\Framework\TestCase; use Brain\Monkey; use Brain\Monkey\Functions; use {{NS}}\Frontend\Shortcode; @@ -17,7 +16,7 @@ /** * Class Shortcode_Test. */ -class Shortcode_Test extends TestCase { +class Shortcode_Test extends Plugin_TestCase { /** * Set up test environment. diff --git a/templates/tests/Unit/Store_Api_Extension_Test.php b/templates/tests/Unit/Store_Api_Extension_Test.php index 9359d19..451704c 100644 --- a/templates/tests/Unit/Store_Api_Extension_Test.php +++ b/templates/tests/Unit/Store_Api_Extension_Test.php @@ -11,13 +11,12 @@ use Brain\Monkey\Functions; use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; -use PHPUnit\Framework\TestCase; use {{NS}}\Woo\Api\Store_Api_Extension; /** * Class Store_Api_Extension_Test. */ -class Store_Api_Extension_Test extends TestCase { +class Store_Api_Extension_Test extends Plugin_TestCase { use MockeryPHPUnitIntegration; diff --git a/templates/tests/Unit/Widget_Registrar_Test.php b/templates/tests/Unit/Widget_Registrar_Test.php index e61b98a..dd709d8 100644 --- a/templates/tests/Unit/Widget_Registrar_Test.php +++ b/templates/tests/Unit/Widget_Registrar_Test.php @@ -9,7 +9,6 @@ namespace {{NS}}\Tests\Unit; -use PHPUnit\Framework\TestCase; use Brain\Monkey; use Brain\Monkey\Functions; use {{NS}}\Elementor\Widget_Registrar; @@ -17,7 +16,7 @@ /** * Class Widget_Registrar_Test. */ -class Widget_Registrar_Test extends TestCase { +class Widget_Registrar_Test extends Plugin_TestCase { /** * Set up test environment. diff --git a/tests/generator.test.js b/tests/generator.test.js index e3a3471..e1f2809 100644 --- a/tests/generator.test.js +++ b/tests/generator.test.js @@ -1428,3 +1428,64 @@ test('WooCommerce bundle alias "woo:all" and "woocommerce" expand to all 9 sub-m }); + +test('test isolation: Plugin_TestCase base always ships; Services_Test only with an accessor and tests it through the public API (#14, #15)', () => { + const withSvc = path.join(__dirname, '../tmp-test-iso-svc'); + const bare = path.join(__dirname, '../tmp-test-iso-bare'); + for (const d of [withSvc, bare]) { + fs.rmSync(d, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } + + runGenerator({ + name: 'Iso Svc', slug: 'iso-svc', prefix: 'isvc', namespace: 'IsoSvc', + authorName: 'A', authorEmail: 'a@example.com', authorUri: 'https://e.com', + description: 'x', modules: ['caching', 'custom_table', 'cli'], useReact: false, out: withSvc + }); + runGenerator({ + name: 'Iso Bare', slug: 'iso-bare', prefix: 'ibar', namespace: 'IsoBare', + authorName: 'A', authorEmail: 'a@example.com', authorUri: 'https://e.com', + description: 'x', modules: [], useReact: false, out: bare + }); + + // Plugin_TestCase ships in every scaffold and resets both globals. + for (const d of [withSvc, bare]) { + const base = fs.readFileSync(path.join(d, 'tests/Unit/Plugin_TestCase.php'), 'utf8'); + assert.match(base, /abstract class Plugin_TestCase extends TestCase/); + assert.match(base, /Services::reset\(\);/); + assert.match(base, /Plugin::set_instance\( null \);/); + } + + // Every generated *_Test.php extends the base, not PHPUnit's TestCase directly. + for (const d of [withSvc, bare]) { + for (const f of fs.readdirSync(path.join(d, 'tests/Unit')).filter((n) => n.endsWith('_Test.php'))) { + const src = fs.readFileSync(path.join(d, 'tests/Unit', f), 'utf8'); + assert.match(src, /extends Plugin_TestCase \{/, `${f} must extend Plugin_TestCase`); + assert.ok(!/^use PHPUnit\Framework\TestCase;/m.test(src), `${f} keeps the TestCase import out (base owns it)`); + } + } + + // Commands_Test is process-isolated so its define('WP_CLI') can't leak. + const commandsTest = fs.readFileSync(path.join(withSvc, 'tests/Unit/Commands_Test.php'), 'utf8'); + assert.match(commandsTest, /@runInSeparateProcess/); + assert.match(commandsTest, /@preserveGlobalState disabled/); + + // Schema_Test's front-end gate test no longer has to skip on a leaked WP_CLI. + const schemaTest = fs.readFileSync(path.join(withSvc, 'tests/Unit/Schema_Test.php'), 'utf8'); + assert.ok(schemaTest.includes('test_maybe_upgrade_skips_on_a_frontend_request')); + assert.ok(!schemaTest.includes('markTestSkipped')); + + // Services_Test ships only when there's an accessor, and drives it through + // createMock() + the public accessor (no reflection). + const svcTest = fs.readFileSync(path.join(withSvc, 'tests/Unit/Services_Test.php'), 'utf8'); + assert.match(svcTest, /public function test_cache_is_a_memoised_singleton\(\): void/); + assert.ok(svcTest.includes('$this->createMock( \\IsoSvc\\Cache\\Cache_Service::class )')); + assert.ok(svcTest.includes('$this->assertSame( $a, Services::cache() )')); + assert.ok(svcTest.includes('$this->assertSame( Services::cache(), Services::cache() )'), 'memoisation asserted'); + assert.ok(!svcTest.includes('ReflectionProperty'), 'no reflection — public API only'); + assert.ok(!fs.existsSync(path.join(bare, 'tests/Unit/Services_Test.php')), 'no Services_Test in a scaffold with zero accessors'); + assert.ok(!fs.existsSync(path.join(withSvc, 'tests/Unit/Container_Test.php'))); + + for (const d of [withSvc, bare]) { + fs.rmSync(d, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } +}); From 7f3d7b0dd2d5916beeac7366e531c74ba45f2c58 Mon Sep 17 00:00:00 2001 From: Akshat Date: Sat, 29 Aug 2026 21:25:17 +0530 Subject: [PATCH 2/4] chore(templates): narrow the templates/ phpcs exclude; add composer prepare-dist (#11, #12 revised) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-audit #2 retracted "drop the */templates/* exclude" — the WC email/My-Account overrides genuinely fail three sniffs (PrefixAllGlobals on core WC hook names + loosely-named locals; EscapeOutput on plain-text bodies that use wp_strip_all_tags). But the blanket path exclude also silenced EscapeOutput on the HTML email template, where escaping does matter. Now: when a woo:email / woo:my-account module ships a templates/ dir, phpcs.xml adds `./templates` and excludes only: - WordPress.NamingConventions.PrefixAllGlobals for */templates/* - WordPress.Security.EscapeOutput for */templates/emails/plain/* only Everything else in templates/ (docblocks, spacing, i18n, the HTML email's escaping) is linted. Gated on a new has_wc_template_overrides flag; a scaffold without those modules has no templates/ reference at all. Also: `composer prepare-dist` = `composer install --no-dev --optimize-autoloader`, so `npm run plugin-zip` doesn't depend on the user reading the release docs. --- index.js | 2 ++ templates/README.md | 2 +- templates/composer.json | 3 ++- templates/phpcs.xml | 33 +++++++++++++++++++++++++-------- tests/generator.test.js | 37 +++++++++++++++++++++++++++++++++++++ 5 files changed, 67 insertions(+), 10 deletions(-) diff --git a/index.js b/index.js index 966c7e2..fded6eb 100644 --- a/index.js +++ b/index.js @@ -823,6 +823,8 @@ function scaffoldInto(answers, targetDir) { // + Core\Uninstaller only ship when one of these modules persists state. has_uninstall: ['admin_settings', 'cron', 'custom_table', 'elementor_widget'].some(m => selectedModules.includes(m)), has_woo: hasAnyWoo, + // The only modules that write a templates/ directory (WC template overrides). + has_wc_template_overrides: hasWooEmail || hasWooMyAccount, lint_wp_org: lintTarget === 'wp-org' || lintTarget === 'both', lint_vip: needsVip }; diff --git a/templates/README.md b/templates/README.md index 4cd50b8..37da321 100644 --- a/templates/README.md +++ b/templates/README.md @@ -82,7 +82,7 @@ created: {{#if needs_build_pipeline}} npm install && npm run build {{/if}} -composer install --no-dev --optimize-autoloader +composer prepare-dist # composer install --no-dev --optimize-autoloader npm run plugin-zip ``` diff --git a/templates/composer.json b/templates/composer.json index 9e71e58..ac40661 100644 --- a/templates/composer.json +++ b/templates/composer.json @@ -40,7 +40,8 @@ {{#if integration_tests}} "test:integration": "phpunit -c phpunit-integration.xml.dist", {{/if}} - "make-pot": "wp i18n make-pot . languages/{{SLUG}}.pot" + "make-pot": "wp i18n make-pot . languages/{{SLUG}}.pot", + "prepare-dist": "composer install --no-dev --optimize-autoloader" }, "config": { "allow-plugins": { diff --git a/templates/phpcs.xml b/templates/phpcs.xml index 35d69ff..10eaca7 100644 --- a/templates/phpcs.xml +++ b/templates/phpcs.xml @@ -12,18 +12,23 @@ ./assets/src {{/if}} +{{#if has_wc_template_overrides}} + + ./templates +{{/if}} /vendor/ /node_modules/ /assets/build/ - - */templates/* @@ -62,6 +67,9 @@ /tests/ +{{#if has_wc_template_overrides}} + */templates/* +{{/if}} @@ -80,6 +88,15 @@ */blocks/*/render.php +{{#if has_wc_template_overrides}} + + + */templates/emails/plain/* + +{{/if}} diff --git a/tests/generator.test.js b/tests/generator.test.js index e1f2809..b3114b7 100644 --- a/tests/generator.test.js +++ b/tests/generator.test.js @@ -1489,3 +1489,40 @@ test('test isolation: Plugin_TestCase base always ships; Services_Test only with fs.rmSync(d, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } }); + +test('phpcs.xml lints templates/ (with two narrow sniff excludes) only when a WC override module ships it (#11 revised)', () => { + const withTpl = path.join(__dirname, '../tmp-test-phpcs-tpl'); + const noTpl = path.join(__dirname, '../tmp-test-phpcs-notpl'); + for (const d of [withTpl, noTpl]) { + fs.rmSync(d, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } + + runGenerator({ + name: 'Tpl', slug: 'tpl', prefix: 'tplp', namespace: 'Tpl', + authorName: 'A', authorEmail: 'a@example.com', authorUri: 'https://e.com', + description: 'x', modules: ['woo:email', 'woo:my-account'], lintTarget: 'both', useReact: false, out: withTpl + }); + runGenerator({ + name: 'No Tpl', slug: 'no-tpl', prefix: 'ntpl', namespace: 'NoTpl', + authorName: 'A', authorEmail: 'a@example.com', authorUri: 'https://e.com', + description: 'x', modules: ['woo:gateway'], lintTarget: 'both', useReact: false, out: noTpl + }); + + const withXml = fs.readFileSync(path.join(withTpl, 'phpcs.xml'), 'utf8'); + assert.match(withXml, /\.\/templates<\/file>/); + assert.match(withXml, /[\s\S]*?\*\/templates\/\*<\/exclude-pattern>/); + assert.match(withXml, /\s*\*\/templates\/emails\/plain\/\*<\/exclude-pattern>/); + assert.ok(!/\{\{[#/]?[A-Za-z]/.test(withXml), 'no leftover template tags'); + + const noXml = fs.readFileSync(path.join(noTpl, 'phpcs.xml'), 'utf8'); + assert.ok(!noXml.includes('templates'), 'no templates/ references when no override module ships one'); + assert.ok(!/\{\{[#/]?[A-Za-z]/.test(noXml), 'no leftover template tags'); + + // The dist-prep helper is present so plugin-zip does not depend on README discipline (#12 revised). + const composer = JSON.parse(fs.readFileSync(path.join(withTpl, 'composer.json'), 'utf8')); + assert.equal(composer.scripts['prepare-dist'], 'composer install --no-dev --optimize-autoloader'); + + for (const d of [withTpl, noTpl]) { + fs.rmSync(d, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } +}); From 0da8e16b4f1ce3c820aedd41a7cc769cd0b98753 Mon Sep 17 00:00:00 2001 From: Akshat Date: Sat, 29 Aug 2026 21:30:25 +0530 Subject: [PATCH 3/4] fix(templates): drop the redundant WP_CLI guard from Commands::init_hooks() so the CLI test needs no define() (#14) --- templates/src/CLI/Commands.php | 10 ++++------ templates/tests/Unit/Commands_Test.php | 18 +++++------------- tests/generator.test.js | 8 +++++--- 3 files changed, 14 insertions(+), 22 deletions(-) diff --git a/templates/src/CLI/Commands.php b/templates/src/CLI/Commands.php index a3e98c4..8d3e653 100644 --- a/templates/src/CLI/Commands.php +++ b/templates/src/CLI/Commands.php @@ -21,16 +21,14 @@ class Commands { /** * Register WP-CLI commands. * - * The bootloader only instantiates this class behind a WP_CLI guard; the - * repeat check here keeps the class safe to call directly (e.g. in tests). + * Plugin::boot() only instantiates this class when WP_CLI is defined and + * truthy, so there is no constant check here — keeping it out means unit + * tests don't have to define( 'WP_CLI' ) (which they can't undo, and which + * leaks into every test that runs afterwards). * * @return void */ public function init_hooks(): void { - if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) { - return; - } - \WP_CLI::add_command( '{{PREFIX}} status', $this->status( ... ) ); \WP_CLI::add_command( '{{PREFIX}} cache clear', $this->cache_clear( ... ) ); } diff --git a/templates/tests/Unit/Commands_Test.php b/templates/tests/Unit/Commands_Test.php index 414222d..4a0ece7 100644 --- a/templates/tests/Unit/Commands_Test.php +++ b/templates/tests/Unit/Commands_Test.php @@ -15,13 +15,6 @@ /** * Class Commands_Test. - * - * Runs in a separate process: it defines the WP_CLI constant, and define() - * cannot be undone — without isolation that would leak into every test that - * ran afterwards (e.g. Schema_Test's front-end-context check). - * - * @runInSeparateProcess - * @preserveGlobalState disabled */ class Commands_Test extends Plugin_TestCase { @@ -43,13 +36,12 @@ protected function tearDown(): void { } /** - * Both WP-CLI commands are registered by init_hooks() when WP_CLI is defined. + * init_hooks() registers both WP-CLI commands. + * + * No define( 'WP_CLI' ) here: Plugin::boot() owns that guard, so + * init_hooks() runs unconditionally and this test stays isolated. */ - public function test_init_hooks_registers_commands_under_wp_cli(): void { - if ( ! defined( 'WP_CLI' ) ) { - define( 'WP_CLI', true ); - } - + public function test_init_hooks_registers_both_commands(): void { ( new Commands() )->init_hooks(); $this->assertArrayHasKey( '{{PREFIX}} status', \WP_CLI::$commands ); diff --git a/tests/generator.test.js b/tests/generator.test.js index b3114b7..84d7784 100644 --- a/tests/generator.test.js +++ b/tests/generator.test.js @@ -1464,10 +1464,12 @@ test('test isolation: Plugin_TestCase base always ships; Services_Test only with } } - // Commands_Test is process-isolated so its define('WP_CLI') can't leak. + // Commands_Test no longer defines WP_CLI (the internal guard moved to + // Plugin::boot()), so there's no constant to leak into later tests. const commandsTest = fs.readFileSync(path.join(withSvc, 'tests/Unit/Commands_Test.php'), 'utf8'); - assert.match(commandsTest, /@runInSeparateProcess/); - assert.match(commandsTest, /@preserveGlobalState disabled/); + assert.ok(!/^\s*define\( 'WP_CLI', true \);/m.test(commandsTest), 'the test does not define WP_CLI'); + const commandsSrc = fs.readFileSync(path.join(withSvc, 'src/CLI/Commands.php'), 'utf8'); + assert.ok(!/init_hooks\(\): void \{\s*if \( ! defined\( 'WP_CLI' \)/.test(commandsSrc), 'init_hooks() carries no WP_CLI guard'); // Schema_Test's front-end gate test no longer has to skip on a leaked WP_CLI. const schemaTest = fs.readFileSync(path.join(withSvc, 'tests/Unit/Schema_Test.php'), 'utf8'); From 2ea3465436806b43ab13117eaafb011127d2cb6d Mon Sep 17 00:00:00 2001 From: Akshat Date: Sat, 29 Aug 2026 21:33:23 +0530 Subject: [PATCH 4/4] fix(templates): capitalise the Commands_Test docblock (WPCS ShortNotCapital) --- templates/tests/Unit/Commands_Test.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/tests/Unit/Commands_Test.php b/templates/tests/Unit/Commands_Test.php index 4a0ece7..a3c3ac2 100644 --- a/templates/tests/Unit/Commands_Test.php +++ b/templates/tests/Unit/Commands_Test.php @@ -36,7 +36,7 @@ protected function tearDown(): void { } /** - * init_hooks() registers both WP-CLI commands. + * Both WP-CLI commands are registered by init_hooks(). * * No define( 'WP_CLI' ) here: Plugin::boot() owns that guard, so * init_hooks() runs unconditionally and this test stays isolated.