From 64e9d142947c14ddda26083d88ef7e0a566ac205 Mon Sep 17 00:00:00 2001 From: Thomas Hauschild <7961978+Morgy93@users.noreply.github.com> Date: Sun, 5 Jul 2026 09:03:12 +0200 Subject: [PATCH 1/2] feat: improve unit tests --- .ddev/commands/web/phpunit | 7 +- .github/workflows/phpunit.yml | 5 + infection.json5 | 3 + .../FetchLatestVersionExceptionTest.php | 19 + .../Config/Source/InspectorThemeTest.php | 33 ++ .../Config/Source/ToolbarPositionTest.php | 36 ++ tests/Unit/Model/ThemeListTest.php | 41 +++ tests/Unit/Model/ThemePathTest.php | 65 ++++ tests/Unit/Service/CacheCleanerTest.php | 59 ++++ tests/Unit/Service/DependencyCheckerTest.php | 196 +++++++++++ tests/Unit/Service/GruntTaskRunnerTest.php | 82 +++++ .../Service/Hyva/CompatibilityCheckerTest.php | 328 ++++++++++++++++++ .../Hyva/IncompatibilityDetectorTest.php | 19 + tests/Unit/Service/Hyva/ModuleScannerTest.php | 195 +++++++++++ tests/Unit/Service/NodePackageManagerTest.php | 164 +++++++++ .../Unit/Service/StandardThemeBuilderTest.php | 124 +++++++ .../Unit/Service/StaticContentCleanerTest.php | 101 ++++++ .../Service/StaticContentDeployerTest.php | 72 ++++ tests/Unit/Service/SymlinkCleanerTest.php | 112 ++++++ .../ThemeBuilder/BuilderFactoryTest.php | 68 ++++ .../Service/ThemeBuilder/BuilderPoolTest.php | 63 ++++ tests/Unit/Service/ThemeCleanerTest.php | 272 +++++++++++++++ tests/Unit/Service/ThemeSuggesterTest.php | 121 +++++++ tests/coverage-checker.php | 43 +++ 24 files changed, 2227 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/Exception/FetchLatestVersionExceptionTest.php create mode 100644 tests/Unit/Model/Config/Source/InspectorThemeTest.php create mode 100644 tests/Unit/Model/Config/Source/ToolbarPositionTest.php create mode 100644 tests/Unit/Model/ThemeListTest.php create mode 100644 tests/Unit/Model/ThemePathTest.php create mode 100644 tests/Unit/Service/CacheCleanerTest.php create mode 100644 tests/Unit/Service/DependencyCheckerTest.php create mode 100644 tests/Unit/Service/GruntTaskRunnerTest.php create mode 100644 tests/Unit/Service/Hyva/CompatibilityCheckerTest.php create mode 100644 tests/Unit/Service/Hyva/ModuleScannerTest.php create mode 100644 tests/Unit/Service/NodePackageManagerTest.php create mode 100644 tests/Unit/Service/StandardThemeBuilderTest.php create mode 100644 tests/Unit/Service/StaticContentCleanerTest.php create mode 100644 tests/Unit/Service/StaticContentDeployerTest.php create mode 100644 tests/Unit/Service/SymlinkCleanerTest.php create mode 100644 tests/Unit/Service/ThemeBuilder/BuilderFactoryTest.php create mode 100644 tests/Unit/Service/ThemeBuilder/BuilderPoolTest.php create mode 100644 tests/Unit/Service/ThemeCleanerTest.php create mode 100644 tests/Unit/Service/ThemeSuggesterTest.php create mode 100644 tests/coverage-checker.php diff --git a/.ddev/commands/web/phpunit b/.ddev/commands/web/phpunit index 924fd720..07eb0669 100755 --- a/.ddev/commands/web/phpunit +++ b/.ddev/commands/web/phpunit @@ -36,11 +36,16 @@ if [[ ${coverage} == true ]]; then echo "Coverage reports: reports/coverage/index.html (HTML), reports/clover.xml (Clover)" # pcov.enabled=1: opt in per-run (globally off, see .ddev/php/pcov.ini). # opcache.jit=off: avoid the "JIT is incompatible" warning PCOV would trigger. - exec php -d pcov.enabled=1 -d opcache.jit=off -d opcache.jit_buffer_size=0 vendor/bin/phpunit \ + php -d pcov.enabled=1 -d opcache.jit=off -d opcache.jit_buffer_size=0 vendor/bin/phpunit \ --coverage-text \ --coverage-html reports/coverage \ --coverage-clover reports/clover.xml \ "${args[@]}" + # Quality ratchet, only meaningful for a full-suite run; keep the threshold + # in sync with .github/workflows/phpunit.yml. + if [[ ${#args[@]} -eq 0 ]]; then + exec php tests/coverage-checker.php reports/clover.xml 20 + fi fi exec vendor/bin/phpunit "${args[@]}" diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index 8c9e9252..82b9d32d 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -54,6 +54,11 @@ jobs: --coverage-html reports/coverage \ --coverage-clover reports/clover.xml + - name: Enforce minimum line coverage + if: ${{ matrix.coverage }} + # Quality ratchet — raise the threshold as coverage grows. + run: php tests/coverage-checker.php reports/clover.xml 20 + - name: Publish coverage summary if: ${{ matrix.coverage && always() }} run: | diff --git a/infection.json5 b/infection.json5 index d6aad063..156c8480 100644 --- a/infection.json5 +++ b/infection.json5 @@ -5,6 +5,9 @@ "excludes": ["registration.php"], }, "timeout": 10, + // Quality ratchet: fails the run (locally and in CI) when the covered-code + // MSI drops below this floor. Raise it as the test suite improves. + "minCoveredMsi": 90, "logs": { "text": "reports/infection/infection.log", "html": "reports/infection/infection.html", diff --git a/tests/Unit/Exception/FetchLatestVersionExceptionTest.php b/tests/Unit/Exception/FetchLatestVersionExceptionTest.php new file mode 100644 index 00000000..3cf07075 --- /dev/null +++ b/tests/Unit/Exception/FetchLatestVersionExceptionTest.php @@ -0,0 +1,19 @@ +assertInstanceOf(\RuntimeException::class, $exception); + $this->assertSame('fetch failed', $exception->getMessage()); + } +} diff --git a/tests/Unit/Model/Config/Source/InspectorThemeTest.php b/tests/Unit/Model/Config/Source/InspectorThemeTest.php new file mode 100644 index 00000000..416a4b7b --- /dev/null +++ b/tests/Unit/Model/Config/Source/InspectorThemeTest.php @@ -0,0 +1,33 @@ +assertInstanceOf(OptionSourceInterface::class, new InspectorTheme()); + } + + public function testReturnsAllInspectorThemes(): void + { + $options = (new InspectorTheme())->toOptionArray(); + + $this->assertSame(['dark', 'light', 'auto'], array_column($options, 'value')); + } + + public function testEveryOptionHasNonEmptyLabel(): void + { + foreach ((new InspectorTheme())->toOptionArray() as $option) { + $this->assertArrayHasKey('label', $option); + $this->assertIsString($option['label']); + $this->assertNotSame('', $option['label']); + } + } +} diff --git a/tests/Unit/Model/Config/Source/ToolbarPositionTest.php b/tests/Unit/Model/Config/Source/ToolbarPositionTest.php new file mode 100644 index 00000000..95aae573 --- /dev/null +++ b/tests/Unit/Model/Config/Source/ToolbarPositionTest.php @@ -0,0 +1,36 @@ +assertInstanceOf(OptionSourceInterface::class, new ToolbarPosition()); + } + + public function testReturnsAllToolbarPositions(): void + { + $options = (new ToolbarPosition())->toOptionArray(); + + $this->assertSame( + ['bottom-left', 'bottom-right', 'top-left', 'top-right'], + array_column($options, 'value'), + ); + } + + public function testEveryOptionHasNonEmptyLabel(): void + { + foreach ((new ToolbarPosition())->toOptionArray() as $option) { + $this->assertArrayHasKey('label', $option); + $this->assertIsString($option['label']); + $this->assertNotSame('', $option['label']); + } + } +} diff --git a/tests/Unit/Model/ThemeListTest.php b/tests/Unit/Model/ThemeListTest.php new file mode 100644 index 00000000..ce19f050 --- /dev/null +++ b/tests/Unit/Model/ThemeListTest.php @@ -0,0 +1,41 @@ +createMock(ThemeInterface::class); + $themeTwo = $this->createMock(ThemeInterface::class); + + $magentoThemeList = $this->createMock(MagentoThemeList::class); + $magentoThemeList + ->method('getItems') + ->willReturn(['frontend/Vendor/one' => $themeOne, 'frontend/Vendor/two' => $themeTwo]); + + $themeList = new ThemeList($magentoThemeList); + + $this->assertSame( + ['frontend/Vendor/one' => $themeOne, 'frontend/Vendor/two' => $themeTwo], + $themeList->getAllThemes(), + ); + } + + public function testReturnsEmptyArrayWhenNoThemesExist(): void + { + $magentoThemeList = $this->createMock(MagentoThemeList::class); + $magentoThemeList->method('getItems')->willReturn([]); + + $themeList = new ThemeList($magentoThemeList); + + $this->assertSame([], $themeList->getAllThemes()); + } +} diff --git a/tests/Unit/Model/ThemePathTest.php b/tests/Unit/Model/ThemePathTest.php new file mode 100644 index 00000000..e71db5a6 --- /dev/null +++ b/tests/Unit/Model/ThemePathTest.php @@ -0,0 +1,65 @@ +componentRegistrar = $this->createMock(ComponentRegistrarInterface::class); + $this->themePath = new ThemePath($this->componentRegistrar); + } + + public function testReturnsFrontendThemePath(): void + { + $this->componentRegistrar + ->method('getPaths') + ->with(ComponentRegistrar::THEME) + ->willReturn([ + 'frontend/Vendor/theme' => '/app/design/frontend/Vendor/theme', + 'adminhtml/Vendor/theme' => '/app/design/adminhtml/Vendor/theme', + ]); + + $this->assertSame('/app/design/frontend/Vendor/theme', $this->themePath->getPath('Vendor/theme')); + } + + public function testFallsBackToAdminhtmlThemePath(): void + { + $this->componentRegistrar + ->method('getPaths') + ->willReturn([ + 'adminhtml/Vendor/backend' => '/app/design/adminhtml/Vendor/backend', + ]); + + $this->assertSame('/app/design/adminhtml/Vendor/backend', $this->themePath->getPath('Vendor/backend')); + } + + public function testReturnsNullForUnknownTheme(): void + { + $this->componentRegistrar + ->method('getPaths') + ->willReturn([ + 'frontend/Other/theme' => '/app/design/frontend/Other/theme', + ]); + + $this->assertNull($this->themePath->getPath('Vendor/unknown')); + } + + public function testReturnsNullWhenNoThemesRegistered(): void + { + $this->componentRegistrar->method('getPaths')->willReturn([]); + + $this->assertNull($this->themePath->getPath('Vendor/theme')); + } +} diff --git a/tests/Unit/Service/CacheCleanerTest.php b/tests/Unit/Service/CacheCleanerTest.php new file mode 100644 index 00000000..de4fff10 --- /dev/null +++ b/tests/Unit/Service/CacheCleanerTest.php @@ -0,0 +1,59 @@ +shell = $this->createMock(Shell::class); + $this->io = $this->createMock(SymfonyStyle::class); + $this->cacheCleaner = new CacheCleaner($this->shell); + } + + public function testCleansFrontendCacheTypes(): void + { + $this->shell + ->expects($this->once()) + ->method('execute') + ->with('bin/magento cache:clean full_page block_html layout translate'); + + $this->assertTrue($this->cacheCleaner->clean($this->io, false)); + } + + public function testPrintsProgressInVerboseMode(): void + { + $this->io->expects($this->once())->method('text')->with('Cleaning cache...'); + $this->io->expects($this->once())->method('success')->with('Cache cleaned successfully.'); + + $this->assertTrue($this->cacheCleaner->clean($this->io, true)); + } + + public function testStaysQuietWhenNotVerbose(): void + { + $this->io->expects($this->never())->method('text'); + $this->io->expects($this->never())->method('success'); + + $this->assertTrue($this->cacheCleaner->clean($this->io, false)); + } + + public function testReturnsFalseAndPrintsErrorWhenShellFails(): void + { + $this->shell->method('execute')->willThrowException(new \RuntimeException('cache backend gone')); + $this->io->expects($this->once())->method('error')->with('Failed to clean cache: cache backend gone'); + + $this->assertFalse($this->cacheCleaner->clean($this->io, true)); + } +} diff --git a/tests/Unit/Service/DependencyCheckerTest.php b/tests/Unit/Service/DependencyCheckerTest.php new file mode 100644 index 00000000..fc4e0f91 --- /dev/null +++ b/tests/Unit/Service/DependencyCheckerTest.php @@ -0,0 +1,196 @@ +fileDriver = $this->createMock(File::class); + $this->shell = $this->createMock(Shell::class); + $this->io = $this->createMock(SymfonyStyle::class); + $this->checker = new DependencyChecker($this->fileDriver, $this->shell); + } + + public function testSucceedsWhenAllDependenciesArePresent(): void + { + $this->givenFiles(['package.json' => true, 'Gruntfile.js' => true]); + $this->fileDriver->method('isDirectory')->with('node_modules')->willReturn(true); + $this->io->expects($this->never())->method('error'); + $this->io->expects($this->never())->method('success'); + $this->io->expects($this->never())->method('warning'); + + $this->assertTrue($this->checker->checkDependencies($this->io, false)); + } + + public function testReportsEveryFoundDependencyInVerboseMode(): void + { + $this->givenFiles(['package.json' => true, 'Gruntfile.js' => true]); + $this->fileDriver->method('isDirectory')->with('node_modules')->willReturn(true); + + $successMessages = []; + $this->io + ->method('success') + ->willReturnCallback(function (string $message) use (&$successMessages): void { + $successMessages[] = $message; + }); + + $this->assertTrue($this->checker->checkDependencies($this->io, true)); + $this->assertSame( + [ + "The 'package.json' file found.", + "The 'node_modules' folder found.", + "The 'Gruntfile.js' file found.", + ], + $successMessages, + ); + } + + public function testWarnsAboutMissingFilesInVerboseMode(): void + { + $this->givenFiles(['package.json' => false, 'package.json.sample' => false]); + + $warnings = []; + $this->io + ->method('warning') + ->willReturnCallback(function (string $message) use (&$warnings): void { + $warnings[] = $message; + }); + + $this->assertFalse($this->checker->checkDependencies($this->io, true)); + $this->assertSame( + [ + "The 'package.json' file does not exist in the Magento root path.", + "The 'package.json.sample' file does not exist in the Magento root path.", + ], + $warnings, + ); + } + + public function testFailsWhenPackageJsonAndSampleAreMissing(): void + { + $this->givenFiles(['package.json' => false, 'package.json.sample' => false]); + $this->io->expects($this->once())->method('error')->with('Skipping this theme build.'); + + $this->assertFalse($this->checker->checkDependencies($this->io, false)); + } + + public function testCopiesPackageJsonFromSampleWhenConfirmed(): void + { + $this->givenFiles([ + 'package.json' => false, + 'package.json.sample' => true, + 'Gruntfile.js' => true, + ]); + $this->fileDriver->method('isDirectory')->willReturn(true); + $this->io->method('confirm')->willReturn(true); + $this->fileDriver + ->expects($this->once()) + ->method('copy') + ->with('package.json.sample', 'package.json'); + + $this->assertTrue($this->checker->checkDependencies($this->io, false)); + } + + public function testRunsNpmInstallWhenNodeModulesMissingAndConfirmed(): void + { + $this->givenFiles(['package.json' => true, 'Gruntfile.js' => true]); + $this->fileDriver->method('isDirectory')->willReturn(false); + $this->io->method('confirm')->willReturn(true); + $this->shell->expects($this->once())->method('execute')->with('npm install --quiet'); + + $this->assertTrue($this->checker->checkDependencies($this->io, false)); + } + + public function testFailsWhenNpmInstallFails(): void + { + $this->givenFiles(['package.json' => true]); + $this->fileDriver->method('isDirectory')->willReturn(false); + $this->io->method('confirm')->willReturn(true); + $this->shell->method('execute')->willThrowException(new \RuntimeException('npm error')); + $this->io->expects($this->once())->method('error')->with('npm error'); + + $this->assertFalse($this->checker->checkDependencies($this->io, false)); + } + + public function testFailsWhenNpmInstallIsDeclined(): void + { + $this->givenFiles(['package.json' => true]); + $this->fileDriver->method('isDirectory')->willReturn(false); + $this->io->method('confirm')->willReturn(false); + $this->shell->expects($this->never())->method('execute'); + $this->io->expects($this->once())->method('error')->with('Skipping this theme build.'); + + $this->assertFalse($this->checker->checkDependencies($this->io, false)); + } + + public function testFailsWhenGruntfileAndSampleAreMissing(): void + { + $this->givenFiles([ + 'package.json' => true, + 'Gruntfile.js' => false, + 'Gruntfile.js.sample' => false, + ]); + $this->fileDriver->method('isDirectory')->willReturn(true); + $this->io->expects($this->once())->method('error')->with('Skipping this theme build.'); + + $this->assertFalse($this->checker->checkDependencies($this->io, false)); + } + + public function testCopiesGruntfileFromSampleWhenConfirmed(): void + { + $this->givenFiles([ + 'package.json' => true, + 'Gruntfile.js' => false, + 'Gruntfile.js.sample' => true, + ]); + $this->fileDriver->method('isDirectory')->willReturn(true); + $this->io->method('confirm')->willReturn(true); + $this->fileDriver + ->expects($this->once()) + ->method('copy') + ->with('Gruntfile.js.sample', 'Gruntfile.js'); + + $this->assertTrue($this->checker->checkDependencies($this->io, false)); + } + + public function testSkippingSampleCopyStillSucceeds(): void + { + $this->givenFiles([ + 'package.json' => false, + 'package.json.sample' => true, + 'Gruntfile.js' => true, + ]); + $this->fileDriver->method('isDirectory')->willReturn(true); + $this->io->method('confirm')->willReturn(false); + $this->fileDriver->expects($this->never())->method('copy'); + + $this->assertTrue($this->checker->checkDependencies($this->io, false)); + } + + /** + * Configure the file driver mock; unlisted files do not exist. + * + * @param array $files + */ + private function givenFiles(array $files): void + { + $this->fileDriver + ->method('isFile') + ->willReturnCallback(static fn(string $path): bool => $files[$path] ?? false); + } +} diff --git a/tests/Unit/Service/GruntTaskRunnerTest.php b/tests/Unit/Service/GruntTaskRunnerTest.php new file mode 100644 index 00000000..93e2af02 --- /dev/null +++ b/tests/Unit/Service/GruntTaskRunnerTest.php @@ -0,0 +1,82 @@ +shell = $this->createMock(Shell::class); + $this->io = $this->createMock(SymfonyStyle::class); + $this->output = $this->createMock(OutputInterface::class); + $this->taskRunner = new GruntTaskRunner($this->shell); + } + + public function testRunsCleanAndLessQuietlyWhenNotVerbose(): void + { + $executedCommands = []; + $this->shell + ->method('execute') + ->willReturnCallback(function (string $command) use (&$executedCommands): string { + $executedCommands[] = $command; + return ''; + }); + $this->output->expects($this->never())->method('writeln'); + + $this->assertTrue($this->taskRunner->runTasks($this->io, $this->output, false)); + $this->assertSame( + ['node_modules/.bin/grunt clean --quiet', 'node_modules/.bin/grunt less --quiet'], + $executedCommands, + ); + } + + public function testRunsTasksAndForwardsOutputWhenVerbose(): void + { + $this->shell + ->method('execute') + ->willReturnMap([ + ['node_modules/.bin/grunt clean', [], 'clean done'], + ['node_modules/.bin/grunt less', [], 'less done'], + ]); + + $forwarded = []; + $this->output + ->method('writeln') + ->willReturnCallback(function (string $line) use (&$forwarded): void { + $forwarded[] = $line; + }); + $announced = []; + $this->io + ->method('text') + ->willReturnCallback(function (string $message) use (&$announced): void { + $announced[] = $message; + }); + $this->io->expects($this->once())->method('success')->with('Grunt tasks completed successfully.'); + + $this->assertTrue($this->taskRunner->runTasks($this->io, $this->output, true)); + $this->assertSame(['clean done', 'less done'], $forwarded); + $this->assertSame(['Running grunt clean...', 'Running grunt less...'], $announced); + } + + public function testReturnsFalseAndPrintsErrorWhenGruntFails(): void + { + $this->shell->method('execute')->willThrowException(new \RuntimeException('grunt not found')); + $this->io->expects($this->once())->method('error')->with('Failed to run grunt tasks: grunt not found'); + + $this->assertFalse($this->taskRunner->runTasks($this->io, $this->output, false)); + } +} diff --git a/tests/Unit/Service/Hyva/CompatibilityCheckerTest.php b/tests/Unit/Service/Hyva/CompatibilityCheckerTest.php new file mode 100644 index 00000000..0ce5092e --- /dev/null +++ b/tests/Unit/Service/Hyva/CompatibilityCheckerTest.php @@ -0,0 +1,328 @@ +componentRegistrar = $this->createMock(ComponentRegistrarInterface::class); + $this->moduleScanner = $this->createMock(ModuleScanner::class); + $this->io = $this->createMock(SymfonyStyle::class); + $this->checker = new CompatibilityChecker($this->componentRegistrar, $this->moduleScanner); + } + + // ------------------------------------------------------------------------- + // check + // ------------------------------------------------------------------------- + + public function testAggregatesSummaryAcrossModules(): void + { + $this->givenModules([ + 'Vendor_Clean' => '/app/code/Vendor/Clean', + 'Vendor_Broken' => '/app/code/Vendor/Broken', + 'Magento_Catalog' => '/app/code/Magento/Catalog', + ]); + $this->givenScanResults([ + '/app/code/Vendor/Clean' => $this->scanResult(critical: 0, total: 1), + '/app/code/Vendor/Broken' => $this->scanResult(critical: 2, total: 3), + '/app/code/Magento/Catalog' => $this->scanResult(critical: 1, total: 1), + ]); + $this->givenModuleInfo(hyvaAware: false); + $this->io + ->expects($this->once()) + ->method('text') + ->with('Scanning 3 modules for Hyvä compatibility...'); + $this->io->expects($this->once())->method('newLine'); + + $results = $this->checker->check($this->io); + + $this->assertSame( + [ + 'total' => 3, + 'compatible' => 1, + 'incompatible' => 2, + 'hyvaAware' => 0, + 'criticalIssues' => 3, + 'warningIssues' => 2, + ], + $results['summary'], + ); + $this->assertTrue($results['hasIncompatibilities']); + $this->assertTrue($results['modules']['Vendor_Clean']['compatible']); + $this->assertFalse($results['modules']['Vendor_Broken']['compatible']); + } + + public function testCriticalIssuesAloneMarkResultsAsIncompatible(): void + { + $this->givenModules(['Vendor_Broken' => '/app/code/Vendor/Broken']); + $this->givenScanResults(['/app/code/Vendor/Broken' => $this->scanResult(critical: 1, total: 1)]); + $this->givenModuleInfo(hyvaAware: false); + + $results = $this->checker->check($this->io); + + $this->assertTrue($results['hasIncompatibilities']); + $this->assertFalse($results['modules']['Vendor_Broken']['hasWarnings']); + } + + public function testFullyCompatibleModulesProduceNoIncompatibilities(): void + { + $this->givenModules(['Vendor_Clean' => '/app/code/Vendor/Clean']); + $this->givenScanResults(['/app/code/Vendor/Clean' => $this->scanResult(critical: 0, total: 0)]); + $this->givenModuleInfo(hyvaAware: false); + + $results = $this->checker->check($this->io); + + $this->assertFalse($results['hasIncompatibilities']); + $this->assertSame(0, $results['summary']['incompatible']); + } + + public function testWarningsAloneMarkResultsAsIncompatible(): void + { + $this->givenModules(['Vendor_Warned' => '/app/code/Vendor/Warned']); + $this->givenScanResults(['/app/code/Vendor/Warned' => $this->scanResult(critical: 0, total: 2)]); + $this->givenModuleInfo(hyvaAware: false); + + $results = $this->checker->check($this->io); + + $this->assertTrue($results['hasIncompatibilities']); + $this->assertTrue($results['modules']['Vendor_Warned']['compatible']); + $this->assertTrue($results['modules']['Vendor_Warned']['hasWarnings']); + $this->assertSame(2, $results['summary']['warningIssues']); + } + + public function testExcludesVendorModulesByDefault(): void + { + $this->givenModules([ + 'Vendor_Local' => '/app/code/Vendor/Local', + 'Thirdparty_Module' => '/app/vendor/thirdparty/module', + ]); + $this->givenScanResults(['/app/code/Vendor/Local' => $this->scanResult(critical: 0, total: 0)]); + $this->givenModuleInfo(hyvaAware: false); + + $results = $this->checker->check($this->io); + + $this->assertSame(['Vendor_Local'], array_keys($results['modules'])); + } + + public function testIncludesVendorModulesOnRequest(): void + { + $this->givenModules(['Thirdparty_Module' => '/app/vendor/thirdparty/module']); + $this->givenScanResults(['/app/vendor/thirdparty/module' => $this->scanResult(critical: 0, total: 0)]); + $this->givenModuleInfo(hyvaAware: false); + + $results = $this->checker->check($this->io, excludeVendor: false); + + $this->assertSame(['Thirdparty_Module'], array_keys($results['modules'])); + } + + public function testSkipsMagentoModulesInThirdPartyOnlyMode(): void + { + $this->givenModules([ + 'Magento_Catalog' => '/app/code/Magento/Catalog', + 'Vendor_Module' => '/app/code/Vendor/Module', + ]); + $this->givenScanResults(['/app/code/Vendor/Module' => $this->scanResult(critical: 0, total: 0)]); + $this->givenModuleInfo(hyvaAware: false); + + $results = $this->checker->check($this->io, thirdPartyOnly: true); + + $this->assertSame(['Vendor_Module'], array_keys($results['modules'])); + } + + public function testCountsHyvaAwareModules(): void + { + $this->givenModules(['Vendor_Module' => '/app/code/Vendor/Module']); + $this->givenScanResults(['/app/code/Vendor/Module' => $this->scanResult(critical: 0, total: 0)]); + $this->givenModuleInfo(hyvaAware: true); + + $results = $this->checker->check($this->io); + + $this->assertSame(1, $results['summary']['hyvaAware']); + } + + // ------------------------------------------------------------------------- + // formatResultsForDisplay + // ------------------------------------------------------------------------- + + public function testDisplaysOnlyProblematicModulesByDefault(): void + { + $results = [ + 'modules' => [ + 'Vendor_Clean' => $this->moduleEntry(compatible: true, hasWarnings: false, critical: 0, total: 0), + 'Vendor_Broken' => $this->moduleEntry(compatible: false, hasWarnings: false, critical: 2, total: 2), + 'Vendor_Warned' => $this->moduleEntry(compatible: true, hasWarnings: true, critical: 0, total: 1), + ], + 'summary' => [], + 'hasIncompatibilities' => true, + ]; + + $tableData = $this->checker->formatResultsForDisplay($results); + + $this->assertSame(['Vendor_Broken', 'Vendor_Warned'], array_column($tableData, 0)); + $this->assertSame('⚠ Warnings', $tableData[1][1]); + $this->assertSame('1 warning(s)', $tableData[1][2]); + } + + public function testDisplaysAllModulesWhenRequested(): void + { + $results = [ + 'modules' => [ + 'Vendor_Clean' => $this->moduleEntry(compatible: true, hasWarnings: false, critical: 0, total: 0), + 'Vendor_Broken' => $this->moduleEntry(compatible: false, hasWarnings: false, critical: 2, total: 2), + ], + 'summary' => [], + 'hasIncompatibilities' => true, + ]; + + $tableData = $this->checker->formatResultsForDisplay($results, true); + + $this->assertSame(['Vendor_Clean', 'Vendor_Broken'], array_column($tableData, 0)); + $this->assertSame('✓ Compatible', $tableData[0][1]); + $this->assertSame('None', $tableData[0][2]); + $this->assertSame('✗ Incompatible', $tableData[1][1]); + $this->assertSame('2 critical', $tableData[1][2]); + } + + public function testFormatsMixedIssuesAndHyvaAwareStatus(): void + { + $results = [ + 'modules' => [ + 'Vendor_Mixed' => $this->moduleEntry( + compatible: false, + hasWarnings: true, + critical: 1, + total: 3, + hyvaAware: true, + ), + ], + 'summary' => [], + 'hasIncompatibilities' => true, + ]; + + $tableData = $this->checker->formatResultsForDisplay($results); + + $this->assertSame('✗ Incompatible (Hyvä-Aware)', $tableData[0][1]); + $this->assertSame('1 critical, 2 warning(s)', $tableData[0][2]); + } + + public function testHyvaAwareCompatibleModuleGetsDedicatedStatus(): void + { + $results = [ + 'modules' => [ + 'Vendor_Aware' => $this->moduleEntry( + compatible: true, + hasWarnings: false, + critical: 0, + total: 0, + hyvaAware: true, + ), + ], + 'summary' => [], + 'hasIncompatibilities' => false, + ]; + + $tableData = $this->checker->formatResultsForDisplay($results, true); + + $this->assertSame('✓ Hyvä-Aware', $tableData[0][1]); + } + + // ------------------------------------------------------------------------- + // getDetailedIssues + // ------------------------------------------------------------------------- + + public function testReturnsIssuesGroupedByFile(): void + { + $jsIssue = ['description' => 'RequireJS define() usage', 'severity' => 'critical', 'line' => 3]; + $xmlIssue = ['description' => 'UI Component usage', 'severity' => 'critical', 'line' => 7]; + $moduleData = $this->moduleEntry(compatible: false, hasWarnings: false, critical: 2, total: 2); + $moduleData['scanResult']['files'] = [ + 'view/frontend/web/js/widget.js' => [$jsIssue], + 'view/frontend/layout/default.xml' => [$xmlIssue], + ]; + + $this->assertSame( + [ + ['file' => 'view/frontend/web/js/widget.js', 'issues' => [$jsIssue]], + ['file' => 'view/frontend/layout/default.xml', 'issues' => [$xmlIssue]], + ], + $this->checker->getDetailedIssues($moduleData), + ); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** + * @param array $modules + */ + private function givenModules(array $modules): void + { + $this->componentRegistrar + ->method('getPaths') + ->with(ComponentRegistrar::MODULE) + ->willReturn($modules); + } + + /** + * @param array> $resultsByPath + */ + private function givenScanResults(array $resultsByPath): void + { + $this->moduleScanner + ->method('scanModule') + ->willReturnCallback( + static fn(string $path): array => $resultsByPath[$path] + ?? ['files' => [], 'totalIssues' => 0, 'criticalIssues' => 0], + ); + } + + private function givenModuleInfo(bool $hyvaAware): void + { + $this->moduleScanner + ->method('getModuleInfo') + ->willReturn(['name' => 'vendor/module', 'version' => '1.0.0', 'isHyvaAware' => $hyvaAware]); + } + + /** + * @return array + */ + private function scanResult(int $critical, int $total): array + { + return ['files' => [], 'totalIssues' => $total, 'criticalIssues' => $critical]; + } + + /** + * @return array + */ + private function moduleEntry( + bool $compatible, + bool $hasWarnings, + int $critical, + int $total, + bool $hyvaAware = false, + ): array { + return [ + 'compatible' => $compatible, + 'hasWarnings' => $hasWarnings, + 'scanResult' => $this->scanResult($critical, $total), + 'moduleInfo' => ['name' => 'vendor/module', 'version' => '1.0.0', 'isHyvaAware' => $hyvaAware], + ]; + } +} diff --git a/tests/Unit/Service/Hyva/IncompatibilityDetectorTest.php b/tests/Unit/Service/Hyva/IncompatibilityDetectorTest.php index 4f2333de..f96a4f12 100644 --- a/tests/Unit/Service/Hyva/IncompatibilityDetectorTest.php +++ b/tests/Unit/Service/Hyva/IncompatibilityDetectorTest.php @@ -368,6 +368,25 @@ public function testReturnsEmptyArrayOnFileReadError(): void $this->assertEmpty($issues, 'File read errors must be handled gracefully'); } + public function testGetExtensionFromPathHandlesCaseSlashesAndMissingExtensions(): void + { + $this->assertSame('js', $this->detector->getExtensionFromPath('view/frontend/web/js/widget.JS')); + $this->assertSame('phtml', $this->detector->getExtensionFromPath('templates\\product\\view.phtml')); + $this->assertSame('xml', $this->detector->getExtensionFromPath('layout/default.xml/')); + $this->assertSame('js', $this->detector->getExtensionFromPath('script.js')); + $this->assertSame('', $this->detector->getExtensionFromPath('Makefile')); + $this->assertSame('', $this->detector->getExtensionFromPath('dir.name\\Makefile')); + } + + public function testDetectsUppercaseFileExtensions(): void + { + $this->fileMock->method('fileGetContents')->willReturn('var qty = ko.observable(1);'); + + $issues = $this->detector->detectInFile('legacy/WIDGET.JS'); + + $this->assertNotEmpty($issues, 'Uppercase file extensions must be scanned like lowercase ones'); + } + public function testReportsCorrectLineNumbers(): void { $content = "const x = 1;\nconst y = 2;\nko.applyBindings(viewModel);\nconst z = 3;"; diff --git a/tests/Unit/Service/Hyva/ModuleScannerTest.php b/tests/Unit/Service/Hyva/ModuleScannerTest.php new file mode 100644 index 00000000..c101eb87 --- /dev/null +++ b/tests/Unit/Service/Hyva/ModuleScannerTest.php @@ -0,0 +1,195 @@ +fileDriver = $this->createMock(File::class); + $this->detector = $this->createMock(IncompatibilityDetector::class); + $this->scanner = new ModuleScanner($this->fileDriver, $this->detector); + } + + // ------------------------------------------------------------------------- + // scanModule + // ------------------------------------------------------------------------- + + public function testReturnsEmptyResultForMissingModuleDirectory(): void + { + $this->fileDriver->method('isDirectory')->with('/module')->willReturn(false); + + $this->assertSame( + ['files' => [], 'totalIssues' => 0, 'criticalIssues' => 0], + $this->scanner->scanModule('/module'), + ); + } + + public function testCollectsIssuesAndCountsCriticalOnes(): void + { + $this->givenDirectories(['/module']); + $this->fileDriver->method('readDirectory')->with('/module')->willReturn([ + '/module/view.js', + '/module/layout.xml', + ]); + $this->detector->method('getExtensionFromPath')->willReturnMap([ + ['/module/view.js', 'js'], + ['/module/layout.xml', 'xml'], + ]); + $this->detector->method('detectInFile')->willReturnMap([ + ['/module/view.js', [ + ['description' => 'RequireJS define() usage', 'severity' => 'critical', 'line' => 1], + ['description' => 'jQuery AJAX direct usage', 'severity' => 'warning', 'line' => 5], + ]], + ['/module/layout.xml', []], + ]); + + $result = $this->scanner->scanModule('/module'); + + $this->assertSame(2, $result['totalIssues']); + $this->assertSame(1, $result['criticalIssues']); + $this->assertArrayHasKey('view.js', $result['files']); + $this->assertCount(1, $result['files']); + } + + public function testSkipsExcludedDirectoriesAndIrrelevantExtensions(): void + { + $this->givenDirectories(['/module', '/module/Test', '/module/node_modules', '/module/src']); + $this->fileDriver->method('readDirectory')->willReturnMap([ + ['/module', ['/module/Test', '/module/node_modules', '/module/src', '/module/readme.md']], + ['/module/src', ['/module/src/widget.js']], + ]); + $this->detector->method('getExtensionFromPath')->willReturnMap([ + ['/module/readme.md', 'md'], + ['/module/src/widget.js', 'js'], + ]); + + $scannedFiles = []; + $this->detector + ->method('detectInFile') + ->willReturnCallback(function (string $file) use (&$scannedFiles): array { + $scannedFiles[] = $file; + return []; + }); + + $this->scanner->scanModule('/module'); + + $this->assertSame(['/module/src/widget.js'], $scannedFiles); + } + + public function testSkipsUnreadableDirectories(): void + { + $this->givenDirectories(['/module']); + $this->fileDriver->method('readDirectory')->willThrowException(new \RuntimeException('permission denied')); + + $this->assertSame( + ['files' => [], 'totalIssues' => 0, 'criticalIssues' => 0], + $this->scanner->scanModule('/module'), + ); + } + + // ------------------------------------------------------------------------- + // getModuleInfo + // ------------------------------------------------------------------------- + + public function testModuleInfoIsUnknownWithoutComposerJson(): void + { + $this->fileDriver->method('isExists')->willReturn(false); + + $this->assertSame( + ['name' => 'Unknown', 'version' => 'Unknown', 'isHyvaAware' => false], + $this->scanner->getModuleInfo('/module'), + ); + } + + public function testModuleInfoIsUnknownForInvalidComposerJson(): void + { + $this->fileDriver->method('isExists')->willReturn(true); + $this->fileDriver->method('fileGetContents')->willReturn('not json'); + + $this->assertSame( + ['name' => 'Unknown', 'version' => 'Unknown', 'isHyvaAware' => false], + $this->scanner->getModuleInfo('/module'), + ); + } + + public function testModuleInfoIsUnknownWhenComposerJsonCannotBeRead(): void + { + $this->fileDriver->method('isExists')->willReturn(true); + $this->fileDriver->method('fileGetContents')->willThrowException(new \RuntimeException('read error')); + + $this->assertSame( + ['name' => 'Unknown', 'version' => 'Unknown', 'isHyvaAware' => false], + $this->scanner->getModuleInfo('/module'), + ); + } + + public function testModuleInfoReadsNameAndVersion(): void + { + $this->givenComposerJson(['name' => 'vendor/module', 'version' => '1.2.3']); + + $this->assertSame( + ['name' => 'vendor/module', 'version' => '1.2.3', 'isHyvaAware' => false], + $this->scanner->getModuleInfo('/module'), + ); + } + + public function testHyvaCompatPackageIsHyvaAware(): void + { + $this->givenComposerJson(['name' => 'hyva-themes/magento2-vendor-module-compat']); + + $this->assertTrue($this->scanner->getModuleInfo('/module')['isHyvaAware']); + } + + public function testModuleRequiringHyvaPackageIsHyvaAware(): void + { + $this->givenComposerJson([ + 'name' => 'vendor/module', + 'require' => ['php' => '^8.3', 'hyva-themes/magento2-default-theme' => '^1.3'], + ]); + + $this->assertTrue($this->scanner->getModuleInfo('/module')['isHyvaAware']); + } + + public function testPlainHyvaThemesPackageWithoutCompatSuffixIsNotHyvaAware(): void + { + $this->givenComposerJson(['name' => 'hyva-themes/magento2-theme-module']); + + $this->assertFalse($this->scanner->getModuleInfo('/module')['isHyvaAware']); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** + * @param array $directories + */ + private function givenDirectories(array $directories): void + { + $this->fileDriver + ->method('isDirectory') + ->willReturnCallback(static fn(string $path): bool => in_array($path, $directories, true)); + } + + /** + * @param array $composerData + */ + private function givenComposerJson(array $composerData): void + { + $this->fileDriver->method('isExists')->with('/module/composer.json')->willReturn(true); + $this->fileDriver->method('fileGetContents')->willReturn(json_encode($composerData)); + } +} diff --git a/tests/Unit/Service/NodePackageManagerTest.php b/tests/Unit/Service/NodePackageManagerTest.php new file mode 100644 index 00000000..5466a8c8 --- /dev/null +++ b/tests/Unit/Service/NodePackageManagerTest.php @@ -0,0 +1,164 @@ +shell = $this->createMock(Shell::class); + $this->fileDriver = $this->createMock(FileDriver::class); + $this->io = $this->createMock(SymfonyStyle::class); + $this->packageManager = new NodePackageManager($this->shell, $this->fileDriver); + } + + // ------------------------------------------------------------------------- + // installNodeModules + // ------------------------------------------------------------------------- + + public function testUsesNpmCiWhenLockFileExists(): void + { + $this->fileDriver->method('isExists')->with('/theme/package-lock.json')->willReturn(true); + $this->shell + ->expects($this->once()) + ->method('execute') + ->with('cd %s && npm ci --quiet', ['/theme']); + + $this->assertTrue($this->packageManager->installNodeModules('/theme', $this->io, false)); + } + + public function testFallsBackToNpmInstallWhenNpmCiFails(): void + { + $this->fileDriver->method('isExists')->willReturn(true); + + $executedCommands = []; + $this->shell + ->method('execute') + ->willReturnCallback(function (string $command, array $args) use (&$executedCommands): string { + $executedCommands[] = $command; + if (str_contains($command, 'npm ci')) { + throw new \RuntimeException('lock file out of sync'); + } + return ''; + }); + + $this->assertTrue($this->packageManager->installNodeModules('/theme', $this->io, false)); + $this->assertSame(['cd %s && npm ci --quiet', 'cd %s && npm install --quiet'], $executedCommands); + } + + public function testUsesNpmInstallWhenLockFileIsMissing(): void + { + $this->fileDriver->method('isExists')->willReturn(false); + $this->shell + ->expects($this->once()) + ->method('execute') + ->with('cd %s && npm install --quiet', ['/theme']); + + $this->assertTrue($this->packageManager->installNodeModules('/theme', $this->io, false)); + } + + public function testReturnsFalseAndPrintsErrorWhenInstallationFails(): void + { + $this->fileDriver->method('isExists')->willReturn(false); + $this->shell->method('execute')->willThrowException(new \RuntimeException('npm not found')); + $this->io + ->expects($this->once()) + ->method('error') + ->with('Failed to install node modules: npm not found'); + + $this->assertFalse($this->packageManager->installNodeModules('/theme', $this->io, false)); + } + + // ------------------------------------------------------------------------- + // isNodeModulesInSync + // ------------------------------------------------------------------------- + + public function testNotInSyncWhenNodeModulesIsMissing(): void + { + $this->fileDriver->method('isDirectory')->with('/theme/node_modules')->willReturn(false); + + $this->assertFalse($this->packageManager->isNodeModulesInSync('/theme')); + } + + public function testNotInSyncWhenLockFileIsMissing(): void + { + $this->fileDriver->method('isDirectory')->willReturn(true); + $this->fileDriver->method('isExists')->with('/theme/package-lock.json')->willReturn(false); + + $this->assertFalse($this->packageManager->isNodeModulesInSync('/theme')); + } + + public function testInSyncWhenNpmLsSucceeds(): void + { + $this->fileDriver->method('isDirectory')->willReturn(true); + $this->fileDriver->method('isExists')->willReturn(true); + $this->shell + ->expects($this->once()) + ->method('execute') + ->with('cd %s && npm ls --depth=0 --json > /dev/null 2>&1', ['/theme']); + + $this->assertTrue($this->packageManager->isNodeModulesInSync('/theme')); + } + + public function testNotInSyncWhenNpmLsFails(): void + { + $this->fileDriver->method('isDirectory')->willReturn(true); + $this->fileDriver->method('isExists')->willReturn(true); + $this->shell->method('execute')->willThrowException(new \RuntimeException('missing packages')); + + $this->assertFalse($this->packageManager->isNodeModulesInSync('/theme')); + } + + // ------------------------------------------------------------------------- + // checkOutdatedPackages + // ------------------------------------------------------------------------- + + public function testWarnsAboutOutdatedPackages(): void + { + $this->shell + ->method('execute') + ->with('cd %s && npm outdated --json', ['/theme']) + ->willReturn('{"tailwindcss": {"current": "3.0.0", "latest": "4.0.0"}}'); + $this->io->expects($this->once())->method('warning')->with('Outdated packages found:'); + $this->io + ->expects($this->once()) + ->method('writeln') + ->with('{"tailwindcss": {"current": "3.0.0", "latest": "4.0.0"}}'); + + $this->packageManager->checkOutdatedPackages('/theme', $this->io); + } + + public function testStaysSilentWhenEverythingIsUpToDate(): void + { + $this->shell->method('execute')->willReturn(''); + $this->io->expects($this->never())->method('warning'); + + $this->packageManager->checkOutdatedPackages('/theme', $this->io); + } + + public function testReportsCheckFailureOnlyInVerboseMode(): void + { + $this->shell->method('execute')->willThrowException(new \RuntimeException('registry unreachable')); + $this->io->method('isVerbose')->willReturn(true); + $this->io + ->expects($this->once()) + ->method('warning') + ->with('Failed to check outdated packages: registry unreachable'); + + $this->packageManager->checkOutdatedPackages('/theme', $this->io); + } +} diff --git a/tests/Unit/Service/StandardThemeBuilderTest.php b/tests/Unit/Service/StandardThemeBuilderTest.php new file mode 100644 index 00000000..4bea57e7 --- /dev/null +++ b/tests/Unit/Service/StandardThemeBuilderTest.php @@ -0,0 +1,124 @@ +dependencyChecker = $this->createMock(DependencyChecker::class); + $this->gruntTaskRunner = $this->createMock(GruntTaskRunner::class); + $this->staticContentDeployer = $this->createMock(StaticContentDeployer::class); + $this->io = $this->createMock(SymfonyStyle::class); + $this->output = $this->createMock(OutputInterface::class); + $this->builder = new StandardThemeBuilder( + $this->dependencyChecker, + $this->gruntTaskRunner, + $this->staticContentDeployer, + ); + } + + public function testBuildsThemeAndRecordsAllSteps(): void + { + $this->dependencyChecker->method('checkDependencies')->willReturn(true); + $this->gruntTaskRunner->method('runTasks')->willReturn(true); + $this->staticContentDeployer->method('deploy')->willReturn(true); + + $successList = []; + $this->assertTrue($this->builder->build('Vendor/theme', $this->io, $this->output, false, $successList)); + $this->assertSame( + [ + 'Vendor/theme: Dependencies checked', + 'Global: Grunt tasks executed', + 'Vendor/theme: Static content deployed', + ], + $successList, + ); + } + + public function testRunsGruntTasksOnlyOncePerBuildProcess(): void + { + $this->dependencyChecker->method('checkDependencies')->willReturn(true); + $this->gruntTaskRunner->expects($this->once())->method('runTasks')->willReturn(true); + $this->staticContentDeployer->method('deploy')->willReturn(true); + + $successList = []; + $this->assertTrue($this->builder->build('Vendor/one', $this->io, $this->output, false, $successList)); + $this->assertTrue($this->builder->build('Vendor/two', $this->io, $this->output, false, $successList)); + + $this->assertSame( + [ + 'Vendor/one: Dependencies checked', + 'Global: Grunt tasks executed', + 'Vendor/one: Static content deployed', + 'Vendor/two: Dependencies checked', + 'Vendor/two: Static content deployed', + ], + $successList, + ); + } + + public function testStopsWhenDependencyCheckFails(): void + { + $this->dependencyChecker->method('checkDependencies')->willReturn(false); + $this->gruntTaskRunner->expects($this->never())->method('runTasks'); + $this->staticContentDeployer->expects($this->never())->method('deploy'); + + $successList = []; + $this->assertFalse($this->builder->build('Vendor/theme', $this->io, $this->output, false, $successList)); + $this->assertSame([], $successList); + } + + public function testStopsWhenGruntTasksFail(): void + { + $this->dependencyChecker->method('checkDependencies')->willReturn(true); + $this->gruntTaskRunner->method('runTasks')->willReturn(false); + $this->staticContentDeployer->expects($this->never())->method('deploy'); + + $successList = []; + $this->assertFalse($this->builder->build('Vendor/theme', $this->io, $this->output, false, $successList)); + $this->assertSame(['Vendor/theme: Dependencies checked'], $successList); + } + + public function testStopsWhenStaticContentDeploymentFails(): void + { + $this->dependencyChecker->method('checkDependencies')->willReturn(true); + $this->gruntTaskRunner->method('runTasks')->willReturn(true); + $this->staticContentDeployer->method('deploy')->willReturn(false); + + $successList = []; + $this->assertFalse($this->builder->build('Vendor/theme', $this->io, $this->output, false, $successList)); + } + + public function testReportsSuccessInVerboseMode(): void + { + $this->dependencyChecker->method('checkDependencies')->willReturn(true); + $this->gruntTaskRunner->method('runTasks')->willReturn(true); + $this->staticContentDeployer->method('deploy')->willReturn(true); + $this->io + ->expects($this->once()) + ->method('success') + ->with('Theme Vendor/theme has been successfully built.'); + + $successList = []; + $this->assertTrue($this->builder->build('Vendor/theme', $this->io, $this->output, true, $successList)); + } +} diff --git a/tests/Unit/Service/StaticContentCleanerTest.php b/tests/Unit/Service/StaticContentCleanerTest.php new file mode 100644 index 00000000..d0fa7116 --- /dev/null +++ b/tests/Unit/Service/StaticContentCleanerTest.php @@ -0,0 +1,101 @@ +state = $this->createMock(State::class); + $this->themeCleaner = $this->createMock(ThemeCleaner::class); + $this->io = $this->createMock(SymfonyStyle::class); + $this->output = $this->createMock(OutputInterface::class); + $this->cleaner = new StaticContentCleaner($this->state, $this->themeCleaner); + } + + public function testDoesNothingOutsideDeveloperMode(): void + { + $this->state->method('getMode')->willReturn(State::MODE_PRODUCTION); + $this->themeCleaner->expects($this->never())->method('hasStaticFiles'); + + $this->assertTrue($this->cleaner->cleanIfNeeded('Vendor/theme', $this->io, $this->output, false)); + } + + public function testDoesNothingWhenNoStaticFilesExist(): void + { + $this->state->method('getMode')->willReturn(State::MODE_DEVELOPER); + $this->themeCleaner->method('hasStaticFiles')->with('Vendor/theme')->willReturn(false); + $this->themeCleaner->expects($this->never())->method('cleanPubStatic'); + + $this->assertTrue($this->cleaner->cleanIfNeeded('Vendor/theme', $this->io, $this->output, false)); + } + + public function testCleansStaticAndPreprocessedFilesInDeveloperMode(): void + { + $this->state->method('getMode')->willReturn(State::MODE_DEVELOPER); + $this->themeCleaner->method('hasStaticFiles')->willReturn(true); + $this->themeCleaner + ->expects($this->once()) + ->method('cleanPubStatic') + ->with('Vendor/theme', $this->io, false, false) + ->willReturn(3); + $this->themeCleaner + ->expects($this->once()) + ->method('cleanViewPreprocessed') + ->with('Vendor/theme', $this->io, false, false) + ->willReturn(1); + + $this->assertTrue($this->cleaner->cleanIfNeeded('Vendor/theme', $this->io, $this->output, false)); + } + + public function testReturnsFalseWhenNothingWasCleaned(): void + { + $this->state->method('getMode')->willReturn(State::MODE_DEVELOPER); + $this->themeCleaner->method('hasStaticFiles')->willReturn(true); + $this->themeCleaner->method('cleanPubStatic')->willReturn(0); + $this->themeCleaner->method('cleanViewPreprocessed')->willReturn(0); + + $this->assertFalse($this->cleaner->cleanIfNeeded('Vendor/theme', $this->io, $this->output, false)); + } + + public function testNotifiesUserInVerboseMode(): void + { + $this->state->method('getMode')->willReturn(State::MODE_DEVELOPER); + $this->themeCleaner->method('hasStaticFiles')->willReturn(true); + $this->themeCleaner->method('cleanPubStatic')->willReturn(1); + $this->themeCleaner->method('cleanViewPreprocessed')->willReturn(0); + $this->io + ->expects($this->once()) + ->method('note') + ->with("Developer mode detected: Cleaning existing static files for theme 'Vendor/theme'..."); + + $this->assertTrue($this->cleaner->cleanIfNeeded('Vendor/theme', $this->io, $this->output, true)); + } + + public function testReturnsFalseAndPrintsErrorOnException(): void + { + $this->state->method('getMode')->willThrowException(new \RuntimeException('state unavailable')); + $this->io + ->expects($this->once()) + ->method('error') + ->with('Failed to check/clean static content: state unavailable'); + + $this->assertFalse($this->cleaner->cleanIfNeeded('Vendor/theme', $this->io, $this->output, false)); + } +} diff --git a/tests/Unit/Service/StaticContentDeployerTest.php b/tests/Unit/Service/StaticContentDeployerTest.php new file mode 100644 index 00000000..eb39bfaf --- /dev/null +++ b/tests/Unit/Service/StaticContentDeployerTest.php @@ -0,0 +1,72 @@ +shell = $this->createMock(Shell::class); + $this->state = $this->createMock(State::class); + $this->io = $this->createMock(SymfonyStyle::class); + $this->output = $this->createMock(OutputInterface::class); + $this->deployer = new StaticContentDeployer($this->shell, $this->state); + } + + public function testSkipsDeploymentInDeveloperMode(): void + { + $this->state->method('getMode')->willReturn(State::MODE_DEVELOPER); + $this->shell->expects($this->never())->method('execute'); + + $this->assertTrue($this->deployer->deploy('Vendor/theme', $this->io, $this->output, false)); + } + + public function testDeploysThemeInProductionMode(): void + { + $this->state->method('getMode')->willReturn(State::MODE_PRODUCTION); + $this->shell + ->expects($this->once()) + ->method('execute') + ->with('php bin/magento setup:static-content:deploy -t %s -f --quiet', ['Vendor/theme']); + + $this->assertTrue($this->deployer->deploy('Vendor/theme', $this->io, $this->output, false)); + } + + public function testForwardsShellOutputAndReportsSuccessWhenVerbose(): void + { + $this->state->method('getMode')->willReturn(State::MODE_DEFAULT); + $this->shell->method('execute')->willReturn('deployed 42 files'); + $this->output->expects($this->once())->method('writeln')->with('deployed 42 files'); + $this->io + ->expects($this->once()) + ->method('success') + ->with("Static content deployed for theme 'Vendor/theme'."); + + $this->assertTrue($this->deployer->deploy('Vendor/theme', $this->io, $this->output, true)); + } + + public function testReturnsFalseAndPrintsErrorWhenDeploymentFails(): void + { + $this->state->method('getMode')->willReturn(State::MODE_PRODUCTION); + $this->shell->method('execute')->willThrowException(new \RuntimeException('deploy failed')); + $this->io->expects($this->once())->method('error')->with('deploy failed'); + + $this->assertFalse($this->deployer->deploy('Vendor/theme', $this->io, $this->output, false)); + } +} diff --git a/tests/Unit/Service/SymlinkCleanerTest.php b/tests/Unit/Service/SymlinkCleanerTest.php new file mode 100644 index 00000000..bc81cafa --- /dev/null +++ b/tests/Unit/Service/SymlinkCleanerTest.php @@ -0,0 +1,112 @@ +fileDriver = $this->createMock(File::class); + $this->state = $this->createMock(State::class); + $this->io = $this->createMock(SymfonyStyle::class); + $this->cleaner = new SymlinkCleaner($this->fileDriver, $this->state); + } + + public function testDoesNothingOutsideDeveloperMode(): void + { + $this->state->method('getMode')->willReturn(State::MODE_PRODUCTION); + $this->fileDriver->expects($this->never())->method('isDirectory'); + + $this->assertTrue($this->cleaner->cleanSymlinks('/theme', $this->io, false)); + } + + public function testDoesNothingWhenCssDirectoryIsMissing(): void + { + $this->state->method('getMode')->willReturn(State::MODE_DEVELOPER); + $this->fileDriver->expects($this->once())->method('isDirectory')->with('/theme/web/css')->willReturn(false); + $this->fileDriver->expects($this->never())->method('readDirectory'); + + $this->assertTrue($this->cleaner->cleanSymlinks('/theme/', $this->io, false)); + } + + public function testDeletesOnlySymlinks(): void + { + $this->state->method('getMode')->willReturn(State::MODE_DEVELOPER); + $this->fileDriver->method('isDirectory')->willReturn(true); + $this->fileDriver + ->method('readDirectory') + ->willReturn(['/theme/web/css/styles.css', '/theme/web/css/link.css']); + $this->fileDriver + ->method('stat') + ->willReturnMap([ + ['/theme/web/css/styles.css', ['mode' => self::REGULAR_FILE_MODE]], + ['/theme/web/css/link.css', ['mode' => self::SYMLINK_MODE]], + ]); + $this->fileDriver->expects($this->once())->method('deleteFile')->with('/theme/web/css/link.css'); + $this->io->expects($this->never())->method('writeln'); + $this->io->expects($this->never())->method('success'); + + $this->assertTrue($this->cleaner->cleanSymlinks('/theme', $this->io, false)); + } + + public function testVerboseModeWithoutSymlinksPrintsNoSuccess(): void + { + $this->state->method('getMode')->willReturn(State::MODE_DEVELOPER); + $this->fileDriver->method('isDirectory')->willReturn(true); + $this->fileDriver->method('readDirectory')->willReturn(['/theme/web/css/styles.css']); + $this->fileDriver->method('stat')->willReturn(['mode' => self::REGULAR_FILE_MODE]); + $this->io->expects($this->never())->method('success'); + + $this->assertTrue($this->cleaner->cleanSymlinks('/theme', $this->io, true)); + } + + public function testTreatsUnstatableItemsAsRegularFiles(): void + { + $this->state->method('getMode')->willReturn(State::MODE_DEVELOPER); + $this->fileDriver->method('isDirectory')->willReturn(true); + $this->fileDriver->method('readDirectory')->willReturn(['/theme/web/css/broken.css']); + $this->fileDriver->method('stat')->willThrowException(new \RuntimeException('stat failed')); + $this->fileDriver->expects($this->never())->method('deleteFile'); + + $this->assertTrue($this->cleaner->cleanSymlinks('/theme', $this->io, false)); + } + + public function testReportsRemovedSymlinksInVerboseMode(): void + { + $this->state->method('getMode')->willReturn(State::MODE_DEVELOPER); + $this->fileDriver->method('isDirectory')->willReturn(true); + $this->fileDriver->method('readDirectory')->willReturn(['/theme/web/css/link.css']); + $this->fileDriver->method('stat')->willReturn(['mode' => self::SYMLINK_MODE]); + $this->io->expects($this->once())->method('writeln')->with(' ⚠ Removed symlink: link.css'); + $this->io->expects($this->once())->method('success')->with('Removed 1 symlink(s) from web/css/'); + + $this->assertTrue($this->cleaner->cleanSymlinks('/theme', $this->io, true)); + } + + public function testSucceedsWithWarningWhenDirectoryCannotBeRead(): void + { + $this->state->method('getMode')->willReturn(State::MODE_DEVELOPER); + $this->fileDriver->method('isDirectory')->willReturn(true); + $this->fileDriver->method('readDirectory')->willThrowException(new \RuntimeException('permission denied')); + $this->io->expects($this->once())->method('warning')->with('Could not clean symlinks: permission denied'); + + $this->assertTrue($this->cleaner->cleanSymlinks('/theme', $this->io, true)); + } +} diff --git a/tests/Unit/Service/ThemeBuilder/BuilderFactoryTest.php b/tests/Unit/Service/ThemeBuilder/BuilderFactoryTest.php new file mode 100644 index 00000000..97c5c042 --- /dev/null +++ b/tests/Unit/Service/ThemeBuilder/BuilderFactoryTest.php @@ -0,0 +1,68 @@ +factory = new BuilderFactory(); + } + + public function testCreateReturnsRegisteredBuilder(): void + { + $builder = $this->createNamedBuilder('tailwind'); + $this->factory->addBuilder($builder); + + $this->assertSame($builder, $this->factory->create('tailwind')); + } + + public function testCreateThrowsForUnknownBuilder(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Builder unknown not found'); + + $this->factory->create('unknown'); + } + + public function testAddBuilderOverwritesExistingName(): void + { + $first = $this->createNamedBuilder('grunt'); + $second = $this->createNamedBuilder('grunt'); + $this->factory->addBuilder($first); + $this->factory->addBuilder($second); + + $this->assertSame($second, $this->factory->create('grunt')); + $this->assertSame(['grunt'], $this->factory->getAvailableBuilders()); + } + + public function testGetAvailableBuildersListsNamesInRegistrationOrder(): void + { + $this->factory->addBuilder($this->createNamedBuilder('grunt')); + $this->factory->addBuilder($this->createNamedBuilder('tailwind')); + + $this->assertSame(['grunt', 'tailwind'], $this->factory->getAvailableBuilders()); + } + + public function testGetAvailableBuildersIsEmptyByDefault(): void + { + $this->assertSame([], $this->factory->getAvailableBuilders()); + } + + private function createNamedBuilder(string $name): BuilderInterface&MockObject + { + $builder = $this->createMock(BuilderInterface::class); + $builder->method('getName')->willReturn($name); + + return $builder; + } +} diff --git a/tests/Unit/Service/ThemeBuilder/BuilderPoolTest.php b/tests/Unit/Service/ThemeBuilder/BuilderPoolTest.php new file mode 100644 index 00000000..cafbafc4 --- /dev/null +++ b/tests/Unit/Service/ThemeBuilder/BuilderPoolTest.php @@ -0,0 +1,63 @@ +createBuilder(detects: false); + $matching = $this->createBuilder(detects: true); + $neverAsked = $this->createMock(BuilderInterface::class); + $neverAsked->expects($this->never())->method('detect'); + + $pool = new BuilderPool([$nonMatching, $matching, $neverAsked]); + + $this->assertSame($matching, $pool->getBuilder('/path/to/theme')); + } + + public function testReturnsNullWhenNoBuilderMatches(): void + { + $pool = new BuilderPool([$this->createBuilder(detects: false), $this->createBuilder(detects: false)]); + + $this->assertNull($pool->getBuilder('/path/to/theme')); + } + + public function testReturnsNullForEmptyPool(): void + { + $pool = new BuilderPool(); + + $this->assertNull($pool->getBuilder('/path/to/theme')); + } + + public function testPassesThemePathToBuilders(): void + { + $builder = $this->createMock(BuilderInterface::class); + $builder->expects($this->once())->method('detect')->with('/expected/theme/path')->willReturn(true); + + (new BuilderPool([$builder]))->getBuilder('/expected/theme/path'); + } + + public function testReturnsAllRegisteredBuilders(): void + { + $builders = [$this->createBuilder(detects: false), $this->createBuilder(detects: true)]; + + $this->assertSame($builders, (new BuilderPool($builders))->getBuilders()); + $this->assertSame([], (new BuilderPool())->getBuilders()); + } + + private function createBuilder(bool $detects): BuilderInterface&MockObject + { + $builder = $this->createMock(BuilderInterface::class); + $builder->method('detect')->willReturn($detects); + + return $builder; + } +} diff --git a/tests/Unit/Service/ThemeCleanerTest.php b/tests/Unit/Service/ThemeCleanerTest.php new file mode 100644 index 00000000..d4750d15 --- /dev/null +++ b/tests/Unit/Service/ThemeCleanerTest.php @@ -0,0 +1,272 @@ +filesystem = $this->createMock(Filesystem::class); + $this->writeDirectory = $this->createMock(WriteInterface::class); + $this->filesystem->method('getDirectoryWrite')->willReturn($this->writeDirectory); + $this->io = $this->createMock(SymfonyStyle::class); + $this->cleaner = new ThemeCleaner($this->filesystem); + } + + // ------------------------------------------------------------------------- + // cleanViewPreprocessed + // ------------------------------------------------------------------------- + + public function testCleansPreprocessedCssAndSourceDirectories(): void + { + $this->writeDirectory->method('isDirectory')->willReturn(true); + $deleted = []; + $this->writeDirectory + ->method('delete') + ->willReturnCallback(function (string $path) use (&$deleted): bool { + $deleted[] = $path; + return true; + }); + $this->io->expects($this->never())->method('writeln'); + + $this->assertSame(2, $this->cleaner->cleanViewPreprocessed('Vendor/theme', $this->io)); + $this->assertSame( + [ + 'view_preprocessed/css/frontend/Vendor/theme', + 'view_preprocessed/source/frontend/Vendor/theme', + ], + $deleted, + ); + } + + public function testCleanViewPreprocessedReturnsZeroForInvalidThemeCode(): void + { + // Directories nominally exist so only the theme-code guard prevents deletion. + $this->writeDirectory->method('isDirectory')->willReturn(true); + $this->writeDirectory->expects($this->never())->method('delete'); + + $this->assertSame(0, $this->cleaner->cleanViewPreprocessed('invalid-theme-code', $this->io)); + $this->assertSame(0, $this->cleaner->cleanViewPreprocessed('too/many/parts', $this->io)); + } + + public function testVerboseDryRunAnnouncesWhatWouldBeCleaned(): void + { + $this->writeDirectory->method('isDirectory')->willReturn(true); + + $lines = []; + $this->io + ->method('writeln') + ->willReturnCallback(function (string $line) use (&$lines): void { + $lines[] = $line; + }); + + $this->assertSame(2, $this->cleaner->cleanViewPreprocessed('Vendor/theme', $this->io, true, true)); + $this->assertSame( + [ + ' ✓ Would clean: var/view_preprocessed/css/frontend/Vendor/theme', + ' ✓ Would clean: var/view_preprocessed/source/frontend/Vendor/theme', + ], + $lines, + ); + } + + public function testVerboseModeReportsDeleteFailures(): void + { + $this->writeDirectory->method('isDirectory')->willReturn(true); + $this->writeDirectory->method('delete')->willThrowException(new \RuntimeException('locked')); + + $lines = []; + $this->io + ->method('writeln') + ->willReturnCallback(function (string $line) use (&$lines): void { + $lines[] = $line; + }); + + $this->assertSame(0, $this->cleaner->cleanViewPreprocessed('Vendor/theme', $this->io, false, true)); + $this->assertSame( + [ + ' ✗ Failed to clean: var/view_preprocessed/css/frontend/Vendor/theme - locked', + ' ✗ Failed to clean: var/view_preprocessed/source/frontend/Vendor/theme - locked', + ], + $lines, + ); + } + + public function testCleanViewPreprocessedReturnsZeroWhenBaseDirectoryMissing(): void + { + $this->writeDirectory->method('isDirectory')->willReturn(false); + $this->writeDirectory->expects($this->never())->method('delete'); + + $this->assertSame(0, $this->cleaner->cleanViewPreprocessed('Vendor/theme', $this->io)); + } + + public function testDryRunCountsWithoutDeleting(): void + { + $this->writeDirectory->method('isDirectory')->willReturn(true); + $this->writeDirectory->expects($this->never())->method('delete'); + + $this->assertSame(2, $this->cleaner->cleanViewPreprocessed('Vendor/theme', $this->io, dryRun: true)); + } + + public function testCleanViewPreprocessedSurvivesDeleteFailure(): void + { + $this->writeDirectory->method('isDirectory')->willReturn(true); + $this->writeDirectory->method('delete')->willThrowException(new \RuntimeException('locked')); + + $this->assertSame(0, $this->cleaner->cleanViewPreprocessed('Vendor/theme', $this->io)); + } + + // ------------------------------------------------------------------------- + // cleanPubStatic + // ------------------------------------------------------------------------- + + public function testCleansPubStaticThemeDirectory(): void + { + $this->writeDirectory->method('isDirectory')->willReturn(true); + $this->writeDirectory + ->expects($this->once()) + ->method('delete') + ->with('frontend/Vendor/theme') + ->willReturn(true); + $this->io->expects($this->never())->method('writeln'); + + $this->assertSame(1, $this->cleaner->cleanPubStatic('Vendor/theme', $this->io)); + } + + public function testCleanPubStaticReturnsZeroWhenThemeDirectoryMissing(): void + { + $this->writeDirectory + ->method('isDirectory') + ->willReturnMap([ + ['frontend', true], + ['frontend/Vendor/theme', false], + ]); + $this->writeDirectory->expects($this->never())->method('delete'); + + $this->assertSame(0, $this->cleaner->cleanPubStatic('Vendor/theme', $this->io)); + } + + public function testCleanPubStaticReportsVerboseOutput(): void + { + $this->writeDirectory->method('isDirectory')->willReturn(true); + $this->writeDirectory->method('delete')->willReturn(true); + $this->io + ->expects($this->once()) + ->method('writeln') + ->with(' ✓ Cleaned: pub/static/frontend/Vendor/theme'); + + $this->assertSame(1, $this->cleaner->cleanPubStatic('Vendor/theme', $this->io, false, true)); + } + + // ------------------------------------------------------------------------- + // cleanPageCache / cleanVarTmp + // ------------------------------------------------------------------------- + + public function testCleansPageCacheWhenPresent(): void + { + $this->writeDirectory->method('isDirectory')->with('page_cache')->willReturn(true); + $this->writeDirectory->expects($this->once())->method('delete')->with('page_cache')->willReturn(true); + $this->io->expects($this->never())->method('writeln'); + + $this->assertSame(1, $this->cleaner->cleanPageCache($this->io)); + } + + public function testCleanPageCacheReturnsZeroWhenMissing(): void + { + $this->writeDirectory->method('isDirectory')->willReturn(false); + + $this->assertSame(0, $this->cleaner->cleanPageCache($this->io)); + } + + public function testCleansVarTmpWhenPresent(): void + { + $this->writeDirectory->method('isDirectory')->with('tmp')->willReturn(true); + $this->writeDirectory->expects($this->once())->method('delete')->with('tmp')->willReturn(true); + $this->io->expects($this->never())->method('writeln'); + + $this->assertSame(1, $this->cleaner->cleanVarTmp($this->io)); + } + + // ------------------------------------------------------------------------- + // cleanGenerated + // ------------------------------------------------------------------------- + + public function testCleansGeneratedCodeAndMetadataAsOneUnit(): void + { + $this->writeDirectory->method('isDirectory')->willReturn(true); + $deleted = []; + $this->writeDirectory + ->method('delete') + ->willReturnCallback(function (string $path) use (&$deleted): bool { + $deleted[] = $path; + return true; + }); + + $this->io->expects($this->never())->method('writeln'); + + $this->assertSame(1, $this->cleaner->cleanGenerated($this->io)); + $this->assertSame(['code', 'metadata'], $deleted); + } + + public function testCleanGeneratedReturnsZeroWhenNothingExists(): void + { + $this->writeDirectory->method('isDirectory')->willReturn(false); + $this->writeDirectory->expects($this->never())->method('delete'); + + $this->assertSame(0, $this->cleaner->cleanGenerated($this->io)); + } + + public function testCleanGeneratedCountsPartialSuccess(): void + { + $this->writeDirectory->method('isDirectory')->willReturn(true); + $this->writeDirectory + ->method('delete') + ->willReturnCallback(static function (string $path): bool { + if ($path === 'code') { + throw new \RuntimeException('permission denied'); + } + return true; + }); + + $this->assertSame(1, $this->cleaner->cleanGenerated($this->io)); + } + + // ------------------------------------------------------------------------- + // hasStaticFiles + // ------------------------------------------------------------------------- + + public function testHasStaticFilesChecksThemeDirectory(): void + { + $readDirectory = $this->createMock(ReadInterface::class); + $this->filesystem + ->method('getDirectoryRead') + ->with(DirectoryList::STATIC_VIEW) + ->willReturn($readDirectory); + $readDirectory->method('isDirectory')->with('frontend/Vendor/theme')->willReturn(true); + + $this->assertTrue($this->cleaner->hasStaticFiles('Vendor/theme')); + } + + public function testHasStaticFilesIsFalseForInvalidThemeCode(): void + { + $this->filesystem->expects($this->never())->method('getDirectoryRead'); + + $this->assertFalse($this->cleaner->hasStaticFiles('invalid')); + } +} diff --git a/tests/Unit/Service/ThemeSuggesterTest.php b/tests/Unit/Service/ThemeSuggesterTest.php new file mode 100644 index 00000000..b94405a3 --- /dev/null +++ b/tests/Unit/Service/ThemeSuggesterTest.php @@ -0,0 +1,121 @@ +createSuggester(['Magento/luma', 'Vendor/checkout', 'Hyva/default']); + + $this->assertSame(['Magento/luma'], $suggester->findSimilarThemes('Magento/lume')); + } + + public function testSuggestsSubstringMatchesRegardlessOfDistance(): void + { + $suggester = $this->createSuggester(['Vendor/super-shop-theme', 'Magento/blank']); + + $this->assertSame(['Vendor/super-shop-theme'], $suggester->findSimilarThemes('shop')); + } + + public function testMatchesUppercaseInputAgainstLowercaseCode(): void + { + $suggester = $this->createSuggester(['hyva/default']); + + $this->assertSame(['hyva/default'], $suggester->findSimilarThemes('HYVA/DEFAULT')); + } + + public function testMatchesLowercaseInputAgainstUppercaseCode(): void + { + $suggester = $this->createSuggester(['HYVA/DEFAULT']); + + $this->assertSame(['HYVA/DEFAULT'], $suggester->findSimilarThemes('hyva/default')); + } + + public function testAcceptsDistancesUpToOneThirdOfInputLengthOnly(): void + { + // Input length 12 → threshold 4; "Magento/blank" has distance 4, "Magento/blanko" distance 5. + $suggester = $this->createSuggester(['Magento/luma', 'Magento/blank', 'Magento/blanko']); + + $this->assertSame(['Magento/luma', 'Magento/blank'], $suggester->findSimilarThemes('Magento/lume')); + } + + public function testSortsSuggestionsByDistanceAndLimitsToThree(): void + { + $suggester = $this->createSuggester([ + 'Magento/luma2', + 'Magento/luma', + 'Magento/lumaX9', + 'Magento/luma34', + ]); + + $suggestions = $suggester->findSimilarThemes('Magento/luma'); + + $this->assertCount(3, $suggestions); + $this->assertSame('Magento/luma', $suggestions[0]); + } + + public function testReturnsEmptyArrayWhenNothingIsSimilar(): void + { + $suggester = $this->createSuggester(['Magento/blank']); + + $this->assertSame([], $suggester->findSimilarThemes('Hyva/checkout-theme')); + } + + public function testReturnsEmptyArrayForEmptyInput(): void + { + $suggester = $this->createSuggester(['Magento/blank']); + + $this->assertSame([], $suggester->findSimilarThemes('')); + } + + public function testReturnsEmptyArrayForOverlongInput(): void + { + // The theme code would substring-match, so only the length guard produces the empty result. + $suggester = $this->createSuggester(['Vendor/' . str_repeat('a', 256)]); + + $this->assertSame([], $suggester->findSimilarThemes(str_repeat('a', 256))); + } + + public function testAcceptsInputOfExactlyMaxLength(): void + { + $longCode = 'Vendor/' . str_repeat('a', 255); + $suggester = $this->createSuggester([$longCode]); + + $this->assertSame([$longCode], $suggester->findSimilarThemes(str_repeat('a', 255))); + } + + public function testOverlongThemeCodeIsOnlyConsideredForSubstringMatch(): void + { + $longCode = 'Vendor/' . str_repeat('x', 260) . '-shop'; + $suggester = $this->createSuggester([$longCode, 'Magento/blank']); + + $this->assertSame([$longCode], $suggester->findSimilarThemes('shop')); + $this->assertSame([], $suggester->findSimilarThemes('Vendor/theme')); + } + + /** + * @param array $themeCodes + */ + private function createSuggester(array $themeCodes): ThemeSuggester + { + $themes = []; + foreach ($themeCodes as $code) { + $theme = $this->createMock(ThemeInterface::class); + $theme->method('getCode')->willReturn($code); + $themes[$code] = $theme; + } + + $themeList = $this->createMock(ThemeList::class); + $themeList->method('getAllThemes')->willReturn($themes); + + return new ThemeSuggester($themeList); + } +} diff --git a/tests/coverage-checker.php b/tests/coverage-checker.php new file mode 100644 index 00000000..cc06924f --- /dev/null +++ b/tests/coverage-checker.php @@ -0,0 +1,43 @@ + + * + * Raise the threshold as coverage grows — it is a ratchet, not a target. + */ + +declare(strict_types=1); + +if ($argc !== 3) { + fwrite(STDERR, "Usage: php tests/coverage-checker.php \n"); + exit(2); +} + +[, $cloverFile, $minCoverage] = $argv; + +if (!is_file($cloverFile)) { + fwrite(STDERR, sprintf("Clover file not found: %s\n", $cloverFile)); + exit(2); +} + +$xml = simplexml_load_file($cloverFile); +if ($xml === false || !isset($xml->project->metrics)) { + fwrite(STDERR, sprintf("Could not parse Clover metrics from %s\n", $cloverFile)); + exit(2); +} + +$metrics = $xml->project->metrics; +$statements = (int) $metrics['statements']; +$covered = (int) $metrics['coveredstatements']; +$coverage = $statements > 0 ? $covered / $statements * 100 : 0.0; + +printf("Line coverage: %.2f%% (%d/%d statements, minimum: %s%%)\n", $coverage, $covered, $statements, $minCoverage); + +if ($coverage < (float) $minCoverage) { + fwrite(STDERR, "FAILED: line coverage dropped below the minimum threshold.\n"); + exit(1); +} + +echo "OK\n"; From 9edaf06af81142f42cf1d8ba6dd2403eb98239e9 Mon Sep 17 00:00:00 2001 From: Thomas Hauschild <7961978+Morgy93@users.noreply.github.com> Date: Sun, 5 Jul 2026 09:13:12 +0200 Subject: [PATCH 2/2] fix: ensure proper exit after coverage checks --- .ddev/commands/web/phpunit | 1 + 1 file changed, 1 insertion(+) diff --git a/.ddev/commands/web/phpunit b/.ddev/commands/web/phpunit index 07eb0669..e45c7657 100755 --- a/.ddev/commands/web/phpunit +++ b/.ddev/commands/web/phpunit @@ -46,6 +46,7 @@ if [[ ${coverage} == true ]]; then if [[ ${#args[@]} -eq 0 ]]; then exec php tests/coverage-checker.php reports/clover.xml 20 fi + exit 0 fi exec vendor/bin/phpunit "${args[@]}"