', $display);
+ $this->assertStringContainsString('bin/magento mageforge:theme:clean --all', $display);
+ $this->assertStringContainsString('Example: bin/magento mageforge:theme:clean Magento/luma', $display);
+ }
+
+ public function testUnknownThemeWithoutSuggestionsCountsAsFailed(): void
+ {
+ $this->themePath->method('getPath')->willReturn(null);
+ $this->themeSuggester->method('findSimilarThemes')->willReturn([]);
+
+ $tester = new CommandTester($this->command);
+ $exitCode = $tester->execute(['themeCodes' => ['Vendor/unknown', 'Vendor/missing']]);
+
+ $this->assertSame(Cli::RETURN_SUCCESS, $exitCode);
+ $display = $tester->getDisplay();
+ $this->assertStringContainsString('No files were cleaned.', $display);
+ $this->assertStringContainsString('Failed to process 2 themes: Vendor/unknown, Vendor/missing', $display);
+ }
+
+ public function testResolvesVendorWildcardBeforeCleaning(): void
+ {
+ $this->themeList->method('getAllThemes')->willReturn([
+ new FakeThemeWithTitle('Vendor/one', 'One'),
+ new FakeThemeWithTitle('Other/theme', 'Other'),
+ ]);
+ $this->themePath->method('getPath')->with('Vendor/one')->willReturn('/path');
+ $this->themeCleaner->method('cleanViewPreprocessed')->willReturn(1);
+ $this->themeCleaner->method('cleanPubStatic')->willReturn(0);
+ $this->themeCleaner->method('cleanPageCache')->willReturn(0);
+ $this->themeCleaner->method('cleanVarTmp')->willReturn(0);
+ $this->themeCleaner->method('cleanGenerated')->willReturn(0);
+
+ $tester = new CommandTester($this->command);
+ $exitCode = $tester->execute(['themeCodes' => ['Vendor/*']]);
+
+ $this->assertSame(Cli::RETURN_SUCCESS, $exitCode);
+ $this->assertStringContainsString("Resolved vendor 'Vendor/*' to 1 theme(s): Vendor/one", $tester->getDisplay());
+ }
+
+ public function testMixedSuccessAndFailureSummaryCountsCorrectly(): void
+ {
+ $this->themePath
+ ->method('getPath')
+ ->willReturnCallback(static fn(string $code): ?string => $code === 'Vendor/good' ? '/path' : null);
+ $this->themeSuggester->method('findSimilarThemes')->willReturn([]);
+ $this->themeCleaner->method('cleanViewPreprocessed')->willReturn(2);
+ $this->themeCleaner->method('cleanPubStatic')->willReturn(0);
+ $this->themeCleaner->method('cleanPageCache')->willReturn(0);
+ $this->themeCleaner->method('cleanVarTmp')->willReturn(0);
+ $this->themeCleaner->method('cleanGenerated')->willReturn(0);
+
+ $tester = new CommandTester($this->command);
+ $exitCode = $tester->execute(['themeCodes' => ['Vendor/good', 'Vendor/bad']]);
+
+ $this->assertSame(Cli::RETURN_SUCCESS, $exitCode);
+ $display = (string) preg_replace('/\s+/', ' ', $tester->getDisplay());
+ $this->assertStringContainsString('Successfully cleaned 2 directories across 1 theme', $display);
+ $this->assertStringNotContainsString('across 1 themes', $display, 'Singular form required for one theme');
+ $this->assertStringContainsString('Failed to process 1 theme: Vendor/bad', $display);
+ }
+}
diff --git a/tests/Unit/Console/Command/Theme/FakeThemeWithTitle.php b/tests/Unit/Console/Command/Theme/FakeThemeWithTitle.php
new file mode 100644
index 00000000..40921077
--- /dev/null
+++ b/tests/Unit/Console/Command/Theme/FakeThemeWithTitle.php
@@ -0,0 +1,66 @@
+code;
+ }
+
+ public function getFullPath()
+ {
+ return 'frontend/' . $this->code;
+ }
+
+ public function getParentTheme()
+ {
+ return null;
+ }
+
+ public function getCode()
+ {
+ return $this->code;
+ }
+
+ public function isPhysical()
+ {
+ return true;
+ }
+
+ public function getInheritedThemes()
+ {
+ return [];
+ }
+
+ public function getId()
+ {
+ return 1;
+ }
+
+ public function getThemeTitle(): string
+ {
+ return $this->themeTitle;
+ }
+}
diff --git a/tests/Unit/Console/Command/Theme/ListCommandTest.php b/tests/Unit/Console/Command/Theme/ListCommandTest.php
new file mode 100644
index 00000000..a590b17b
--- /dev/null
+++ b/tests/Unit/Console/Command/Theme/ListCommandTest.php
@@ -0,0 +1,58 @@
+themeList = $this->createMock(ThemeList::class);
+ $this->command = new ListCommand($this->themeList);
+ }
+
+ public function testDisplaysInfoMessageWhenNoThemesExist(): void
+ {
+ $this->themeList->method('getAllThemes')->willReturn([]);
+
+ $tester = new CommandTester($this->command);
+ $exitCode = $tester->execute([]);
+
+ $this->assertSame(Cli::RETURN_SUCCESS, $exitCode);
+ $this->assertStringContainsString('No themes found.', $tester->getDisplay());
+ }
+
+ public function testDisplaysTableOfAvailableThemes(): void
+ {
+ $theme = new FakeThemeWithTitle('Vendor/theme', 'Vendor Theme');
+
+ $this->themeList->method('getAllThemes')->willReturn(['frontend/Vendor/theme' => $theme]);
+
+ $tester = new CommandTester($this->command);
+ $exitCode = $tester->execute([]);
+
+ $this->assertSame(Cli::RETURN_SUCCESS, $exitCode);
+ $display = $tester->getDisplay();
+ $this->assertStringContainsString('Available Themes:', $display);
+ $this->assertStringContainsString('Vendor/theme', $display);
+ $this->assertStringContainsString('Vendor Theme', $display);
+ $this->assertStringContainsString('frontend/Vendor/theme', $display);
+ }
+
+ public function testCommandNameAndAliases(): void
+ {
+ $this->assertSame('mageforge:theme:list', $this->command->getName());
+ $this->assertSame(['frontend:list'], $this->command->getAliases());
+ }
+}
diff --git a/tests/Unit/Console/Command/Theme/TokensCommandTest.php b/tests/Unit/Console/Command/Theme/TokensCommandTest.php
new file mode 100644
index 00000000..36b8bc3c
--- /dev/null
+++ b/tests/Unit/Console/Command/Theme/TokensCommandTest.php
@@ -0,0 +1,244 @@
+themeList = $this->createMock(ThemeList::class);
+ $this->themePath = $this->createMock(ThemePath::class);
+ $this->builderPool = $this->createMock(BuilderPool::class);
+ $this->fileDriver = $this->createMock(File::class);
+ $this->shell = $this->createMock(Shell::class);
+ $this->themeSuggester = $this->createMock(ThemeSuggester::class);
+ $this->command = new TokensCommand(
+ $this->themeList,
+ $this->themePath,
+ $this->builderPool,
+ $this->fileDriver,
+ $this->shell,
+ $this->themeSuggester,
+ );
+ }
+
+ public function testCommandNameAndAlias(): void
+ {
+ $this->assertSame('mageforge:hyva:tokens', $this->command->getName());
+ $this->assertSame(['hyva:tokens'], $this->command->getAliases());
+ }
+
+ public function testGeneratesTokensForLocalHyvaTheme(): void
+ {
+ $this->givenHyvaTheme('/app/design/frontend/Vendor/theme');
+ $this->fileDriver->method('isDirectory')->willReturn(true);
+ $this->shell
+ ->expects($this->once())
+ ->method('execute')
+ ->with('cd %s && npx hyva-tokens', ['/app/design/frontend/Vendor/theme/web/tailwind']);
+
+ $tester = new CommandTester($this->command);
+ $exitCode = $tester->execute(['themeCode' => 'Vendor/theme']);
+
+ $this->assertSame(Cli::RETURN_SUCCESS, $exitCode);
+ $display = $tester->getDisplay();
+ $this->assertStringContainsString('Hyvä design tokens generated successfully.', $display);
+ $this->assertStringContainsString(
+ 'Generated file: /app/design/frontend/Vendor/theme/web/tailwind/generated/hyva-tokens.css',
+ $display,
+ );
+ }
+
+ public function testCopiesTokensToVarGeneratedForVendorTheme(): void
+ {
+ $this->givenHyvaTheme('/app/vendor/hyva-themes/theme');
+ $this->fileDriver
+ ->method('isDirectory')
+ ->willReturnCallback(static fn(string $path): bool => !str_contains($path, 'var/generated'));
+ $this->fileDriver->method('isExists')->willReturn(true);
+ $this->fileDriver
+ ->expects($this->once())
+ ->method('createDirectory')
+ ->with($this->stringContains('var/generated/hyva-token/Vendor/theme'), 0o755);
+ $this->fileDriver
+ ->expects($this->once())
+ ->method('copy')
+ ->with(
+ '/app/vendor/hyva-themes/theme/web/tailwind/generated/hyva-tokens.css',
+ $this->stringContains('var/generated/hyva-token/Vendor/theme/hyva-tokens.css'),
+ );
+
+ $tester = new CommandTester($this->command);
+ $exitCode = $tester->execute(['themeCode' => 'Vendor/theme']);
+
+ $this->assertSame(Cli::RETURN_SUCCESS, $exitCode);
+ $normalized = (string) preg_replace('/\s+/', ' ', $tester->getDisplay());
+ $this->assertStringContainsString('Hyvä design tokens generated successfully.', $normalized);
+ $this->assertStringContainsString('This is a vendor theme.', $normalized);
+ $this->assertStringContainsString(
+ 'This is a vendor theme. Tokens have been saved to var/generated/hyva-token/ instead.',
+ $normalized,
+ );
+ $this->assertStringContainsString('Generated file: ', $normalized);
+ $this->assertStringContainsString('var/generated/hyva-token/Vendor/theme/hyva-tokens.css', $normalized);
+ }
+
+ public function testVerboseModeAnnouncesWorkingDirectory(): void
+ {
+ $this->givenHyvaTheme('/theme');
+ $this->fileDriver->method('isDirectory')->willReturn(true);
+
+ $tester = new CommandTester($this->command);
+ $exitCode = $tester->execute(
+ ['themeCode' => 'Vendor/theme'],
+ ['verbosity' => OutputInterface::VERBOSITY_VERBOSE],
+ );
+
+ $this->assertSame(Cli::RETURN_SUCCESS, $exitCode);
+ $display = $tester->getDisplay();
+ $this->assertStringContainsString('Generating Hyvä design tokens for theme: Vendor/theme', $display);
+ $this->assertStringContainsString('Working directory: /theme/web/tailwind', $display);
+ $this->assertStringContainsString('Running npx hyva-tokens...', $display);
+ }
+
+ public function testFailsWhenTokenGenerationFails(): void
+ {
+ $this->givenHyvaTheme('/theme');
+ $this->fileDriver->method('isDirectory')->willReturn(true);
+ $this->shell->method('execute')->willThrowException(new \RuntimeException('npx not found'));
+
+ $tester = new CommandTester($this->command);
+ $exitCode = $tester->execute(['themeCode' => 'Vendor/theme']);
+
+ $this->assertSame(Cli::RETURN_FAILURE, $exitCode);
+ $this->assertStringContainsString(
+ 'Failed to generate Hyvä design tokens: npx not found',
+ (string) preg_replace('/\s+/', ' ', $tester->getDisplay()),
+ );
+ }
+
+ public function testFailsForNonHyvaTheme(): void
+ {
+ $this->themePath->method('getPath')->willReturn('/theme');
+ $builder = $this->createMock(BuilderInterface::class);
+ $builder->method('getName')->willReturn('MagentoStandard');
+ $this->builderPool->method('getBuilder')->willReturn($builder);
+ $this->shell->expects($this->never())->method('execute');
+
+ $tester = new CommandTester($this->command);
+ $exitCode = $tester->execute(['themeCode' => 'Vendor/theme']);
+
+ $this->assertSame(Cli::RETURN_FAILURE, $exitCode);
+ $normalized = (string) preg_replace('/\s+/', ' ', $tester->getDisplay());
+ $this->assertStringContainsString('Theme Vendor/theme is not a Hyvä theme.', $normalized);
+ $this->assertStringNotContainsString('Tailwind directory', $normalized, 'Command must stop at theme check');
+ }
+
+ public function testFailsWhenNoBuilderIsFound(): void
+ {
+ $this->themePath->method('getPath')->willReturn('/theme');
+ $this->builderPool->method('getBuilder')->willReturn(null);
+
+ $tester = new CommandTester($this->command);
+ $exitCode = $tester->execute(['themeCode' => 'Vendor/theme']);
+
+ $this->assertSame(Cli::RETURN_FAILURE, $exitCode);
+ }
+
+ public function testFailsWhenTailwindDirectoryIsMissing(): void
+ {
+ $this->givenHyvaTheme('/theme');
+ $this->fileDriver
+ ->method('isDirectory')
+ ->willReturnCallback(static fn(string $path): bool => str_ends_with($path, 'node_modules'));
+ $this->shell->expects($this->never())->method('execute');
+
+ $tester = new CommandTester($this->command);
+ $exitCode = $tester->execute(['themeCode' => 'Vendor/theme']);
+
+ $this->assertSame(Cli::RETURN_FAILURE, $exitCode);
+ $this->assertStringContainsString(
+ 'Tailwind directory not found in: /theme/web/tailwind',
+ (string) preg_replace('/\s+/', ' ', $tester->getDisplay()),
+ );
+ }
+
+ public function testWarnsWhenNodeModulesAreMissing(): void
+ {
+ $this->givenHyvaTheme('/theme');
+ $this->fileDriver
+ ->method('isDirectory')
+ ->willReturnCallback(static fn(string $path): bool => !str_ends_with($path, 'node_modules'));
+ $this->shell->expects($this->never())->method('execute');
+
+ $tester = new CommandTester($this->command);
+ $exitCode = $tester->execute(['themeCode' => 'Vendor/theme']);
+
+ $this->assertSame(Cli::RETURN_FAILURE, $exitCode);
+ $this->assertStringContainsString(
+ 'Node modules not found. Please run: bin/magento mageforge:theme:build Vendor/theme',
+ (string) preg_replace('/\s+/', ' ', $tester->getDisplay()),
+ );
+ }
+
+ public function testFailsForUnknownThemeWithoutSuggestions(): void
+ {
+ $this->themePath->method('getPath')->willReturn(null);
+ $this->themeSuggester->method('findSimilarThemes')->willReturn([]);
+
+ $tester = new CommandTester($this->command);
+ $exitCode = $tester->execute(['themeCode' => 'Vendor/unknown']);
+
+ $this->assertSame(Cli::RETURN_FAILURE, $exitCode);
+ }
+
+ private function givenHyvaTheme(string $path): void
+ {
+ $this->themePath->method('getPath')->with('Vendor/theme')->willReturn($path);
+ $builder = $this->createMock(BuilderInterface::class);
+ $builder->method('getName')->willReturn('HyvaThemes');
+ $this->builderPool->method('getBuilder')->with($path)->willReturn($builder);
+ }
+
+ public function testNormalizesTrailingSlashInThemePath(): void
+ {
+ $this->themePath->method('getPath')->with('Vendor/theme')->willReturn('/theme/');
+ $builder = $this->createMock(BuilderInterface::class);
+ $builder->method('getName')->willReturn('HyvaThemes');
+ $this->builderPool->method('getBuilder')->willReturn($builder);
+ $this->fileDriver->method('isDirectory')->willReturn(true);
+ $this->shell
+ ->expects($this->once())
+ ->method('execute')
+ ->with('cd %s && npx hyva-tokens', ['/theme/web/tailwind']);
+
+ $tester = new CommandTester($this->command);
+ $exitCode = $tester->execute(['themeCode' => 'Vendor/theme']);
+
+ $this->assertSame(Cli::RETURN_SUCCESS, $exitCode);
+ }
+}
diff --git a/tests/Unit/Console/Command/Theme/WatchCommandTest.php b/tests/Unit/Console/Command/Theme/WatchCommandTest.php
new file mode 100644
index 00000000..8d20035d
--- /dev/null
+++ b/tests/Unit/Console/Command/Theme/WatchCommandTest.php
@@ -0,0 +1,132 @@
+builderPool = $this->createMock(BuilderPool::class);
+ $this->themeList = $this->createMock(ThemeList::class);
+ $this->themePath = $this->createMock(ThemePath::class);
+ $this->themeSuggester = $this->createMock(ThemeSuggester::class);
+ $this->command = new WatchCommand(
+ $this->builderPool,
+ $this->themeList,
+ $this->themePath,
+ $this->themeSuggester,
+ );
+ }
+
+ public function testCommandNameAndAlias(): void
+ {
+ $this->assertSame('mageforge:theme:watch', $this->command->getName());
+ $this->assertSame(['frontend:watch'], $this->command->getAliases());
+ }
+
+ public function testWatchesThemeViaArgument(): void
+ {
+ $this->themePath->method('getPath')->with('Vendor/theme')->willReturn('/path/to/theme');
+ $builder = $this->createMock(BuilderInterface::class);
+ $builder
+ ->expects($this->once())
+ ->method('watch')
+ ->with('Vendor/theme', '/path/to/theme')
+ ->willReturn(true);
+ $this->builderPool->method('getBuilder')->with('/path/to/theme')->willReturn($builder);
+
+ $tester = new CommandTester($this->command);
+ $exitCode = $tester->execute(['themeCode' => 'Vendor/theme']);
+
+ $this->assertSame(Command::SUCCESS, $exitCode);
+ }
+
+ public function testWatchesThemeViaOption(): void
+ {
+ $this->themePath->method('getPath')->with('Vendor/theme')->willReturn('/path/to/theme');
+ $builder = $this->createMock(BuilderInterface::class);
+ $builder->method('watch')->willReturn(true);
+ $this->builderPool->method('getBuilder')->willReturn($builder);
+
+ $tester = new CommandTester($this->command);
+ $exitCode = $tester->execute(['--theme' => 'Vendor/theme']);
+
+ $this->assertSame(Command::SUCCESS, $exitCode);
+ }
+
+ public function testFailsWhenWatcherReportsFailure(): void
+ {
+ $this->themePath->method('getPath')->willReturn('/path/to/theme');
+ $builder = $this->createMock(BuilderInterface::class);
+ $builder->method('watch')->willReturn(false);
+ $this->builderPool->method('getBuilder')->willReturn($builder);
+
+ $tester = new CommandTester($this->command);
+ $exitCode = $tester->execute(['themeCode' => 'Vendor/theme']);
+
+ $this->assertSame(Command::FAILURE, $exitCode);
+ }
+
+ public function testFailsWhenNoBuilderIsFound(): void
+ {
+ $this->themePath->method('getPath')->willReturn('/path/to/theme');
+ $this->builderPool->method('getBuilder')->willReturn(null);
+
+ $tester = new CommandTester($this->command);
+ $exitCode = $tester->execute(['themeCode' => 'Vendor/theme']);
+
+ $this->assertSame(Command::FAILURE, $exitCode);
+ $this->assertStringContainsString('No suitable builder found for theme Vendor/theme.', $tester->getDisplay());
+ }
+
+ public function testFailsForUnknownThemeWithoutSuggestions(): void
+ {
+ $this->themePath->method('getPath')->willReturn(null);
+ $this->themeSuggester->method('findSimilarThemes')->willReturn([]);
+
+ $tester = new CommandTester($this->command);
+ $exitCode = $tester->execute(['themeCode' => 'Vendor/unknown']);
+
+ $this->assertSame(Command::FAILURE, $exitCode);
+ $normalizedDisplay = (string) preg_replace('/\s+/', ' ', $tester->getDisplay());
+ $this->assertStringContainsString(
+ "Theme 'Vendor/unknown' is not installed and no similar themes were found.",
+ $normalizedDisplay,
+ );
+ }
+
+ public function testListsSuggestionsForUnknownThemeInNonInteractiveMode(): void
+ {
+ $this->themePath->method('getPath')->willReturn(null);
+ $this->themeSuggester->method('findSimilarThemes')->willReturn(['Vendor/theme', 'Vendor/other']);
+
+ $tester = new CommandTester($this->command);
+ $exitCode = $tester->execute(['themeCode' => 'Vendor/them']);
+
+ $this->assertSame(Command::FAILURE, $exitCode);
+ $display = $tester->getDisplay();
+ $this->assertStringContainsString("Theme 'Vendor/them' is not installed.", $display);
+ $this->assertStringContainsString('Did you mean one of these?', $display);
+ $this->assertStringContainsString('- Vendor/theme', $display);
+ $this->assertStringContainsString('- Vendor/other', $display);
+ }
+}
diff --git a/tests/Unit/Model/Config/InspectorTest.php b/tests/Unit/Model/Config/InspectorTest.php
new file mode 100644
index 00000000..16662233
--- /dev/null
+++ b/tests/Unit/Model/Config/InspectorTest.php
@@ -0,0 +1,25 @@
+assertSame('dev/mageforge_inspector/enabled', Inspector::XML_PATH_ENABLED);
+ $this->assertSame('mageforge/inspector/show_button_labels', Inspector::XML_PATH_SHOW_BUTTON_LABELS);
+ $this->assertSame('mageforge/inspector/theme', Inspector::XML_PATH_THEME);
+ $this->assertSame('mageforge/inspector/position', Inspector::XML_PATH_POSITION);
+ }
+
+ public function testDefaultValueConstants(): void
+ {
+ $this->assertSame('dark', Inspector::DEFAULT_THEME);
+ $this->assertSame('bottom-left', Inspector::DEFAULT_POSITION);
+ }
+}
diff --git a/tests/Unit/Model/TemplateEngine/Decorator/FakeVendorModuleBlock2.php b/tests/Unit/Model/TemplateEngine/Decorator/FakeVendorModuleBlock2.php
new file mode 100644
index 00000000..e0a52ddb
--- /dev/null
+++ b/tests/Unit/Model/TemplateEngine/Decorator/FakeVendorModuleBlock2.php
@@ -0,0 +1,20 @@
+objectManager = $this->createMock(ObjectManagerInterface::class);
+ $this->factory = new InspectorHintsFactory($this->objectManager);
+ }
+
+ public function testCreatePassesDataToObjectManager(): void
+ {
+ $instance = $this->createMock(InspectorHints::class);
+ $data = ['subject' => 'foo', 'showBlockHints' => true];
+
+ $this->objectManager->expects($this->once())
+ ->method('create')
+ ->with(InspectorHints::class, $data)
+ ->willReturn($instance);
+
+ $this->assertSame($instance, $this->factory->create($data));
+ }
+
+ public function testCreateDefaultsToEmptyData(): void
+ {
+ $instance = $this->createMock(InspectorHints::class);
+
+ $this->objectManager->expects($this->once())
+ ->method('create')
+ ->with(InspectorHints::class, [])
+ ->willReturn($instance);
+
+ $this->assertSame($instance, $this->factory->create());
+ }
+}
diff --git a/tests/Unit/Model/TemplateEngine/Decorator/InspectorHintsTest.php b/tests/Unit/Model/TemplateEngine/Decorator/InspectorHintsTest.php
new file mode 100644
index 00000000..9a7369af
--- /dev/null
+++ b/tests/Unit/Model/TemplateEngine/Decorator/InspectorHintsTest.php
@@ -0,0 +1,215 @@
+subject = $this->createMock(TemplateEngineInterface::class);
+ $this->random = $this->createMock(Random::class);
+ $this->cacheCollector = $this->createMock(BlockCacheCollector::class);
+ $this->fileDriver = $this->createMock(File::class);
+ $this->escaper = $this->createMock(Escaper::class);
+
+ // Fix the resolved Magento root so relative-path assertions are deterministic.
+ $this->fileDriver->method('getParentDirectory')->willReturn('/var/www/html');
+
+ $this->cacheCollector->method('getCacheInfo')->willReturn([
+ 'cacheable' => true,
+ 'lifetime' => null,
+ 'cacheKey' => '',
+ 'cacheTags' => [],
+ 'pageCacheable' => true,
+ ]);
+ $this->cacheCollector->method('formatMetricsForJson')->willReturn([
+ 'performance' => ['renderTime' => '0.00', 'timestamp' => 0],
+ 'cache' => ['cacheable' => true, 'lifetime' => null, 'key' => '', 'tags' => [], 'pageCacheable' => true],
+ ]);
+ $this->escaper->method('escapeHtml')->willReturnCallback(
+ static fn (string $value): string => htmlspecialchars($value, ENT_QUOTES),
+ );
+ }
+
+ private function createInspectorHints(
+ array $excludedClassPrefixes = [],
+ array $excludedTemplatePaths = [],
+ ): InspectorHints {
+ return new InspectorHints(
+ $this->subject,
+ true,
+ $this->random,
+ $this->cacheCollector,
+ $this->fileDriver,
+ $this->escaper,
+ $excludedClassPrefixes,
+ $excludedTemplatePaths,
+ );
+ }
+
+ private function createBlock(): BlockInterface&MockObject
+ {
+ return $this->createMock(BlockInterface::class);
+ }
+
+ public function testRenderReturnsSubjectResultWhenShowBlockHintsDisabled(): void
+ {
+ $block = $this->createBlock();
+ $this->subject->method('render')->willReturn('Hello
');
+
+ $inspector = new InspectorHints(
+ $this->subject,
+ false,
+ $this->random,
+ $this->cacheCollector,
+ $this->fileDriver,
+ $this->escaper,
+ );
+
+ $result = $inspector->render($block, 'template.phtml');
+
+ $this->assertSame('Hello
', $result);
+ }
+
+ public function testRenderSkipsInjectionForExcludedBlockClass(): void
+ {
+ $block = $this->createBlock();
+ $this->subject->method('render')->willReturn('Hello
');
+
+ $inspector = $this->createInspectorHints([get_class($block)]);
+ $result = $inspector->render($block, 'template.phtml');
+
+ $this->assertSame('Hello
', $result);
+ }
+
+ public function testRenderSkipsInjectionForExcludedTemplatePath(): void
+ {
+ $block = $this->createBlock();
+ $this->subject->method('render')->willReturn('Hello
');
+
+ $inspector = $this->createInspectorHints([], ['magewire']);
+ $result = $inspector->render($block, '/path/to/magewire/component.phtml');
+
+ $this->assertSame('Hello
', $result);
+ }
+
+ public function testRenderSkipsInjectionForEmptyContent(): void
+ {
+ $block = $this->createBlock();
+ $this->subject->method('render')->willReturn(' ');
+
+ $inspector = $this->createInspectorHints();
+ $result = $inspector->render($block, 'template.phtml');
+
+ $this->assertSame(' ', $result);
+ }
+
+ public function testRenderSkipsInjectionWhenContentDoesNotStartWithHtmlTag(): void
+ {
+ $block = $this->createBlock();
+ $this->subject->method('render')->willReturn('https://example.com/some-url');
+
+ $inspector = $this->createInspectorHints();
+ $result = $inspector->render($block, 'template.phtml');
+
+ $this->assertSame('https://example.com/some-url', $result);
+ }
+
+ public function testRenderInjectsInspectorAttributesIntoRootElement(): void
+ {
+ $block = $this->createBlock();
+ $this->subject->method('render')->willReturn('Hello
');
+ $this->random->method('getRandomString')->willReturn('abcdef1234567890');
+
+ $inspector = $this->createInspectorHints();
+ $result = $inspector->render($block, '/var/www/html/template.phtml');
+
+ $this->assertStringStartsWith('assertStringContainsString('data-mageforge-block=', $result);
+ $this->assertStringContainsString('Hello
', $result);
+ }
+
+ public function testRenderEmbedsMetadataAsEscapedJson(): void
+ {
+ $block = $this->createBlock();
+ $this->subject->method('render')->willReturn('Hello
');
+ $this->random->method('getRandomString')->willReturn('xyz');
+
+ $inspector = $this->createInspectorHints();
+ $result = $inspector->render($block, '/var/www/html/some/template.phtml');
+
+ preg_match('/data-mageforge-block="([^"]*)"/', $result, $matches);
+ $this->assertNotEmpty($matches);
+ $decodedJson = html_entity_decode($matches[1], ENT_QUOTES);
+ $metadata = json_decode($decodedJson, true);
+
+ $this->assertSame('mageforge-xyz', $metadata['id']);
+ $this->assertSame('some/template.phtml', $metadata['template']);
+ $this->assertArrayHasKey('block', $metadata);
+ $this->assertArrayHasKey('module', $metadata);
+ $this->assertArrayHasKey('performance', $metadata);
+ $this->assertArrayHasKey('cache', $metadata);
+ }
+
+ public function testRenderExtractsModuleNameFromRealBlockClassName(): void
+ {
+ $block = new FakeVendorModuleBlock2();
+ $this->subject->method('render')->willReturn('Hello
');
+ $this->random->method('getRandomString')->willReturn('xyz');
+
+ $inspector = $this->createInspectorHints();
+ $result = $inspector->render($block, '/var/www/html/template.phtml');
+
+ preg_match('/data-mageforge-block="([^"]*)"/', $result, $matches);
+ $metadata = json_decode(html_entity_decode($matches[1], ENT_QUOTES), true);
+
+ $this->assertSame('OpenForgeProject_MageForge', $metadata['module']);
+ }
+
+ public function testRenderDetectsParentBlockAndAlias(): void
+ {
+ $parentBlock = $this->getMockBuilder(AbstractBlock::class)
+ ->disableOriginalConstructor()
+ ->onlyMethods(['getNameInLayout'])
+ ->getMock();
+ $parentBlock->method('getNameInLayout')->willReturn('parent.block');
+
+ $block = $this->getMockBuilder(AbstractBlock::class)
+ ->disableOriginalConstructor()
+ ->onlyMethods(['getParentBlock', 'getNameInLayout'])
+ ->getMock();
+ $block->method('getParentBlock')->willReturn($parentBlock);
+ $block->method('getNameInLayout')->willReturn('child.block');
+
+ $this->subject->method('render')->willReturn('Hello
');
+ $this->random->method('getRandomString')->willReturn('xyz');
+
+ $inspector = $this->createInspectorHints();
+ $result = $inspector->render($block, '/var/www/html/template.phtml');
+
+ preg_match('/data-mageforge-block="([^"]*)"/', $result, $matches);
+ $metadata = json_decode(html_entity_decode($matches[1], ENT_QUOTES), true);
+
+ $this->assertSame('parent.block', $metadata['parent']);
+ $this->assertSame('child.block', $metadata['alias']);
+ }
+}
diff --git a/tests/Unit/Model/TemplateEngine/Plugin/InspectorHintsTest.php b/tests/Unit/Model/TemplateEngine/Plugin/InspectorHintsTest.php
new file mode 100644
index 00000000..0be5f63e
--- /dev/null
+++ b/tests/Unit/Model/TemplateEngine/Plugin/InspectorHintsTest.php
@@ -0,0 +1,100 @@
+scopeConfig = $this->createMock(ScopeConfigInterface::class);
+ $this->developerAccessChecker = $this->createMock(DeveloperAccessChecker::class);
+ $this->inspectorHintsFactory = $this->createMock(InspectorHintsFactory::class);
+ $this->state = $this->createMock(State::class);
+ $this->subject = $this->createMock(TemplateEngineFactory::class);
+ $this->invocationResult = $this->createMock(TemplateEngineInterface::class);
+
+ $this->plugin = new InspectorHints(
+ $this->scopeConfig,
+ $this->developerAccessChecker,
+ $this->inspectorHintsFactory,
+ $this->state,
+ );
+ }
+
+ public function testReturnsOriginalResultWhenNotInDeveloperMode(): void
+ {
+ $this->state->method('getMode')->willReturn(State::MODE_PRODUCTION);
+ $this->scopeConfig->expects($this->never())->method('isSetFlag');
+
+ $result = $this->plugin->afterCreate($this->subject, $this->invocationResult);
+
+ $this->assertSame($this->invocationResult, $result);
+ }
+
+ public function testReturnsOriginalResultWhenInspectorDisabled(): void
+ {
+ $this->state->method('getMode')->willReturn(State::MODE_DEVELOPER);
+ $this->scopeConfig->method('isSetFlag')
+ ->with(InspectorConfig::XML_PATH_ENABLED, InspectorConfig::SCOPE_STORE)
+ ->willReturn(false);
+ $this->developerAccessChecker->expects($this->never())->method('isDevAllowed');
+
+ $result = $this->plugin->afterCreate($this->subject, $this->invocationResult);
+
+ $this->assertSame($this->invocationResult, $result);
+ }
+
+ public function testReturnsOriginalResultWhenIpNotAllowed(): void
+ {
+ $this->state->method('getMode')->willReturn(State::MODE_DEVELOPER);
+ $this->scopeConfig->method('isSetFlag')->willReturn(true);
+ $this->developerAccessChecker->method('isDevAllowed')->willReturn(false);
+ $this->inspectorHintsFactory->expects($this->never())->method('create');
+
+ $result = $this->plugin->afterCreate($this->subject, $this->invocationResult);
+
+ $this->assertSame($this->invocationResult, $result);
+ }
+
+ public function testWrapsResultWithInspectorDecoratorWhenAllChecksPass(): void
+ {
+ $this->state->method('getMode')->willReturn(State::MODE_DEVELOPER);
+ $this->scopeConfig->method('isSetFlag')->willReturn(true);
+ $this->developerAccessChecker->method('isDevAllowed')->willReturn(true);
+
+ $decorated = $this->createMock(InspectorHintsDecorator::class);
+ $this->inspectorHintsFactory->expects($this->once())
+ ->method('create')
+ ->with([
+ 'subject' => $this->invocationResult,
+ 'showBlockHints' => true,
+ ])
+ ->willReturn($decorated);
+
+ $result = $this->plugin->afterCreate($this->subject, $this->invocationResult);
+
+ $this->assertSame($decorated, $result);
+ }
+}
diff --git a/tests/Unit/Service/DependencyCheckerTest.php b/tests/Unit/Service/DependencyCheckerTest.php
index fc4e0f91..1231ec85 100644
--- a/tests/Unit/Service/DependencyCheckerTest.php
+++ b/tests/Unit/Service/DependencyCheckerTest.php
@@ -193,4 +193,82 @@ private function givenFiles(array $files): void
->method('isFile')
->willReturnCallback(static fn(string $path): bool => $files[$path] ?? false);
}
+
+ public function testVerboseSampleCopyFlowReportsEachStep(): 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);
+
+ $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.sample' file found.",
+ "'package.json.sample' has been copied to 'package.json'.",
+ "The 'node_modules' folder found.",
+ "The 'Gruntfile.js' file found.",
+ ],
+ $successMessages,
+ );
+ }
+
+ public function testVerboseNpmInstallFlowReportsEachStep(): void
+ {
+ $this->givenFiles(['package.json' => true, 'Gruntfile.js' => true]);
+ $this->fileDriver->method('isDirectory')->willReturn(false);
+ $this->io->method('confirm')->willReturn(true);
+ $this->shell->method('execute')->with('npm install --quiet')->willReturn('added 120 packages');
+ $this->io->expects($this->once())->method('section')->with("Running 'npm install'... Please wait.");
+ $this->io->expects($this->once())->method('writeln')->with('added 120 packages');
+
+ $warnings = [];
+ $this->io
+ ->method('warning')
+ ->willReturnCallback(function (string $message) use (&$warnings): void {
+ $warnings[] = $message;
+ });
+
+ $this->assertTrue($this->checker->checkDependencies($this->io, true));
+ $this->assertSame(["The 'node_modules' folder does not exist in the Magento root path."], $warnings);
+ }
+
+ public function testVerboseGruntfileSampleCopyReportsEachStep(): 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);
+
+ $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.sample' file found.",
+ "'Gruntfile.js.sample' has been copied to 'Gruntfile.js'.",
+ ],
+ $successMessages,
+ );
+ }
}
diff --git a/tests/Unit/Service/DeveloperAccessCheckerTest.php b/tests/Unit/Service/DeveloperAccessCheckerTest.php
new file mode 100644
index 00000000..3ee1428b
--- /dev/null
+++ b/tests/Unit/Service/DeveloperAccessCheckerTest.php
@@ -0,0 +1,93 @@
+scopeConfig = $this->createMock(ScopeConfigInterface::class);
+ $this->remoteAddress = $this->createMock(RemoteAddress::class);
+ $this->httpHeader = $this->createMock(Header::class);
+
+ $this->checker = new DeveloperAccessChecker(
+ $this->scopeConfig,
+ $this->remoteAddress,
+ $this->httpHeader,
+ );
+ }
+
+ public function testAllowsAccessWhenNoAllowedIpsAreConfigured(): void
+ {
+ $this->scopeConfig->method('getValue')->willReturn('');
+ $this->remoteAddress->expects($this->never())->method('getRemoteAddress');
+
+ $this->assertTrue($this->checker->isDevAllowed());
+ }
+
+ public function testAllowsAccessWhenConfiguredValueIsNotAString(): void
+ {
+ $this->scopeConfig->method('getValue')->willReturn(null);
+
+ $this->assertTrue($this->checker->isDevAllowed());
+ }
+
+ public function testAllowsAccessWhenRemoteAddressCannotBeDetermined(): void
+ {
+ $this->scopeConfig->method('getValue')->willReturn('127.0.0.1');
+ $this->remoteAddress->method('getRemoteAddress')->willReturn(false);
+
+ $this->assertTrue($this->checker->isDevAllowed());
+ }
+
+ public function testAllowsMatchingRemoteAddress(): void
+ {
+ $this->scopeConfig->method('getValue')->willReturn('10.0.0.1, 10.0.0.2');
+ $this->remoteAddress->method('getRemoteAddress')->willReturn('10.0.0.2');
+
+ $this->assertTrue($this->checker->isDevAllowed());
+ }
+
+ public function testAllowsMatchingHttpHostWhenRemoteAddressDoesNotMatch(): void
+ {
+ $this->scopeConfig->method('getValue')->willReturn('example.test');
+ $this->remoteAddress->method('getRemoteAddress')->willReturn('10.0.0.9');
+ $this->httpHeader->method('getHttpHost')->willReturn('example.test');
+
+ $this->assertTrue($this->checker->isDevAllowed());
+ }
+
+ public function testDeniesAccessWhenNeitherRemoteAddressNorHostMatch(): void
+ {
+ $this->scopeConfig->method('getValue')->willReturn('10.0.0.1');
+ $this->remoteAddress->method('getRemoteAddress')->willReturn('10.0.0.9');
+ $this->httpHeader->method('getHttpHost')->willReturn('other.test');
+
+ $this->assertFalse($this->checker->isDevAllowed());
+ }
+
+ public function testPassesStoreIdAndScopeToScopeConfig(): void
+ {
+ $this->scopeConfig->expects($this->once())
+ ->method('getValue')
+ ->with('dev/restrict/allow_ips', InspectorConfig::SCOPE_STORE, '2')
+ ->willReturn('');
+
+ $this->checker->isDevAllowed('2');
+ }
+}
diff --git a/tests/Unit/Service/Hyva/CompatibilityCheckerTest.php b/tests/Unit/Service/Hyva/CompatibilityCheckerTest.php
index 0ce5092e..748215e9 100644
--- a/tests/Unit/Service/Hyva/CompatibilityCheckerTest.php
+++ b/tests/Unit/Service/Hyva/CompatibilityCheckerTest.php
@@ -156,6 +156,24 @@ public function testCountsHyvaAwareModules(): void
$this->assertSame(1, $results['summary']['hyvaAware']);
}
+ public function testShowAllPrintsScanningLineForEachModule(): void
+ {
+ $this->givenModules(['Vendor_Module' => '/app/code/Vendor/Module']);
+ $this->givenScanResults(['/app/code/Vendor/Module' => $this->scanResult(critical: 0, total: 0)]);
+ $this->givenModuleInfo(hyvaAware: false);
+ $textCalls = [];
+ $this->io->method('text')->willReturnCallback(function (string $message) use (&$textCalls): void {
+ $textCalls[] = $message;
+ });
+
+ $this->checker->check($this->io, showAll: true);
+
+ $this->assertSame(
+ ['Scanning 1 modules for Hyvä compatibility...', ' Scanning: Vendor_Module>'],
+ $textCalls,
+ );
+ }
+
// -------------------------------------------------------------------------
// formatResultsForDisplay
// -------------------------------------------------------------------------
diff --git a/tests/Unit/Service/Hyva/IncompatibilityDetectorTest.php b/tests/Unit/Service/Hyva/IncompatibilityDetectorTest.php
index f96a4f12..c958f7e0 100644
--- a/tests/Unit/Service/Hyva/IncompatibilityDetectorTest.php
+++ b/tests/Unit/Service/Hyva/IncompatibilityDetectorTest.php
@@ -349,8 +349,15 @@ public static function incompatibleHtmlProvider(): array
public function testReturnsEmptyArrayWhenFileNotExists(): void
{
- $this->fileMock->method('isExists')->willReturn(false);
- $issues = $this->detector->detectInFile('nonexistent.js');
+ // Uses its own mock (rather than the shared $this->fileMock, which the setUp() method
+ // already stubs with isExists() => true) since a second `method('isExists')` stub
+ // does not override the first one registered for the same method.
+ $fileMock = $this->createMock(File::class);
+ $fileMock->method('isExists')->willReturn(false);
+ $fileMock->expects($this->never())->method('fileGetContents');
+ $detector = new IncompatibilityDetector($fileMock);
+
+ $issues = $detector->detectInFile('nonexistent.js');
$this->assertEmpty($issues);
}
diff --git a/tests/Unit/Service/Hyva/ModuleScannerTest.php b/tests/Unit/Service/Hyva/ModuleScannerTest.php
index c101eb87..ee04dab6 100644
--- a/tests/Unit/Service/Hyva/ModuleScannerTest.php
+++ b/tests/Unit/Service/Hyva/ModuleScannerTest.php
@@ -43,25 +43,31 @@ public function testCollectsIssuesAndCountsCriticalOnes(): void
$this->fileDriver->method('readDirectory')->with('/module')->willReturn([
'/module/view.js',
'/module/layout.xml',
+ '/module/clean.js',
]);
$this->detector->method('getExtensionFromPath')->willReturnMap([
['/module/view.js', 'js'],
['/module/layout.xml', 'xml'],
+ ['/module/clean.js', 'js'],
]);
$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', []],
+ ['/module/layout.xml', [
+ ['description' => 'UI Component usage', 'severity' => 'critical', 'line' => 3],
+ ]],
+ ['/module/clean.js', []],
]);
$result = $this->scanner->scanModule('/module');
- $this->assertSame(2, $result['totalIssues']);
- $this->assertSame(1, $result['criticalIssues']);
+ $this->assertSame(3, $result['totalIssues']);
+ $this->assertSame(2, $result['criticalIssues']);
$this->assertArrayHasKey('view.js', $result['files']);
- $this->assertCount(1, $result['files']);
+ $this->assertArrayHasKey('layout.xml', $result['files']);
+ $this->assertCount(2, $result['files']);
}
public function testSkipsExcludedDirectoriesAndIrrelevantExtensions(): void
@@ -69,11 +75,12 @@ 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']],
+ ['/module/src', ['/module/src/widget.js', '/module/src/grid.js']],
]);
$this->detector->method('getExtensionFromPath')->willReturnMap([
['/module/readme.md', 'md'],
['/module/src/widget.js', 'js'],
+ ['/module/src/grid.js', 'js'],
]);
$scannedFiles = [];
@@ -86,7 +93,7 @@ public function testSkipsExcludedDirectoriesAndIrrelevantExtensions(): void
$this->scanner->scanModule('/module');
- $this->assertSame(['/module/src/widget.js'], $scannedFiles);
+ $this->assertSame(['/module/src/widget.js', '/module/src/grid.js'], $scannedFiles);
}
public function testSkipsUnreadableDirectories(): void
@@ -100,6 +107,20 @@ public function testSkipsUnreadableDirectories(): void
);
}
+ public function testHandlesDirectoryEntryWithoutPathSeparator(): void
+ {
+ $this->givenDirectories(['/module']);
+ $this->fileDriver->method('readDirectory')->willReturnMap([
+ ['/module', ['widget.js']],
+ ]);
+ $this->detector->method('getExtensionFromPath')->with('widget.js')->willReturn('js');
+ $this->detector->method('detectInFile')->willReturn([]);
+
+ $result = $this->scanner->scanModule('/module');
+
+ $this->assertSame(['files' => [], 'totalIssues' => 0, 'criticalIssues' => 0], $result);
+ }
+
// -------------------------------------------------------------------------
// getModuleInfo
// -------------------------------------------------------------------------
@@ -170,6 +191,13 @@ public function testPlainHyvaThemesPackageWithoutCompatSuffixIsNotHyvaAware(): v
$this->assertFalse($this->scanner->getModuleInfo('/module')['isHyvaAware']);
}
+ public function testNonArrayRequireIsNotHyvaAware(): void
+ {
+ $this->givenComposerJson(['name' => 'vendor/module', 'require' => 'not-an-array']);
+
+ $this->assertFalse($this->scanner->getModuleInfo('/module')['isHyvaAware']);
+ }
+
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
@@ -192,4 +220,14 @@ private function givenComposerJson(array $composerData): void
$this->fileDriver->method('isExists')->with('/module/composer.json')->willReturn(true);
$this->fileDriver->method('fileGetContents')->willReturn(json_encode($composerData));
}
+
+ public function testNonHyvaRequirementsAreNotHyvaAware(): void
+ {
+ $this->givenComposerJson([
+ 'name' => 'vendor/module',
+ 'require' => ['php' => '^8.3', 'magento/framework' => '*'],
+ ]);
+
+ $this->assertFalse($this->scanner->getModuleInfo('/module')['isHyvaAware']);
+ }
}
diff --git a/tests/Unit/Service/Inspector/Cache/BlockCacheCollectorTest.php b/tests/Unit/Service/Inspector/Cache/BlockCacheCollectorTest.php
new file mode 100644
index 00000000..25334379
--- /dev/null
+++ b/tests/Unit/Service/Inspector/Cache/BlockCacheCollectorTest.php
@@ -0,0 +1,175 @@
+layout = $this->createMock(LayoutInterface::class);
+ $this->collector = new BlockCacheCollector($this->layout);
+ }
+
+ /**
+ * getCacheLifetime()/getCacheTags() are protected on AbstractBlock, so calling them from
+ * outside the class (as the collector does) is routed through DataObject::__call(), which
+ * reads the raw value from the block's data bag. Configuring the block via setData() lets
+ * us control exactly what the collector sees without fighting mock visibility rules.
+ */
+ private function createBlock(): AbstractBlock&MockObject
+ {
+ return $this->getMockBuilder(AbstractBlock::class)
+ ->disableOriginalConstructor()
+ ->onlyMethods(['isScopePrivate', 'getCacheKey'])
+ ->getMock();
+ }
+
+ public function testGetCacheInfoReturnsNotCacheableWhenLifetimeIsFalse(): void
+ {
+ $block = $this->createBlock();
+ $block->setData('cache_lifetime', false);
+ $this->layout->method('getAllBlocks')->willReturn([]);
+
+ $info = $this->collector->getCacheInfo($block);
+
+ $this->assertFalse($info['cacheable']);
+ $this->assertNull($info['lifetime']);
+ }
+
+ public function testGetCacheInfoReturnsUnlimitedLifetimeWhenNull(): void
+ {
+ $block = $this->createBlock();
+ $block->setData('cache_lifetime', null);
+ $this->layout->method('getAllBlocks')->willReturn([]);
+
+ $info = $this->collector->getCacheInfo($block);
+
+ $this->assertTrue($info['cacheable']);
+ $this->assertNull($info['lifetime']);
+ }
+
+ public function testGetCacheInfoReturnsSpecificLifetime(): void
+ {
+ $block = $this->createBlock();
+ $block->setData('cache_lifetime', 3600);
+ $this->layout->method('getAllBlocks')->willReturn([]);
+
+ $info = $this->collector->getCacheInfo($block);
+
+ $this->assertTrue($info['cacheable']);
+ $this->assertSame(3600, $info['lifetime']);
+ }
+
+ public function testGetCacheInfoTreatsPrivateScopeAsNotCacheable(): void
+ {
+ $block = $this->createBlock();
+ $block->setData('cache_lifetime', 3600);
+ $block->method('isScopePrivate')->willReturn(true);
+ $this->layout->method('getAllBlocks')->willReturn([]);
+
+ $info = $this->collector->getCacheInfo($block);
+
+ $this->assertFalse($info['cacheable']);
+ $this->assertNull($info['lifetime']);
+ }
+
+ public function testGetCacheInfoResolvesCacheKeyAndTags(): void
+ {
+ $block = $this->createBlock();
+ $block->setData('cache_lifetime', false);
+ $block->setData('cache_tags', ['tag_one', 'tag_two', 42]);
+ $block->method('getCacheKey')->willReturn('some_cache_key');
+ $this->layout->method('getAllBlocks')->willReturn([]);
+
+ $info = $this->collector->getCacheInfo($block);
+
+ $this->assertSame('some_cache_key', $info['cacheKey']);
+ $this->assertSame(['tag_one', 'tag_two'], $info['cacheTags']);
+ }
+
+ public function testGetCacheInfoDefaultsCacheKeyWhenEmpty(): void
+ {
+ $block = $this->createBlock();
+ $block->setData('cache_lifetime', false);
+ $block->method('getCacheKey')->willReturn('');
+ $this->layout->method('getAllBlocks')->willReturn([]);
+
+ $info = $this->collector->getCacheInfo($block);
+
+ $this->assertSame('', $info['cacheKey']);
+ }
+
+ public function testGetCacheInfoDetectsNonCacheablePageFromLayoutData(): void
+ {
+ $block = $this->createBlock();
+ $block->setData('cache_lifetime', false);
+
+ $dataBlock = $this->createBlock();
+ $dataBlock->setData('cacheable', false);
+
+ $this->layout->method('getAllBlocks')->willReturn([$dataBlock]);
+
+ $info = $this->collector->getCacheInfo($block);
+
+ $this->assertFalse($info['pageCacheable']);
+ }
+
+ public function testGetCacheInfoTreatsPageAsCacheableWhenLayoutThrows(): void
+ {
+ $block = $this->createBlock();
+ $block->setData('cache_lifetime', false);
+ $this->layout->method('getAllBlocks')->willThrowException(new \Exception('boom'));
+
+ $info = $this->collector->getCacheInfo($block);
+
+ $this->assertTrue($info['pageCacheable']);
+ }
+
+ public function testGetCacheInfoIgnoresNonObjectBlocksFromLayout(): void
+ {
+ $block = $this->createBlock();
+ $block->setData('cache_lifetime', false);
+ $this->layout->method('getAllBlocks')->willReturn(['not-an-object']);
+
+ $info = $this->collector->getCacheInfo($block);
+
+ $this->assertTrue($info['pageCacheable']);
+ }
+
+ public function testFormatMetricsForJsonBuildsExpectedStructure(): void
+ {
+ $renderMetrics = [
+ 'renderTimeMs' => 12.345,
+ 'startTime' => 1_700_000_000_000_000_000,
+ 'endTime' => 1_700_000_000_012_000_000,
+ ];
+ $cacheMetrics = [
+ 'cacheable' => true,
+ 'lifetime' => 3600,
+ 'cacheKey' => 'key',
+ 'cacheTags' => ['tag'],
+ 'pageCacheable' => true,
+ ];
+
+ $formatted = $this->collector->formatMetricsForJson($renderMetrics, $cacheMetrics);
+
+ $this->assertSame('12.35', $formatted['performance']['renderTime']);
+ $this->assertSame(1_700_000_000, $formatted['performance']['timestamp']);
+ $this->assertSame(true, $formatted['cache']['cacheable']);
+ $this->assertSame(3600, $formatted['cache']['lifetime']);
+ $this->assertSame('key', $formatted['cache']['key']);
+ $this->assertSame(['tag'], $formatted['cache']['tags']);
+ $this->assertTrue($formatted['cache']['pageCacheable']);
+ }
+}
diff --git a/tests/Unit/Service/NodePackageManagerTest.php b/tests/Unit/Service/NodePackageManagerTest.php
index 5466a8c8..d67e92d5 100644
--- a/tests/Unit/Service/NodePackageManagerTest.php
+++ b/tests/Unit/Service/NodePackageManagerTest.php
@@ -83,6 +83,44 @@ public function testReturnsFalseAndPrintsErrorWhenInstallationFails(): void
$this->assertFalse($this->packageManager->installNodeModules('/theme', $this->io, false));
}
+ public function testWarnsAndReportsSuccessInVerboseModeWhenNpmCiFails(): void
+ {
+ $this->fileDriver->method('isExists')->willReturn(true);
+ $this->shell
+ ->method('execute')
+ ->willReturnCallback(function (string $command): string {
+ if (str_contains($command, 'npm ci')) {
+ throw new \RuntimeException('lock file out of sync');
+ }
+ return '';
+ });
+ $this->io
+ ->expects($this->once())
+ ->method('warning')
+ ->with('npm ci failed, falling back to npm install...');
+ $this->io
+ ->expects($this->once())
+ ->method('success')
+ ->with('Node modules installed successfully.');
+
+ $this->assertTrue($this->packageManager->installNodeModules('/theme', $this->io, true));
+ }
+
+ public function testWarnsInVerboseModeWhenLockFileIsMissing(): void
+ {
+ $this->fileDriver->method('isExists')->willReturn(false);
+ $this->io
+ ->expects($this->once())
+ ->method('warning')
+ ->with('No package-lock.json found, running npm install...');
+ $this->io
+ ->expects($this->once())
+ ->method('success')
+ ->with('Node modules installed successfully.');
+
+ $this->assertTrue($this->packageManager->installNodeModules('/theme', $this->io, true));
+ }
+
// -------------------------------------------------------------------------
// isNodeModulesInSync
// -------------------------------------------------------------------------
diff --git a/tests/Unit/Service/NodeSetupValidatorTest.php b/tests/Unit/Service/NodeSetupValidatorTest.php
new file mode 100644
index 00000000..85356890
--- /dev/null
+++ b/tests/Unit/Service/NodeSetupValidatorTest.php
@@ -0,0 +1,193 @@
+fileDriver = $this->createMock(FileDriver::class);
+ $this->nodePackageManager = $this->createMock(NodePackageManager::class);
+ $this->io = $this->createMock(SymfonyStyle::class);
+
+ $this->validator = new NodeSetupValidator($this->fileDriver, $this->nodePackageManager);
+ }
+
+ private function markAllRequiredFilesPresent(): void
+ {
+ // Exact-path whitelists (rather than a blanket willReturn(true)) so that a mutation to
+ // the path concatenation (wrong separator, wrong operand order, etc.) produces a path
+ // that doesn't match and is treated as "missing", changing the test's outcome.
+ $expectedFiles = ['./package.json', './Gruntfile.js', './grunt-config.json', './package-lock.json'];
+ $this->fileDriver->method('isExists')->willReturnCallback(
+ static fn (string $path): bool => in_array($path, $expectedFiles, true),
+ );
+ $this->fileDriver->method('isDirectory')->willReturnCallback(
+ static fn (string $path): bool => $path === './node_modules',
+ );
+ }
+
+ public function testValidateAndRestoreReturnsTrueWhenNothingIsMissing(): void
+ {
+ $this->markAllRequiredFilesPresent();
+
+ $this->assertTrue($this->validator->validateAndRestore('.', $this->io, false));
+ }
+
+ public function testValidateAndRestoreReportsSuccessInVerboseModeWhenNothingMissing(): void
+ {
+ $this->markAllRequiredFilesPresent();
+ $this->io->expects($this->once())->method('success')
+ ->with('All required Node.js setup files are present.');
+
+ $this->assertTrue($this->validator->validateAndRestore('.', $this->io, true));
+ }
+
+ public function testValidateAndRestoreAutomaticallyRestoresOnlyMissingGeneratedFiles(): void
+ {
+ // Everything present except the generated package-lock.json file. Generated files are
+ // never copied from Magento base (they're only ever produced by npm install), so no
+ // copy() call is expected here.
+ $expectedFiles = ['./package.json', './Gruntfile.js', './grunt-config.json'];
+ $this->fileDriver->method('isExists')->willReturnCallback(
+ static fn (string $path): bool => in_array($path, $expectedFiles, true),
+ );
+ $this->fileDriver->method('isDirectory')->willReturnCallback(
+ static fn (string $path): bool => $path === './node_modules' || $path === 'vendor/magento/magento2-base',
+ );
+ $this->fileDriver->expects($this->never())->method('copy');
+ $this->nodePackageManager->expects($this->never())->method('installNodeModules');
+
+ $this->assertTrue($this->validator->validateAndRestore('.', $this->io, true));
+ }
+
+ public function testValidateAndRestoreInstallsNodeModulesWhenDirectoryMissing(): void
+ {
+ // Every required/generated file present, but node_modules directory missing.
+ $expectedFiles = ['./package.json', './Gruntfile.js', './grunt-config.json', './package-lock.json'];
+ $this->fileDriver->method('isExists')->willReturnCallback(
+ static fn (string $path): bool => in_array($path, $expectedFiles, true),
+ );
+ $this->fileDriver->method('isDirectory')->willReturnCallback(
+ static fn (string $path): bool => $path === 'vendor/magento/magento2-base',
+ );
+ $this->nodePackageManager->expects($this->once())
+ ->method('installNodeModules')
+ ->with('.', $this->io, false)
+ ->willReturn(true);
+
+ $result = $this->validator->validateAndRestore('.', $this->io, false);
+
+ $this->assertTrue($result);
+ }
+
+ public function testValidateAndRestoreReturnsFalseWhenNpmInstallFails(): void
+ {
+ $expectedFiles = ['./package.json', './Gruntfile.js', './grunt-config.json', './package-lock.json'];
+ $this->fileDriver->method('isExists')->willReturnCallback(
+ static fn (string $path): bool => in_array($path, $expectedFiles, true),
+ );
+ $this->fileDriver->method('isDirectory')->willReturnCallback(
+ static fn (string $path): bool => $path === 'vendor/magento/magento2-base',
+ );
+ $this->nodePackageManager->method('installNodeModules')->willReturn(false);
+
+ $this->assertFalse($this->validator->validateAndRestore('.', $this->io, false));
+ }
+
+ // -------------------------------------------------------------------------
+ // Mutation hardening: exact messages and flow boundaries
+ // -------------------------------------------------------------------------
+
+ public function testAllFilesPresentDoesNotEnterRestoreFlow(): void
+ {
+ $this->markAllRequiredFilesPresent();
+ $this->io->expects($this->never())->method('note');
+ $this->io->expects($this->never())->method('warning');
+ $this->io->expects($this->once())->method('success')->with('All required Node.js setup files are present.');
+
+ $this->assertTrue($this->validator->validateAndRestore('.', $this->io, true));
+ }
+
+ public function testVerboseAutoRestoreAnnouncesEachGeneratedItem(): void
+ {
+ // Only generated artifacts missing: no prompt, no source-file copies.
+ $this->fileDriver->method('isExists')->willReturnCallback(
+ static fn(string $path): bool => !str_ends_with($path, 'package-lock.json'),
+ );
+ $this->fileDriver->method('isDirectory')->willReturnCallback(
+ static fn(string $path): bool => !str_ends_with($path, 'node_modules'),
+ );
+ $this->nodePackageManager->method('installNodeModules')->willReturn(true);
+ $this->io->expects($this->never())->method('warning');
+ $this->fileDriver->expects($this->never())->method('copy');
+
+ $notes = [];
+ $this->io
+ ->method('note')
+ ->willReturnCallback(function (string $message) use (&$notes): void {
+ $notes[] = $message;
+ });
+ $lines = [];
+ $this->io
+ ->method('writeln')
+ ->willReturnCallback(function (string $line) use (&$lines): void {
+ $lines[] = $line;
+ });
+ $this->io->expects($this->once())->method('text')->with('Installing Node.js dependencies...');
+
+ $this->assertTrue($this->validator->validateAndRestore('.', $this->io, true));
+ $this->assertSame(
+ [
+ 'Detected missing generated files/directories. Installing automatically...',
+ 'Skipping package-lock.json - will be generated by npm install',
+ 'Skipping node_modules/ - will be generated by npm install',
+ ],
+ $notes,
+ );
+ $this->assertSame([' - package-lock.json', ' - node_modules/'], $lines);
+ }
+
+ public function testFailedNpmInstallReportsExactError(): void
+ {
+ $this->fileDriver->method('isExists')->willReturnCallback(
+ static fn(string $path): bool => !str_ends_with($path, 'package-lock.json'),
+ );
+ $this->fileDriver->method('isDirectory')->willReturnCallback(
+ static fn(string $path): bool => !str_ends_with($path, 'node_modules'),
+ );
+ $this->nodePackageManager->method('installNodeModules')->willReturn(false);
+ $this->io->expects($this->once())->method('error')->with('Failed to install Node.js dependencies.');
+
+ $this->assertFalse($this->validator->validateAndRestore('.', $this->io, false));
+ }
+
+ public function testQuietAutoRestoreStaysSilent(): void
+ {
+ $this->fileDriver->method('isExists')->willReturnCallback(
+ static fn(string $path): bool => !str_ends_with($path, 'package-lock.json'),
+ );
+ $this->fileDriver->method('isDirectory')->willReturnCallback(
+ static fn(string $path): bool => !str_ends_with($path, 'node_modules'),
+ );
+ $this->nodePackageManager->method('installNodeModules')->willReturn(true);
+ $this->io->expects($this->never())->method('note');
+ $this->io->expects($this->never())->method('writeln');
+
+ $this->assertTrue($this->validator->validateAndRestore('.', $this->io, false));
+ }
+}
diff --git a/tests/Unit/Service/StaticContentDeployerTest.php b/tests/Unit/Service/StaticContentDeployerTest.php
index eb39bfaf..66a0257c 100644
--- a/tests/Unit/Service/StaticContentDeployerTest.php
+++ b/tests/Unit/Service/StaticContentDeployerTest.php
@@ -37,6 +37,17 @@ public function testSkipsDeploymentInDeveloperMode(): void
$this->assertTrue($this->deployer->deploy('Vendor/theme', $this->io, $this->output, false));
}
+ public function testReportsSkipInVerboseModeWhenInDeveloperMode(): void
+ {
+ $this->state->method('getMode')->willReturn(State::MODE_DEVELOPER);
+ $this->io
+ ->expects($this->once())
+ ->method('info')
+ ->with('Skipping static content deployment in developer mode.');
+
+ $this->assertTrue($this->deployer->deploy('Vendor/theme', $this->io, $this->output, true));
+ }
+
public function testDeploysThemeInProductionMode(): void
{
$this->state->method('getMode')->willReturn(State::MODE_PRODUCTION);
diff --git a/tests/Unit/Service/SymlinkCleanerTest.php b/tests/Unit/Service/SymlinkCleanerTest.php
index bc81cafa..09cce69a 100644
--- a/tests/Unit/Service/SymlinkCleanerTest.php
+++ b/tests/Unit/Service/SymlinkCleanerTest.php
@@ -109,4 +109,15 @@ public function testSucceedsWithWarningWhenDirectoryCannotBeRead(): void
$this->assertTrue($this->cleaner->cleanSymlinks('/theme', $this->io, true));
}
+
+ public function testReportsBasenameForItemWithoutDirectorySeparator(): void
+ {
+ $this->state->method('getMode')->willReturn(State::MODE_DEVELOPER);
+ $this->fileDriver->method('isDirectory')->willReturn(true);
+ $this->fileDriver->method('readDirectory')->willReturn(['link.css']);
+ $this->fileDriver->method('stat')->willReturn(['mode' => self::SYMLINK_MODE]);
+ $this->io->expects($this->once())->method('writeln')->with(' ⚠> Removed symlink: link.css');
+
+ $this->assertTrue($this->cleaner->cleanSymlinks('/theme', $this->io, true));
+ }
}
diff --git a/tests/Unit/Service/ThemeBuilder/HyvaThemes/BuilderTest.php b/tests/Unit/Service/ThemeBuilder/HyvaThemes/BuilderTest.php
new file mode 100644
index 00000000..e38ea3dc
--- /dev/null
+++ b/tests/Unit/Service/ThemeBuilder/HyvaThemes/BuilderTest.php
@@ -0,0 +1,572 @@
+shell = $this->createMock(Shell::class);
+ $this->fileDriver = $this->createMock(File::class);
+ $this->staticContentDeployer = $this->createMock(StaticContentDeployer::class);
+ $this->staticContentCleaner = $this->createMock(StaticContentCleaner::class);
+ $this->cacheCleaner = $this->createMock(CacheCleaner::class);
+ $this->symlinkCleaner = $this->createMock(SymlinkCleaner::class);
+ $this->nodePackageManager = $this->createMock(NodePackageManager::class);
+ $this->io = $this->createMock(SymfonyStyle::class);
+ $this->output = $this->createMock(OutputInterface::class);
+
+ $this->builder = new Builder(
+ $this->shell,
+ $this->fileDriver,
+ $this->staticContentDeployer,
+ $this->staticContentCleaner,
+ $this->cacheCleaner,
+ $this->symlinkCleaner,
+ $this->nodePackageManager,
+ );
+ }
+
+ public function testGetNameReturnsThemeName(): void
+ {
+ $this->assertSame('HyvaThemes', $this->builder->getName());
+ }
+
+ public function testDetectReturnsFalseWhenTailwindDirectoryMissing(): void
+ {
+ $this->fileDriver->method('isExists')->willReturn(false);
+
+ $this->assertFalse($this->builder->detect($this->themePath));
+ }
+
+ public function testDetectReturnsTrueForHyvaThemeXml(): void
+ {
+ $this->fileDriver->method('isExists')->willReturnMap([
+ [$this->themePath . '/web/tailwind', true],
+ [$this->themePath . '/theme.xml', true],
+ ]);
+ $this->fileDriver->method('fileGetContents')
+ ->with($this->themePath . '/theme.xml')
+ ->willReturn('Hyva');
+
+ $this->assertTrue($this->builder->detect($this->themePath));
+ }
+
+ public function testDetectReturnsFalseForNonHyvaThemeXml(): void
+ {
+ $this->fileDriver->method('isExists')->willReturnMap([
+ [$this->themePath . '/web/tailwind', true],
+ [$this->themePath . '/theme.xml', true],
+ [$this->themePath . '/composer.json', false],
+ ]);
+ $this->fileDriver->method('fileGetContents')
+ ->with($this->themePath . '/theme.xml')
+ ->willReturn('Custom');
+
+ $this->assertFalse($this->builder->detect($this->themePath));
+ }
+
+ public function testDetectReturnsTrueForHyvaComposerJson(): void
+ {
+ $this->fileDriver->method('isExists')->willReturnMap([
+ [$this->themePath . '/web/tailwind', true],
+ [$this->themePath . '/theme.xml', false],
+ [$this->themePath . '/composer.json', true],
+ ]);
+ $this->fileDriver->method('fileGetContents')
+ ->with($this->themePath . '/composer.json')
+ ->willReturn(json_encode(['name' => 'hyva-themes/theme-default']));
+
+ $this->assertTrue($this->builder->detect($this->themePath));
+ }
+
+ public function testDetectReturnsFalseForNonHyvaComposerJson(): void
+ {
+ $this->fileDriver->method('isExists')->willReturnMap([
+ [$this->themePath . '/web/tailwind', true],
+ [$this->themePath . '/theme.xml', false],
+ [$this->themePath . '/composer.json', true],
+ ]);
+ $this->fileDriver->method('fileGetContents')
+ ->with($this->themePath . '/composer.json')
+ ->willReturn(json_encode(['name' => 'vendor/custom-theme']));
+
+ $this->assertFalse($this->builder->detect($this->themePath));
+ }
+
+ private function configureSuccessfulDetection(): void
+ {
+ $this->fileDriver->method('isExists')->willReturnMap([
+ [$this->themePath . '/web/tailwind', true],
+ [$this->themePath . '/theme.xml', true],
+ ]);
+ $this->fileDriver->method('fileGetContents')
+ ->with($this->themePath . '/theme.xml')
+ ->willReturn('Hyva');
+ }
+
+ public function testBuildReturnsFalseWhenDetectFails(): void
+ {
+ $this->fileDriver->method('isExists')->willReturn(false);
+
+ $this->assertFalse($this->builder->build('Vendor/theme', $this->themePath, $this->io, $this->output, false));
+ }
+
+ public function testBuildReturnsFalseWhenStaticContentCleanFails(): void
+ {
+ $this->configureSuccessfulDetection();
+ $this->staticContentCleaner->method('cleanIfNeeded')->willReturn(false);
+
+ $this->assertFalse($this->builder->build('Vendor/theme', $this->themePath, $this->io, $this->output, false));
+ }
+
+ public function testBuildReturnsFalseWhenSymlinkCleanFails(): void
+ {
+ $this->configureSuccessfulDetection();
+ $this->staticContentCleaner->method('cleanIfNeeded')->willReturn(true);
+ $this->nodePackageManager->method('isNodeModulesInSync')->willReturn(true);
+ $this->symlinkCleaner->method('cleanSymlinks')->willReturn(false);
+ $this->shell->expects($this->never())->method('execute');
+
+ $this->assertFalse($this->builder->build('Vendor/theme', $this->themePath, $this->io, $this->output, false));
+ }
+
+ public function testBuildReturnsFalseWhenHyvaConfigGenerationFails(): void
+ {
+ $this->configureSuccessfulDetection();
+ $this->staticContentCleaner->method('cleanIfNeeded')->willReturn(true);
+ $this->nodePackageManager->method('isNodeModulesInSync')->willReturn(true);
+ $this->symlinkCleaner->method('cleanSymlinks')->willReturn(true);
+ $this->shell->method('execute')->willThrowException(new \RuntimeException('generate failed'));
+ $this->io->expects($this->once())->method('error')
+ ->with($this->stringContains('Failed to generate Hyv'));
+
+ $this->assertFalse($this->builder->build('Vendor/theme', $this->themePath, $this->io, $this->output, false));
+ }
+
+ public function testBuildReturnsFalseWhenTailwindDirectoryMissing(): void
+ {
+ $this->configureSuccessfulDetection();
+ $this->staticContentCleaner->method('cleanIfNeeded')->willReturn(true);
+ $this->nodePackageManager->method('isNodeModulesInSync')->willReturn(true);
+ $this->symlinkCleaner->method('cleanSymlinks')->willReturn(true);
+ $this->shell->method('execute')->willReturn('');
+ $this->fileDriver->expects($this->once())
+ ->method('isDirectory')
+ ->with($this->themePath . '/web/tailwind')
+ ->willReturn(false);
+ $this->io->expects($this->once())
+ ->method('error')
+ ->with('Tailwind directory not found in: ' . $this->themePath . '/web/tailwind');
+
+ $this->assertFalse($this->builder->build('Vendor/theme', $this->themePath, $this->io, $this->output, false));
+ }
+
+ public function testBuildHandlesThemeBuildException(): void
+ {
+ $this->configureSuccessfulDetection();
+ $this->staticContentCleaner->method('cleanIfNeeded')->willReturn(true);
+ $this->nodePackageManager->method('isNodeModulesInSync')->willReturn(true);
+ $this->symlinkCleaner->method('cleanSymlinks')->willReturn(true);
+ $this->fileDriver->method('isDirectory')->willReturn(true);
+
+ $callCount = 0;
+ $this->shell->method('execute')->willReturnCallback(function () use (&$callCount) {
+ $callCount++;
+ if ($callCount === 2) {
+ throw new \RuntimeException('build failed');
+ }
+ return '';
+ });
+ $this->io->expects($this->once())->method('error')
+ ->with($this->stringContains('Failed to build Hyv'));
+
+ $this->assertFalse($this->builder->build('Vendor/theme', $this->themePath, $this->io, $this->output, false));
+ }
+
+ public function testBuildRunsFullPipelineSuccessfully(): void
+ {
+ $this->configureSuccessfulDetection();
+ $this->staticContentCleaner->method('cleanIfNeeded')->willReturn(true);
+ $this->nodePackageManager->method('isNodeModulesInSync')->willReturn(true);
+ $this->symlinkCleaner->method('cleanSymlinks')->willReturn(true);
+ $this->fileDriver->expects($this->once())
+ ->method('isDirectory')
+ ->with($this->themePath . '/web/tailwind')
+ ->willReturn(true);
+ $executedCommands = [];
+ $this->shell->expects($this->exactly(2))
+ ->method('execute')
+ ->willReturnCallback(function (string $command, array $args = []) use (&$executedCommands): string {
+ $executedCommands[] = [$command, $args];
+ return '';
+ });
+ $this->staticContentDeployer->method('deploy')->willReturn(true);
+ $this->cacheCleaner->method('clean')->willReturn(true);
+ $successMessages = [];
+ $this->io->method('success')->willReturnCallback(function (string $message) use (&$successMessages): void {
+ $successMessages[] = $message;
+ });
+ $textMessages = [];
+ $this->io->method('text')->willReturnCallback(function (string $message) use (&$textMessages): void {
+ $textMessages[] = $message;
+ });
+
+ $this->assertTrue($this->builder->build('Vendor/theme', $this->themePath, $this->io, $this->output, true));
+ $this->assertSame(
+ [
+ ['bin/magento hyva:config:generate', []],
+ ['cd %s && npm run build', [$this->themePath . '/web/tailwind']],
+ ],
+ $executedCommands,
+ );
+ $this->assertSame(['Generating Hyvä configuration...', 'Running npm build...'], $textMessages);
+ $this->assertSame(
+ ['Hyvä configuration generated successfully.', 'Hyvä theme build completed successfully.'],
+ $successMessages,
+ );
+ }
+
+ public function testBuildReturnsFalseWhenDeployFails(): void
+ {
+ $this->configureSuccessfulDetection();
+ $this->staticContentCleaner->method('cleanIfNeeded')->willReturn(true);
+ $this->nodePackageManager->method('isNodeModulesInSync')->willReturn(true);
+ $this->symlinkCleaner->method('cleanSymlinks')->willReturn(true);
+ $this->fileDriver->method('isDirectory')->willReturn(true);
+ $this->staticContentDeployer->method('deploy')->willReturn(false);
+ $this->cacheCleaner->expects($this->never())->method('clean');
+
+ $this->assertFalse($this->builder->build('Vendor/theme', $this->themePath, $this->io, $this->output, false));
+ }
+
+ public function testAutoRepairInstallsWhenOutOfSync(): void
+ {
+ $this->nodePackageManager->method('isNodeModulesInSync')->willReturn(false);
+ $this->nodePackageManager->expects($this->once())->method('installNodeModules')->willReturn(true);
+
+ $this->assertTrue($this->builder->autoRepair($this->themePath, $this->io, $this->output, true));
+ }
+
+ public function testAutoRepairReturnsFalseWhenInstallFails(): void
+ {
+ $this->nodePackageManager->method('isNodeModulesInSync')->willReturn(false);
+ $this->nodePackageManager->method('installNodeModules')->willReturn(false);
+
+ $this->assertFalse($this->builder->autoRepair($this->themePath, $this->io, $this->output, false));
+ }
+
+ public function testWatchReturnsFalseWhenDetectFails(): void
+ {
+ $this->fileDriver->method('isExists')->willReturn(false);
+
+ $this->assertFalse($this->builder->watch('Vendor/theme', $this->themePath, $this->io, $this->output, false));
+ }
+
+ public function testWatchReturnsFalseWhenStaticContentCleanFails(): void
+ {
+ $this->configureSuccessfulDetection();
+ $this->staticContentCleaner->method('cleanIfNeeded')->willReturn(false);
+
+ $this->assertFalse($this->builder->watch('Vendor/theme', $this->themePath, $this->io, $this->output, false));
+ }
+
+ public function testWatchReturnsFalseWhenAutoRepairFails(): void
+ {
+ $this->configureSuccessfulDetection();
+ $this->staticContentCleaner->method('cleanIfNeeded')->willReturn(true);
+ $this->nodePackageManager->method('isNodeModulesInSync')->willReturn(false);
+ $this->nodePackageManager->method('installNodeModules')->willReturn(false);
+
+ $this->assertFalse($this->builder->watch('Vendor/theme', $this->themePath, $this->io, $this->output, false));
+ }
+
+ public function testWatchReturnsFalseWhenTailwindDirectoryMissing(): void
+ {
+ $this->configureSuccessfulDetection();
+ $this->staticContentCleaner->method('cleanIfNeeded')->willReturn(true);
+ $this->nodePackageManager->method('isNodeModulesInSync')->willReturn(true);
+ $this->fileDriver->method('isDirectory')->willReturn(false);
+
+ $this->assertFalse($this->builder->watch('Vendor/theme', $this->themePath, $this->io, $this->output, false));
+ }
+
+ // -------------------------------------------------------------------------
+ // Mutation hardening: exact messages, commands and branch boundaries
+ // -------------------------------------------------------------------------
+
+ public function testDetectMatchesHyvaCaseInsensitivelyAndNormalizesSlash(): void
+ {
+ $this->fileDriver->method('isExists')->willReturnMap([
+ [$this->themePath . '/web/tailwind', true],
+ [$this->themePath . '/theme.xml', true],
+ ]);
+ $this->fileDriver->method('fileGetContents')->willReturn('HYVA Default');
+
+ $this->assertTrue($this->builder->detect($this->themePath . '/'));
+ }
+
+ public function testGeneratesHyvaConfigWithExactCommandAndVerboseMessages(): void
+ {
+ $this->configureSuccessfulDetection();
+ $this->givenPipelineUpToConfigGeneration();
+
+ $executedCommands = [];
+ $this->shell
+ ->method('execute')
+ ->willReturnCallback(function (string $command) use (&$executedCommands): string {
+ $executedCommands[] = $command;
+ return '';
+ });
+ $this->fileDriver->method('isDirectory')->willReturn(true);
+ $this->staticContentDeployer->method('deploy')->willReturn(true);
+ $this->cacheCleaner->method('clean')->willReturn(true);
+
+ $texts = [];
+ $this->io
+ ->method('text')
+ ->willReturnCallback(function (string $message) use (&$texts): void {
+ $texts[] = $message;
+ });
+ $successes = [];
+ $this->io
+ ->method('success')
+ ->willReturnCallback(function (string $message) use (&$successes): void {
+ $successes[] = $message;
+ });
+
+ $this->assertTrue($this->builder->build('Vendor/theme', $this->themePath, $this->io, $this->output, true));
+ $this->assertSame('bin/magento hyva:config:generate', $executedCommands[0]);
+ $this->assertSame('cd %s && npm run build', $executedCommands[1]);
+ $this->assertSame(['Generating Hyvä configuration...', 'Running npm build...'], $texts);
+ $this->assertSame(
+ ['Hyvä configuration generated successfully.', 'Hyvä theme build completed successfully.'],
+ $successes,
+ );
+ }
+
+ public function testQuietBuildUsesQuietNpmCommandAndStaysSilent(): void
+ {
+ $this->configureSuccessfulDetection();
+ $this->givenPipelineUpToConfigGeneration();
+
+ $executedCommands = [];
+ $this->shell
+ ->method('execute')
+ ->willReturnCallback(function (string $command) use (&$executedCommands): string {
+ $executedCommands[] = $command;
+ return '';
+ });
+ $this->fileDriver->method('isDirectory')->willReturn(true);
+ $this->staticContentDeployer->method('deploy')->willReturn(true);
+ $this->cacheCleaner->method('clean')->willReturn(true);
+ $this->io->expects($this->never())->method('text');
+ $this->io->expects($this->never())->method('success');
+
+ $this->assertTrue($this->builder->build('Vendor/theme', $this->themePath, $this->io, $this->output, false));
+ $this->assertSame('cd %s && npm run build --quiet', $executedCommands[1]);
+ }
+
+ public function testConfigGenerationFailureReportsExactError(): void
+ {
+ $this->configureSuccessfulDetection();
+ $this->givenPipelineUpToConfigGeneration();
+ $this->shell->method('execute')->willThrowException(new \RuntimeException('config error'));
+ $this->io
+ ->expects($this->once())
+ ->method('error')
+ ->with('Failed to generate Hyvä configuration: config error');
+
+ $this->assertFalse($this->builder->build('Vendor/theme', $this->themePath, $this->io, $this->output, false));
+ }
+
+ public function testMissingTailwindDirectoryReportsExactError(): void
+ {
+ $this->configureSuccessfulDetection();
+ $this->givenPipelineUpToConfigGeneration();
+ $this->fileDriver->method('isDirectory')->willReturn(false);
+ $this->io
+ ->expects($this->once())
+ ->method('error')
+ ->with('Tailwind directory not found in: ' . $this->themePath . '/web/tailwind');
+
+ $this->assertFalse($this->builder->build('Vendor/theme', $this->themePath, $this->io, $this->output, false));
+ }
+
+ public function testNpmBuildFailureReportsExactError(): void
+ {
+ $this->configureSuccessfulDetection();
+ $this->givenPipelineUpToConfigGeneration();
+ $this->fileDriver->method('isDirectory')->willReturn(true);
+ $this->shell
+ ->method('execute')
+ ->willReturnCallback(static function (string $command): string {
+ if (str_contains($command, 'npm run build')) {
+ throw new \RuntimeException('npm exploded');
+ }
+ return '';
+ });
+ $this->io->expects($this->once())->method('error')->with('Failed to build Hyvä theme: npm exploded');
+
+ $this->assertFalse($this->builder->build('Vendor/theme', $this->themePath, $this->io, $this->output, false));
+ }
+
+ public function testAutoRepairUsesTailwindPathAndExactWarning(): void
+ {
+ $this->nodePackageManager
+ ->expects($this->once())
+ ->method('isNodeModulesInSync')
+ ->with($this->themePath . '/web/tailwind')
+ ->willReturn(false);
+ $this->nodePackageManager
+ ->expects($this->once())
+ ->method('installNodeModules')
+ ->with($this->themePath . '/web/tailwind')
+ ->willReturn(true);
+ $this->io
+ ->expects($this->once())
+ ->method('warning')
+ ->with('Node modules out of sync or missing. Installing dependencies...');
+
+ $this->assertTrue($this->builder->autoRepair($this->themePath . '/', $this->io, $this->output, true));
+ }
+
+ public function testAutoRepairChecksOutdatedPackagesOnlyInVerboseMode(): void
+ {
+ $this->nodePackageManager->method('isNodeModulesInSync')->willReturn(true);
+ $this->nodePackageManager
+ ->expects($this->once())
+ ->method('checkOutdatedPackages')
+ ->with($this->themePath . '/web/tailwind');
+
+ $this->assertTrue($this->builder->autoRepair($this->themePath, $this->io, $this->output, true));
+ }
+
+ public function testQuietAutoRepairSkipsOutdatedCheckAndWarning(): void
+ {
+ $this->nodePackageManager->method('isNodeModulesInSync')->willReturn(false);
+ $this->nodePackageManager->method('installNodeModules')->willReturn(true);
+ $this->nodePackageManager->expects($this->never())->method('checkOutdatedPackages');
+ $this->io->expects($this->never())->method('warning');
+
+ $this->assertTrue($this->builder->autoRepair($this->themePath, $this->io, $this->output, false));
+ }
+
+ /**
+ * Static content and symlink cleaning succeed so the build reaches config generation.
+ */
+ private function givenPipelineUpToConfigGeneration(): void
+ {
+ $this->staticContentCleaner->method('cleanIfNeeded')->willReturn(true);
+ $this->nodePackageManager->method('isNodeModulesInSync')->willReturn(true);
+ $this->symlinkCleaner->method('cleanSymlinks')->willReturn(true);
+ }
+
+ public function testDetectRequiresTailwindDirectoryEvenForHyvaThemeXml(): void
+ {
+ $this->fileDriver->method('isExists')->willReturnMap([
+ [$this->themePath . '/web/tailwind', false],
+ [$this->themePath . '/theme.xml', true],
+ ]);
+ $this->fileDriver->method('fileGetContents')->willReturn('Hyva');
+
+ $this->assertFalse($this->builder->detect($this->themePath));
+ }
+
+ public function testBuildStopsBeforeCleaningWhenDetectFails(): void
+ {
+ $this->fileDriver->method('isExists')->willReturn(false);
+ $this->staticContentCleaner->expects($this->never())->method('cleanIfNeeded');
+ $this->nodePackageManager->expects($this->never())->method('isNodeModulesInSync');
+
+ $this->assertFalse($this->builder->build('Vendor/theme', $this->themePath, $this->io, $this->output, false));
+ }
+
+ public function testMissingTailwindDirectorySkipsNpmBuild(): void
+ {
+ $this->configureSuccessfulDetection();
+ $this->givenPipelineUpToConfigGeneration();
+ $this->fileDriver->method('isDirectory')->willReturn(false);
+ $executedCommands = [];
+ $this->shell
+ ->method('execute')
+ ->willReturnCallback(function (string $command) use (&$executedCommands): string {
+ $executedCommands[] = $command;
+ return '';
+ });
+ $this->staticContentDeployer->expects($this->never())->method('deploy');
+
+ $this->assertFalse($this->builder->build('Vendor/theme', $this->themePath, $this->io, $this->output, false));
+ $this->assertSame(['bin/magento hyva:config:generate'], $executedCommands, 'npm build must not run');
+ }
+
+ public function testWatchStopsBeforeCleaningWhenDetectFails(): void
+ {
+ $this->fileDriver->method('isExists')->willReturn(false);
+ $this->staticContentCleaner->expects($this->never())->method('cleanIfNeeded');
+
+ $this->assertFalse($this->builder->watch('Vendor/theme', $this->themePath, $this->io, $this->output, false));
+ }
+
+ public function testWatchStopsBeforeAutoRepairWhenCleaningFails(): void
+ {
+ $this->configureSuccessfulDetection();
+ $this->staticContentCleaner->method('cleanIfNeeded')->willReturn(false);
+ $this->nodePackageManager->expects($this->never())->method('isNodeModulesInSync');
+
+ $this->assertFalse($this->builder->watch('Vendor/theme', $this->themePath, $this->io, $this->output, false));
+ }
+
+ public function testWatchStopsBeforeDirectoryCheckWhenAutoRepairFails(): void
+ {
+ $this->configureSuccessfulDetection();
+ $this->staticContentCleaner->method('cleanIfNeeded')->willReturn(true);
+ $this->nodePackageManager->method('isNodeModulesInSync')->willReturn(false);
+ $this->nodePackageManager->method('installNodeModules')->willReturn(false);
+ $this->fileDriver->expects($this->never())->method('isDirectory');
+
+ $this->assertFalse($this->builder->watch('Vendor/theme', $this->themePath, $this->io, $this->output, false));
+ }
+
+ public function testWatchReportsExactErrorForMissingTailwindDirectory(): void
+ {
+ $this->configureSuccessfulDetection();
+ $this->staticContentCleaner->method('cleanIfNeeded')->willReturn(true);
+ $this->nodePackageManager->method('isNodeModulesInSync')->willReturn(true);
+ $this->fileDriver->method('isDirectory')->willReturn(false);
+ $this->io
+ ->expects($this->once())
+ ->method('error')
+ ->with('Tailwind directory not found in: ' . $this->themePath . '/web/tailwind');
+ $this->io->expects($this->never())->method('text');
+
+ $this->assertFalse($this->builder->watch('Vendor/theme', $this->themePath . '/', $this->io, $this->output, false));
+ }
+}
diff --git a/tests/Unit/Service/ThemeBuilder/MagentoStandard/BuilderTest.php b/tests/Unit/Service/ThemeBuilder/MagentoStandard/BuilderTest.php
new file mode 100644
index 00000000..687e0009
--- /dev/null
+++ b/tests/Unit/Service/ThemeBuilder/MagentoStandard/BuilderTest.php
@@ -0,0 +1,515 @@
+shell = $this->createMock(Shell::class);
+ $this->fileDriver = $this->createMock(File::class);
+ $this->staticContentDeployer = $this->createMock(StaticContentDeployer::class);
+ $this->staticContentCleaner = $this->createMock(StaticContentCleaner::class);
+ $this->cacheCleaner = $this->createMock(CacheCleaner::class);
+ $this->symlinkCleaner = $this->createMock(SymlinkCleaner::class);
+ $this->nodePackageManager = $this->createMock(NodePackageManager::class);
+ $this->gruntTaskRunner = $this->createMock(GruntTaskRunner::class);
+ $this->io = $this->createMock(SymfonyStyle::class);
+ $this->output = $this->createMock(OutputInterface::class);
+
+ $this->builder = new Builder(
+ $this->shell,
+ $this->fileDriver,
+ $this->staticContentDeployer,
+ $this->staticContentCleaner,
+ $this->cacheCleaner,
+ $this->symlinkCleaner,
+ $this->nodePackageManager,
+ $this->gruntTaskRunner,
+ );
+ }
+
+ public function testGetNameReturnsThemeName(): void
+ {
+ $this->assertSame('MagentoStandard', $this->builder->getName());
+ }
+
+ public function testDetectReturnsTrueForStandardTheme(): void
+ {
+ $this->fileDriver->method('isExists')->willReturnMap([
+ [$this->themePath . '/theme.xml', true],
+ [$this->themePath . '/web/tailwind', false],
+ ]);
+
+ $this->assertTrue($this->builder->detect($this->themePath));
+ }
+
+ public function testDetectReturnsFalseWhenThemeXmlMissing(): void
+ {
+ $this->fileDriver->method('isExists')->willReturnMap([
+ [$this->themePath . '/theme.xml', false],
+ [$this->themePath . '/web/tailwind', false],
+ ]);
+
+ $this->assertFalse($this->builder->detect($this->themePath));
+ }
+
+ public function testDetectReturnsFalseWhenTailwindDirectoryPresent(): void
+ {
+ $this->fileDriver->method('isExists')->willReturnMap([
+ [$this->themePath . '/theme.xml', true],
+ [$this->themePath . '/web/tailwind', true],
+ ]);
+
+ $this->assertFalse($this->builder->detect($this->themePath));
+ }
+
+ private function configureSuccessfulDetection(): void
+ {
+ $this->fileDriver->method('isExists')->willReturnMap([
+ [$this->themePath . '/theme.xml', true],
+ [$this->themePath . '/web/tailwind', false],
+ ]);
+ }
+
+ public function testBuildReturnsFalseWhenDetectFails(): void
+ {
+ $this->fileDriver->method('isExists')->willReturn(false);
+
+ $this->assertFalse($this->builder->build('Vendor/theme', $this->themePath, $this->io, $this->output, false));
+ }
+
+ public function testBuildReturnsFalseWhenStaticContentCleanFails(): void
+ {
+ $this->configureSuccessfulDetection();
+ $this->staticContentCleaner->method('cleanIfNeeded')->willReturn(false);
+
+ $this->assertFalse($this->builder->build('Vendor/theme', $this->themePath, $this->io, $this->output, false));
+ }
+
+ public function testBuildSkipsGruntStepsForVendorTheme(): void
+ {
+ $vendorThemePath = 'root/vendor/some-vendor/theme';
+ $this->fileDriver->method('isExists')->willReturnMap([
+ [$vendorThemePath . '/theme.xml', true],
+ [$vendorThemePath . '/web/tailwind', false],
+ ]);
+ $this->staticContentCleaner->method('cleanIfNeeded')->willReturn(true);
+ $this->io->expects($this->once())->method('warning')
+ ->with('Vendor theme detected. Skipping Grunt steps.');
+ $this->nodePackageManager->expects($this->never())->method('isNodeModulesInSync');
+ $this->staticContentDeployer->method('deploy')->willReturn(true);
+ $this->cacheCleaner->method('clean')->willReturn(true);
+
+ $this->assertTrue(
+ $this->builder->build('Vendor/theme', $vendorThemePath, $this->io, $this->output, false),
+ );
+ }
+
+ public function testBuildSkipsGruntStepsWhenNoNodeSetupDetected(): void
+ {
+ $this->configureSuccessfulDetection();
+ $this->staticContentCleaner->method('cleanIfNeeded')->willReturn(true);
+ $this->fileDriver->method('isExists')->willReturnMap([
+ [$this->themePath . '/theme.xml', true],
+ [$this->themePath . '/web/tailwind', false],
+ ['./package.json', false],
+ ['./package-lock.json', false],
+ ['./gruntfile.js', false],
+ ['./grunt-config.json', false],
+ ]);
+ $this->nodePackageManager->expects($this->never())->method('isNodeModulesInSync');
+ $this->staticContentDeployer->method('deploy')->willReturn(true);
+ $this->cacheCleaner->method('clean')->willReturn(true);
+
+ $this->assertTrue($this->builder->build('Vendor/theme', $this->themePath, $this->io, $this->output, true));
+ }
+
+ public function testBuildRunsNodeSetupWhenDetected(): void
+ {
+ $this->fileDriver->method('isExists')->willReturnMap([
+ [$this->themePath . '/theme.xml', true],
+ [$this->themePath . '/web/tailwind', false],
+ ['./package.json', true],
+ ]);
+ $this->staticContentCleaner->method('cleanIfNeeded')->willReturn(true);
+ $this->nodePackageManager->method('isNodeModulesInSync')->willReturn(true);
+ $executedCommands = [];
+ $this->shell->method('execute')->willReturnCallback(function (string $command) use (&$executedCommands) {
+ $executedCommands[] = $command;
+ return '';
+ });
+ $this->symlinkCleaner->expects($this->once())
+ ->method('cleanSymlinks')
+ ->with($this->themePath, $this->io, false)
+ ->willReturn(true);
+ $this->gruntTaskRunner->expects($this->once())
+ ->method('runTasks')
+ ->with($this->io, $this->output, false)
+ ->willReturn(true);
+ $this->staticContentDeployer->method('deploy')->willReturn(true);
+ $this->cacheCleaner->method('clean')->willReturn(true);
+
+ $this->assertTrue($this->builder->build('Vendor/theme', $this->themePath, $this->io, $this->output, false));
+ $this->assertSame(['which grunt'], $executedCommands);
+ }
+
+ public function testBuildReturnsFalseWhenNodeSetupProcessingFails(): void
+ {
+ $this->fileDriver->method('isExists')->willReturnMap([
+ [$this->themePath . '/theme.xml', true],
+ [$this->themePath . '/web/tailwind', false],
+ ['./package.json', true],
+ ]);
+ $this->staticContentCleaner->method('cleanIfNeeded')->willReturn(true);
+ $this->nodePackageManager->method('isNodeModulesInSync')->willReturn(true);
+ $this->shell->method('execute')->willReturn('');
+ $this->symlinkCleaner->method('cleanSymlinks')->willReturn(false);
+
+ $this->assertFalse($this->builder->build('Vendor/theme', $this->themePath, $this->io, $this->output, false));
+ }
+
+ public function testBuildReturnsFalseWhenDeployFails(): void
+ {
+ $vendorThemePath = 'root/vendor/some-vendor/theme';
+ $this->fileDriver->method('isExists')->willReturnMap([
+ [$vendorThemePath . '/theme.xml', true],
+ [$vendorThemePath . '/web/tailwind', false],
+ ]);
+ $this->staticContentCleaner->method('cleanIfNeeded')->willReturn(true);
+ $this->staticContentDeployer->method('deploy')->willReturn(false);
+ $this->cacheCleaner->expects($this->never())->method('clean');
+
+ $this->assertFalse(
+ $this->builder->build('Vendor/theme', $vendorThemePath, $this->io, $this->output, false),
+ );
+ }
+
+ public function testAutoRepairInstallsWhenOutOfSync(): void
+ {
+ $this->nodePackageManager->method('isNodeModulesInSync')->willReturn(false);
+ $this->nodePackageManager->expects($this->once())->method('installNodeModules')->willReturn(true);
+ $this->shell->method('execute')->willReturn('');
+
+ $this->assertTrue($this->builder->autoRepair($this->themePath, $this->io, $this->output, true));
+ }
+
+ public function testAutoRepairReturnsFalseWhenInstallFails(): void
+ {
+ $this->nodePackageManager->method('isNodeModulesInSync')->willReturn(false);
+ $this->nodePackageManager->method('installNodeModules')->willReturn(false);
+
+ $this->assertFalse($this->builder->autoRepair($this->themePath, $this->io, $this->output, false));
+ }
+
+ public function testAutoRepairInstallsGruntWhenMissing(): void
+ {
+ $this->nodePackageManager->method('isNodeModulesInSync')->willReturn(true);
+ $callCount = 0;
+ $this->shell->method('execute')->willReturnCallback(function (string $command) use (&$callCount) {
+ $callCount++;
+ if ($command === 'which grunt') {
+ throw new \RuntimeException('not found');
+ }
+ return '';
+ });
+ $this->io->expects($this->atLeastOnce())->method('success');
+
+ $this->assertTrue($this->builder->autoRepair($this->themePath, $this->io, $this->output, true));
+ }
+
+ public function testAutoRepairReturnsFalseWhenGruntInstallFails(): void
+ {
+ $this->nodePackageManager->method('isNodeModulesInSync')->willReturn(true);
+ $this->shell->method('execute')->willThrowException(new \RuntimeException('failed'));
+
+ $this->assertFalse($this->builder->autoRepair($this->themePath, $this->io, $this->output, false));
+ }
+
+ public function testWatchReturnsFalseWhenDetectFails(): void
+ {
+ $this->fileDriver->method('isExists')->willReturn(false);
+
+ $this->assertFalse($this->builder->watch('Vendor/theme', $this->themePath, $this->io, $this->output, false));
+ }
+
+ public function testWatchReturnsFalseForVendorTheme(): void
+ {
+ $vendorThemePath = 'root/vendor/some-vendor/theme';
+ $this->fileDriver->method('isExists')->willReturnMap([
+ [$vendorThemePath . '/theme.xml', true],
+ [$vendorThemePath . '/web/tailwind', false],
+ ]);
+
+ $this->assertFalse($this->builder->watch('Vendor/theme', $vendorThemePath, $this->io, $this->output, false));
+ }
+
+ public function testWatchReturnsFalseWhenNoNodeSetupDetected(): void
+ {
+ $this->fileDriver->method('isExists')->willReturnMap([
+ [$this->themePath . '/theme.xml', true],
+ [$this->themePath . '/web/tailwind', false],
+ ['./package.json', false],
+ ['./package-lock.json', false],
+ ['./gruntfile.js', false],
+ ['./grunt-config.json', false],
+ ]);
+
+ $this->assertFalse($this->builder->watch('Vendor/theme', $this->themePath, $this->io, $this->output, false));
+ }
+
+ public function testWatchReturnsFalseWhenStaticContentCleanFails(): void
+ {
+ $this->fileDriver->method('isExists')->willReturnMap([
+ [$this->themePath . '/theme.xml', true],
+ [$this->themePath . '/web/tailwind', false],
+ ['./package.json', true],
+ ]);
+ $this->staticContentCleaner->method('cleanIfNeeded')->willReturn(false);
+
+ $this->assertFalse($this->builder->watch('Vendor/theme', $this->themePath, $this->io, $this->output, false));
+ }
+
+ public function testWatchReturnsFalseWhenAutoRepairFails(): void
+ {
+ $this->fileDriver->method('isExists')->willReturnMap([
+ [$this->themePath . '/theme.xml', true],
+ [$this->themePath . '/web/tailwind', false],
+ ['./package.json', true],
+ ]);
+ $this->staticContentCleaner->method('cleanIfNeeded')->willReturn(true);
+ $this->nodePackageManager->method('isNodeModulesInSync')->willReturn(false);
+ $this->nodePackageManager->method('installNodeModules')->willReturn(false);
+
+ $this->assertFalse($this->builder->watch('Vendor/theme', $this->themePath, $this->io, $this->output, false));
+ }
+
+ // -------------------------------------------------------------------------
+ // Mutation hardening: exact messages, call counts and branch boundaries
+ // -------------------------------------------------------------------------
+
+ public function testDetectNormalizesTrailingSlash(): void
+ {
+ $this->fileDriver->method('isExists')->willReturnMap([
+ [$this->themePath . '/theme.xml', true],
+ [$this->themePath . '/web/tailwind', false],
+ ]);
+
+ $this->assertTrue($this->builder->detect($this->themePath . '/'));
+ }
+
+ public function testBuildStopsBeforeCleaningWhenDetectFails(): void
+ {
+ $this->fileDriver->method('isExists')->willReturn(false);
+ $this->staticContentCleaner->expects($this->never())->method('cleanIfNeeded');
+
+ $this->assertFalse($this->builder->build('Vendor/theme', $this->themePath, $this->io, $this->output, false));
+ }
+
+ public function testVendorThemeSkipsGruntStepsWithExactWarning(): void
+ {
+ $vendorPath = '/app/vendor/vendor-name/theme';
+ $this->givenFilesystem($vendorPath, nodeSetup: true);
+ $this->staticContentCleaner->method('cleanIfNeeded')->willReturn(true);
+ $this->staticContentDeployer->method('deploy')->willReturn(true);
+ $this->cacheCleaner->method('clean')->willReturn(true);
+ $this->io->expects($this->once())->method('warning')->with('Vendor theme detected. Skipping Grunt steps.');
+ $this->io->expects($this->once())->method('newLine')->with(2);
+ $this->gruntTaskRunner->expects($this->never())->method('runTasks');
+ $this->nodePackageManager->expects($this->never())->method('isNodeModulesInSync');
+
+ $this->assertTrue($this->builder->build('Vendor/theme', $vendorPath, $this->io, $this->output, false));
+ }
+
+ public function testMissingNodeSetupIsAnnouncedOnlyInVerboseMode(): void
+ {
+ $this->givenFilesystem($this->themePath, nodeSetup: false);
+ $this->staticContentCleaner->method('cleanIfNeeded')->willReturn(true);
+ $this->staticContentDeployer->method('deploy')->willReturn(true);
+ $this->cacheCleaner->method('clean')->willReturn(true);
+ $this->gruntTaskRunner->expects($this->never())->method('runTasks');
+ $this->io->expects($this->once())->method('note')->with('No Node.js/Grunt setup detected. Skipping Grunt steps.');
+
+ $this->assertTrue($this->builder->build('Vendor/theme', $this->themePath, $this->io, $this->output, true));
+ }
+
+ public function testMissingNodeSetupStaysSilentWhenNotVerbose(): void
+ {
+ $this->givenFilesystem($this->themePath, nodeSetup: false);
+ $this->staticContentCleaner->method('cleanIfNeeded')->willReturn(true);
+ $this->staticContentDeployer->method('deploy')->willReturn(true);
+ $this->cacheCleaner->method('clean')->willReturn(true);
+ $this->io->expects($this->never())->method('note');
+
+ $this->assertTrue($this->builder->build('Vendor/theme', $this->themePath, $this->io, $this->output, false));
+ }
+
+ public function testBuildFailsWhenSymlinkCleaningFails(): void
+ {
+ $this->givenFilesystem($this->themePath, nodeSetup: true);
+ $this->staticContentCleaner->method('cleanIfNeeded')->willReturn(true);
+ $this->nodePackageManager->method('isNodeModulesInSync')->willReturn(true);
+ $this->symlinkCleaner->method('cleanSymlinks')->willReturn(false);
+ $this->gruntTaskRunner->expects($this->never())->method('runTasks');
+ $this->staticContentDeployer->expects($this->never())->method('deploy');
+
+ $this->assertFalse($this->builder->build('Vendor/theme', $this->themePath, $this->io, $this->output, false));
+ }
+
+ public function testAutoRepairInstallsWithExactWarningWhenOutOfSync(): void
+ {
+ $this->nodePackageManager->method('isNodeModulesInSync')->with('.')->willReturn(false);
+ $this->nodePackageManager->expects($this->once())->method('installNodeModules')->willReturn(true);
+ $this->io
+ ->expects($this->once())
+ ->method('warning')
+ ->with('Node modules out of sync, missing, or no lock file found. Installing...');
+
+ $this->assertTrue($this->builder->autoRepair($this->themePath, $this->io, $this->output, true));
+ }
+
+ public function testAutoRepairSkipsInstallAndOutdatedCheckWhenQuietAndInSync(): void
+ {
+ $this->nodePackageManager->method('isNodeModulesInSync')->willReturn(true);
+ $this->nodePackageManager->expects($this->never())->method('installNodeModules');
+
+ $executedCommands = [];
+ $this->shell
+ ->method('execute')
+ ->willReturnCallback(function (string $command) use (&$executedCommands): string {
+ $executedCommands[] = $command;
+ return '';
+ });
+
+ $this->assertTrue($this->builder->autoRepair($this->themePath, $this->io, $this->output, false));
+ $this->assertSame(['which grunt'], $executedCommands, 'Outdated check must not run in quiet mode');
+ }
+
+ public function testAutoRepairChecksOutdatedPackagesInVerboseMode(): void
+ {
+ $this->nodePackageManager->method('isNodeModulesInSync')->willReturn(true);
+
+ $executedCommands = [];
+ $this->shell
+ ->method('execute')
+ ->willReturnCallback(function (string $command) use (&$executedCommands): string {
+ $executedCommands[] = $command;
+ return '';
+ });
+
+ $this->assertTrue($this->builder->autoRepair($this->themePath, $this->io, $this->output, true));
+ $this->assertSame(['which grunt', 'npm outdated --json'], $executedCommands);
+ }
+
+ public function testInstallsGruntWithExactMessagesWhenMissing(): void
+ {
+ $this->nodePackageManager->method('isNodeModulesInSync')->willReturn(true);
+ $this->shell
+ ->method('execute')
+ ->willReturnCallback(static function (string $command): string {
+ if ($command === 'which grunt') {
+ throw new \RuntimeException('not found');
+ }
+ return '';
+ });
+ $this->io->expects($this->once())->method('warning')->with('Grunt not found globally. Installing grunt...');
+ $this->io->expects($this->once())->method('success')->with('Grunt installed successfully.');
+
+ $this->assertTrue($this->builder->autoRepair($this->themePath, $this->io, $this->output, true));
+ }
+
+ public function testGruntInstallFailureReportsExactError(): void
+ {
+ $this->nodePackageManager->method('isNodeModulesInSync')->willReturn(true);
+ $this->shell->method('execute')->willThrowException(new \RuntimeException('registry down'));
+ $this->io->expects($this->once())->method('error')->with('Failed to install grunt: registry down');
+
+ $this->assertFalse($this->builder->autoRepair($this->themePath, $this->io, $this->output, false));
+ }
+
+ public function testWatchStopsBeforeCleaningWhenDetectFails(): void
+ {
+ $this->fileDriver->method('isExists')->willReturn(false);
+ $this->staticContentCleaner->expects($this->never())->method('cleanIfNeeded');
+
+ $this->assertFalse($this->builder->watch('Vendor/theme', $this->themePath, $this->io, $this->output, false));
+ }
+
+ public function testWatchRejectsVendorThemesWithExactError(): void
+ {
+ $vendorPath = '/app/vendor/vendor-name/theme';
+ $this->givenFilesystem($vendorPath, nodeSetup: true);
+ $this->io
+ ->expects($this->once())
+ ->method('error')
+ ->with(
+ 'Watch mode is not supported for vendor themes. Vendor themes are read-only and '
+ . 'should have pre-built assets.',
+ );
+ $this->staticContentCleaner->expects($this->never())->method('cleanIfNeeded');
+
+ $this->assertFalse($this->builder->watch('Vendor/theme', $vendorPath, $this->io, $this->output, false));
+ }
+
+ public function testWatchRequiresNodeSetupWithExactError(): void
+ {
+ $this->givenFilesystem($this->themePath, nodeSetup: false);
+ $this->io
+ ->expects($this->once())
+ ->method('error')
+ ->with(
+ 'Watch mode requires Node.js/Grunt setup. No package.json, package-lock.json, '
+ . 'node_modules, or grunt-config.json found.',
+ );
+ $this->staticContentCleaner->expects($this->never())->method('cleanIfNeeded');
+
+ $this->assertFalse($this->builder->watch('Vendor/theme', $this->themePath, $this->io, $this->output, false));
+ }
+
+ /**
+ * Configure the file driver: valid standard theme, node setup toggled.
+ */
+ private function givenFilesystem(string $themePath, bool $nodeSetup): void
+ {
+ $this->fileDriver
+ ->method('isExists')
+ ->willReturnCallback(static function (string $path) use ($themePath, $nodeSetup): bool {
+ if ($path === $themePath . '/theme.xml') {
+ return true;
+ }
+ if ($path === $themePath . '/web/tailwind') {
+ return false;
+ }
+ return $nodeSetup && $path === './package.json';
+ });
+ }
+}
diff --git a/tests/Unit/Service/ThemeBuilder/TailwindCSS/BuilderTest.php b/tests/Unit/Service/ThemeBuilder/TailwindCSS/BuilderTest.php
new file mode 100644
index 00000000..ba567518
--- /dev/null
+++ b/tests/Unit/Service/ThemeBuilder/TailwindCSS/BuilderTest.php
@@ -0,0 +1,528 @@
+shell = $this->createMock(Shell::class);
+ $this->fileDriver = $this->createMock(File::class);
+ $this->staticContentDeployer = $this->createMock(StaticContentDeployer::class);
+ $this->staticContentCleaner = $this->createMock(StaticContentCleaner::class);
+ $this->cacheCleaner = $this->createMock(CacheCleaner::class);
+ $this->symlinkCleaner = $this->createMock(SymlinkCleaner::class);
+ $this->nodePackageManager = $this->createMock(NodePackageManager::class);
+ $this->io = $this->createMock(SymfonyStyle::class);
+ $this->output = $this->createMock(OutputInterface::class);
+
+ $this->builder = new Builder(
+ $this->shell,
+ $this->fileDriver,
+ $this->staticContentDeployer,
+ $this->staticContentCleaner,
+ $this->cacheCleaner,
+ $this->symlinkCleaner,
+ $this->nodePackageManager,
+ );
+ }
+
+ public function testGetNameReturnsThemeName(): void
+ {
+ $this->assertSame('TailwindCSS', $this->builder->getName());
+ }
+
+ public function testDetectReturnsFalseWhenTailwindDirectoryMissing(): void
+ {
+ $this->fileDriver->method('isExists')->willReturn(false);
+
+ $this->assertFalse($this->builder->detect('app/design/frontend/Vendor/theme'));
+ }
+
+ public function testDetectReturnsTrueForNonHyvaThemeXml(): void
+ {
+ $this->fileDriver->method('isExists')->willReturnMap([
+ ['app/design/frontend/Vendor/theme/web/tailwind', true],
+ ['app/design/frontend/Vendor/theme/theme.xml', true],
+ ]);
+ $this->fileDriver->method('fileGetContents')
+ ->with('app/design/frontend/Vendor/theme/theme.xml')
+ ->willReturn('Custom');
+
+ $this->assertTrue($this->builder->detect('app/design/frontend/Vendor/theme'));
+ }
+
+ public function testDetectReturnsFalseForHyvaThemeXml(): void
+ {
+ $this->fileDriver->method('isExists')->willReturnMap([
+ ['app/design/frontend/Vendor/theme/web/tailwind', true],
+ ['app/design/frontend/Vendor/theme/theme.xml', true],
+ ['app/design/frontend/Vendor/theme/composer.json', false],
+ ]);
+ $this->fileDriver->method('fileGetContents')
+ ->with('app/design/frontend/Vendor/theme/theme.xml')
+ ->willReturn('Hyva');
+
+ $this->assertFalse($this->builder->detect('app/design/frontend/Vendor/theme'));
+ }
+
+ public function testDetectReturnsTrueForNonHyvaComposerJson(): void
+ {
+ $this->fileDriver->method('isExists')->willReturnMap([
+ ['app/design/frontend/Vendor/theme/web/tailwind', true],
+ ['app/design/frontend/Vendor/theme/theme.xml', false],
+ ['app/design/frontend/Vendor/theme/composer.json', true],
+ ]);
+ $this->fileDriver->method('fileGetContents')
+ ->with('app/design/frontend/Vendor/theme/composer.json')
+ ->willReturn(json_encode(['name' => 'vendor/custom-theme']));
+
+ $this->assertTrue($this->builder->detect('app/design/frontend/Vendor/theme'));
+ }
+
+ public function testDetectReturnsFalseForHyvaComposerJson(): void
+ {
+ $this->fileDriver->method('isExists')->willReturnMap([
+ ['app/design/frontend/Vendor/theme/web/tailwind', true],
+ ['app/design/frontend/Vendor/theme/theme.xml', false],
+ ['app/design/frontend/Vendor/theme/composer.json', true],
+ ]);
+ $this->fileDriver->method('fileGetContents')
+ ->with('app/design/frontend/Vendor/theme/composer.json')
+ ->willReturn(json_encode(['name' => 'hyva-themes/theme-default']));
+
+ $this->assertFalse($this->builder->detect('app/design/frontend/Vendor/theme'));
+ }
+
+ public function testDetectReturnsFalseWhenNeitherFilePresent(): void
+ {
+ $this->fileDriver->method('isExists')->willReturnMap([
+ ['app/design/frontend/Vendor/theme/web/tailwind', true],
+ ['app/design/frontend/Vendor/theme/theme.xml', false],
+ ['app/design/frontend/Vendor/theme/composer.json', false],
+ ]);
+
+ $this->assertFalse($this->builder->detect('app/design/frontend/Vendor/theme'));
+ }
+
+ private function configureSuccessfulDetection(string $themePath): void
+ {
+ $this->fileDriver->method('isExists')->willReturnMap([
+ [$themePath . '/web/tailwind', true],
+ [$themePath . '/theme.xml', true],
+ ]);
+ $this->fileDriver->method('fileGetContents')
+ ->with($themePath . '/theme.xml')
+ ->willReturn('Custom');
+ }
+
+ public function testBuildReturnsFalseWhenDetectFails(): void
+ {
+ $this->fileDriver->method('isExists')->willReturn(false);
+ $this->staticContentCleaner->expects($this->never())->method('cleanIfNeeded');
+
+ $successList = [];
+ $this->assertFalse(
+ $this->builder->build('Vendor/theme', 'app/design/frontend/Vendor/theme', $this->io, $this->output, false),
+ );
+ }
+
+ public function testBuildReturnsFalseWhenStaticContentCleanFails(): void
+ {
+ $themePath = 'app/design/frontend/Vendor/theme';
+ $this->configureSuccessfulDetection($themePath);
+ $this->staticContentCleaner->method('cleanIfNeeded')->willReturn(false);
+ $this->nodePackageManager->expects($this->never())->method('isNodeModulesInSync');
+
+ $this->assertFalse($this->builder->build('Vendor/theme', $themePath, $this->io, $this->output, false));
+ }
+
+ public function testBuildReturnsFalseWhenSymlinkCleanFails(): void
+ {
+ $themePath = 'app/design/frontend/Vendor/theme';
+ $this->configureSuccessfulDetection($themePath);
+ $this->staticContentCleaner->method('cleanIfNeeded')->willReturn(true);
+ $this->nodePackageManager->method('isNodeModulesInSync')->willReturn(true);
+ $this->symlinkCleaner->method('cleanSymlinks')->willReturn(false);
+ $this->shell->expects($this->never())->method('execute');
+
+ $this->assertFalse($this->builder->build('Vendor/theme', $themePath, $this->io, $this->output, false));
+ }
+
+ public function testBuildReturnsFalseWhenTailwindDirectoryNotFound(): void
+ {
+ $themePath = 'app/design/frontend/Vendor/theme';
+ $this->configureSuccessfulDetection($themePath);
+ $this->staticContentCleaner->method('cleanIfNeeded')->willReturn(true);
+ $this->nodePackageManager->method('isNodeModulesInSync')->willReturn(true);
+ $this->symlinkCleaner->method('cleanSymlinks')->willReturn(true);
+ $this->fileDriver->method('isDirectory')->willReturn(false);
+
+ $this->assertFalse($this->builder->build('Vendor/theme', $themePath, $this->io, $this->output, false));
+ }
+
+ public function testBuildRunsNpmBuildAndDeploysSuccessfully(): void
+ {
+ $themePath = 'app/design/frontend/Vendor/theme';
+ $this->configureSuccessfulDetection($themePath);
+ $this->staticContentCleaner->method('cleanIfNeeded')->willReturn(true);
+ $this->nodePackageManager->method('isNodeModulesInSync')->willReturn(true);
+ $this->symlinkCleaner->method('cleanSymlinks')->willReturn(true);
+ $this->fileDriver->method('isDirectory')->willReturn(true);
+ $this->shell->expects($this->once())->method('execute')
+ ->with('cd %s && npm run build --quiet', [$themePath . '/web/tailwind']);
+ $this->staticContentDeployer->method('deploy')->willReturn(true);
+ $this->cacheCleaner->method('clean')->willReturn(true);
+
+ $this->assertTrue($this->builder->build('Vendor/theme', $themePath, $this->io, $this->output, false));
+ }
+
+ public function testBuildUsesVerboseNpmCommandAndReportsSuccess(): void
+ {
+ $themePath = 'app/design/frontend/Vendor/theme';
+ $this->configureSuccessfulDetection($themePath);
+ $this->staticContentCleaner->method('cleanIfNeeded')->willReturn(true);
+ $this->nodePackageManager->method('isNodeModulesInSync')->willReturn(true);
+ $this->symlinkCleaner->method('cleanSymlinks')->willReturn(true);
+ $this->fileDriver->method('isDirectory')->willReturn(true);
+ $this->shell->expects($this->once())->method('execute')
+ ->with('cd %s && npm run build', [$themePath . '/web/tailwind']);
+ $this->staticContentDeployer->method('deploy')->willReturn(true);
+ $this->cacheCleaner->method('clean')->willReturn(true);
+ $this->io->expects($this->atLeastOnce())->method('success');
+
+ $this->assertTrue($this->builder->build('Vendor/theme', $themePath, $this->io, $this->output, true));
+ }
+
+ public function testBuildHandlesShellExecuteException(): void
+ {
+ $themePath = 'app/design/frontend/Vendor/theme';
+ $this->configureSuccessfulDetection($themePath);
+ $this->staticContentCleaner->method('cleanIfNeeded')->willReturn(true);
+ $this->nodePackageManager->method('isNodeModulesInSync')->willReturn(true);
+ $this->symlinkCleaner->method('cleanSymlinks')->willReturn(true);
+ $this->fileDriver->method('isDirectory')->willReturn(true);
+ $this->shell->method('execute')->willThrowException(new \RuntimeException('npm failed'));
+ $this->io->expects($this->once())->method('error')
+ ->with($this->stringContains('Failed to build custom TailwindCSS theme'));
+
+ $this->assertFalse($this->builder->build('Vendor/theme', $themePath, $this->io, $this->output, false));
+ }
+
+ public function testBuildReturnsFalseWhenDeployFails(): void
+ {
+ $themePath = 'app/design/frontend/Vendor/theme';
+ $this->configureSuccessfulDetection($themePath);
+ $this->staticContentCleaner->method('cleanIfNeeded')->willReturn(true);
+ $this->nodePackageManager->method('isNodeModulesInSync')->willReturn(true);
+ $this->symlinkCleaner->method('cleanSymlinks')->willReturn(true);
+ $this->fileDriver->method('isDirectory')->willReturn(true);
+ $this->staticContentDeployer->method('deploy')->willReturn(false);
+ $this->cacheCleaner->expects($this->never())->method('clean');
+
+ $this->assertFalse($this->builder->build('Vendor/theme', $themePath, $this->io, $this->output, false));
+ }
+
+ public function testBuildReturnsFalseWhenCacheCleanFails(): void
+ {
+ $themePath = 'app/design/frontend/Vendor/theme';
+ $this->configureSuccessfulDetection($themePath);
+ $this->staticContentCleaner->method('cleanIfNeeded')->willReturn(true);
+ $this->nodePackageManager->method('isNodeModulesInSync')->willReturn(true);
+ $this->symlinkCleaner->method('cleanSymlinks')->willReturn(true);
+ $this->fileDriver->method('isDirectory')->willReturn(true);
+ $this->staticContentDeployer->method('deploy')->willReturn(true);
+ $this->cacheCleaner->method('clean')->willReturn(false);
+
+ $this->assertFalse($this->builder->build('Vendor/theme', $themePath, $this->io, $this->output, false));
+ }
+
+ public function testAutoRepairInstallsWhenOutOfSync(): void
+ {
+ $themePath = 'app/design/frontend/Vendor/theme';
+ $this->nodePackageManager->method('isNodeModulesInSync')->willReturn(false);
+ $this->nodePackageManager->expects($this->once())->method('installNodeModules')->willReturn(true);
+
+ $this->assertTrue($this->builder->autoRepair($themePath, $this->io, $this->output, false));
+ }
+
+ public function testAutoRepairReturnsFalseWhenInstallFails(): void
+ {
+ $themePath = 'app/design/frontend/Vendor/theme';
+ $this->nodePackageManager->method('isNodeModulesInSync')->willReturn(false);
+ $this->nodePackageManager->method('installNodeModules')->willReturn(false);
+
+ $this->assertFalse($this->builder->autoRepair($themePath, $this->io, $this->output, false));
+ }
+
+ public function testAutoRepairSkipsInstallWhenInSync(): void
+ {
+ $themePath = 'app/design/frontend/Vendor/theme';
+ $this->nodePackageManager->method('isNodeModulesInSync')->willReturn(true);
+ $this->nodePackageManager->expects($this->never())->method('installNodeModules');
+
+ $this->assertTrue($this->builder->autoRepair($themePath, $this->io, $this->output, false));
+ }
+
+ public function testAutoRepairChecksOutdatedInVerboseMode(): void
+ {
+ $themePath = 'app/design/frontend/Vendor/theme';
+ $this->nodePackageManager->method('isNodeModulesInSync')->willReturn(true);
+ $this->nodePackageManager->expects($this->once())->method('checkOutdatedPackages');
+
+ $this->assertTrue($this->builder->autoRepair($themePath, $this->io, $this->output, true));
+ }
+
+ public function testWatchReturnsFalseWhenDetectFails(): void
+ {
+ $this->fileDriver->method('isExists')->willReturn(false);
+
+ $this->assertFalse(
+ $this->builder->watch('Vendor/theme', 'app/design/frontend/Vendor/theme', $this->io, $this->output, false),
+ );
+ }
+
+ public function testWatchReturnsFalseWhenStaticContentCleanFails(): void
+ {
+ $themePath = 'app/design/frontend/Vendor/theme';
+ $this->configureSuccessfulDetection($themePath);
+ $this->staticContentCleaner->method('cleanIfNeeded')->willReturn(false);
+
+ $this->assertFalse($this->builder->watch('Vendor/theme', $themePath, $this->io, $this->output, false));
+ }
+
+ public function testWatchReturnsFalseWhenTailwindDirectoryMissing(): void
+ {
+ $themePath = 'app/design/frontend/Vendor/theme';
+ $this->configureSuccessfulDetection($themePath);
+ $this->staticContentCleaner->method('cleanIfNeeded')->willReturn(true);
+ $this->fileDriver->method('isDirectory')->willReturn(false);
+
+ $this->assertFalse($this->builder->watch('Vendor/theme', $themePath, $this->io, $this->output, false));
+ }
+
+ // -------------------------------------------------------------------------
+ // Mutation hardening: exact messages, commands and branch boundaries
+ // -------------------------------------------------------------------------
+
+ public function testBuildUsesVerboseNpmCommandWithExactMessages(): void
+ {
+ $themePath = 'app/design/frontend/Vendor/theme';
+ $this->configureSuccessfulDetection($themePath);
+ $this->staticContentCleaner->method('cleanIfNeeded')->willReturn(true);
+ $this->nodePackageManager->method('isNodeModulesInSync')->willReturn(true);
+ $this->symlinkCleaner->method('cleanSymlinks')->willReturn(true);
+ $this->fileDriver->method('isDirectory')->willReturn(true);
+ $this->staticContentDeployer->method('deploy')->willReturn(true);
+ $this->cacheCleaner->method('clean')->willReturn(true);
+ $this->shell
+ ->expects($this->once())
+ ->method('execute')
+ ->with('cd %s && npm run build', [$themePath . '/web/tailwind']);
+ $this->io->expects($this->once())->method('text')->with('Running npm build...');
+ $this->io
+ ->expects($this->once())
+ ->method('success')
+ ->with('Custom TailwindCSS theme build completed successfully.');
+
+ $this->assertTrue($this->builder->build('Vendor/theme', $themePath, $this->io, $this->output, true));
+ }
+
+ public function testQuietBuildUsesQuietNpmCommandAndStaysSilent(): void
+ {
+ $themePath = 'app/design/frontend/Vendor/theme';
+ $this->configureSuccessfulDetection($themePath);
+ $this->staticContentCleaner->method('cleanIfNeeded')->willReturn(true);
+ $this->nodePackageManager->method('isNodeModulesInSync')->willReturn(true);
+ $this->symlinkCleaner->method('cleanSymlinks')->willReturn(true);
+ $this->fileDriver->method('isDirectory')->willReturn(true);
+ $this->staticContentDeployer->method('deploy')->willReturn(true);
+ $this->cacheCleaner->method('clean')->willReturn(true);
+ $this->shell
+ ->expects($this->once())
+ ->method('execute')
+ ->with('cd %s && npm run build --quiet', [$themePath . '/web/tailwind']);
+ $this->io->expects($this->never())->method('text');
+ $this->io->expects($this->never())->method('success');
+
+ $this->assertTrue($this->builder->build('Vendor/theme', $themePath, $this->io, $this->output, false));
+ }
+
+ public function testMissingTailwindBuildDirectoryReportsExactError(): void
+ {
+ $themePath = 'app/design/frontend/Vendor/theme';
+ $this->configureSuccessfulDetection($themePath);
+ $this->staticContentCleaner->method('cleanIfNeeded')->willReturn(true);
+ $this->nodePackageManager->method('isNodeModulesInSync')->willReturn(true);
+ $this->symlinkCleaner->method('cleanSymlinks')->willReturn(true);
+ $this->fileDriver->method('isDirectory')->willReturn(false);
+ $this->io
+ ->expects($this->once())
+ ->method('error')
+ ->with('Tailwind directory not found in: ' . $themePath . '/web/tailwind');
+
+ $this->assertFalse($this->builder->build('Vendor/theme', $themePath, $this->io, $this->output, false));
+ }
+
+ public function testNpmBuildFailureReportsExactError(): void
+ {
+ $themePath = 'app/design/frontend/Vendor/theme';
+ $this->configureSuccessfulDetection($themePath);
+ $this->staticContentCleaner->method('cleanIfNeeded')->willReturn(true);
+ $this->nodePackageManager->method('isNodeModulesInSync')->willReturn(true);
+ $this->symlinkCleaner->method('cleanSymlinks')->willReturn(true);
+ $this->fileDriver->method('isDirectory')->willReturn(true);
+ $this->shell->method('execute')->willThrowException(new \RuntimeException('npm exploded'));
+ $this->io
+ ->expects($this->once())
+ ->method('error')
+ ->with('Failed to build custom TailwindCSS theme: npm exploded');
+ $this->staticContentDeployer->expects($this->never())->method('deploy');
+
+ $this->assertFalse($this->builder->build('Vendor/theme', $themePath, $this->io, $this->output, false));
+ }
+
+ public function testAutoRepairUsesTailwindPathAndExactWarning(): void
+ {
+ $themePath = 'app/design/frontend/Vendor/theme';
+ $this->nodePackageManager
+ ->expects($this->once())
+ ->method('isNodeModulesInSync')
+ ->with($themePath . '/web/tailwind')
+ ->willReturn(false);
+ $this->nodePackageManager
+ ->expects($this->once())
+ ->method('installNodeModules')
+ ->with($themePath . '/web/tailwind')
+ ->willReturn(true);
+ $this->io
+ ->expects($this->once())
+ ->method('warning')
+ ->with('Node modules out of sync or missing. Installing npm dependencies...');
+
+ $this->assertTrue($this->builder->autoRepair($themePath . '/', $this->io, $this->output, true));
+ }
+
+ public function testAutoRepairChecksOutdatedPackagesOnlyInVerboseMode(): void
+ {
+ $themePath = 'app/design/frontend/Vendor/theme';
+ $this->nodePackageManager->method('isNodeModulesInSync')->willReturn(true);
+ $this->nodePackageManager
+ ->expects($this->once())
+ ->method('checkOutdatedPackages')
+ ->with($themePath . '/web/tailwind');
+
+ $this->assertTrue($this->builder->autoRepair($themePath, $this->io, $this->output, true));
+ }
+
+ public function testQuietAutoRepairSkipsOutdatedCheckAndWarning(): void
+ {
+ $this->nodePackageManager->method('isNodeModulesInSync')->willReturn(false);
+ $this->nodePackageManager->method('installNodeModules')->willReturn(true);
+ $this->nodePackageManager->expects($this->never())->method('checkOutdatedPackages');
+ $this->io->expects($this->never())->method('warning');
+
+ $this->assertTrue(
+ $this->builder->autoRepair('app/design/frontend/Vendor/theme', $this->io, $this->output, false),
+ );
+ }
+
+ public function testDetectNormalizesTrailingSlashAndRequiresTailwindDirectory(): void
+ {
+ $themePath = 'app/design/frontend/Vendor/theme';
+ $this->fileDriver->method('isExists')->willReturnMap([
+ [$themePath . '/web/tailwind', false],
+ [$themePath . '/theme.xml', true],
+ ]);
+ $this->fileDriver->method('fileGetContents')->willReturn('Custom');
+
+ $this->assertFalse($this->builder->detect($themePath . '/'));
+ }
+
+ public function testBuildStopsBeforeCleaningWhenDetectFails(): void
+ {
+ $this->fileDriver->method('isExists')->willReturn(false);
+ $this->staticContentCleaner->expects($this->never())->method('cleanIfNeeded');
+ $this->nodePackageManager->expects($this->never())->method('isNodeModulesInSync');
+
+ $this->assertFalse(
+ $this->builder->build('Vendor/theme', 'app/design/frontend/Vendor/theme', $this->io, $this->output, false),
+ );
+ }
+
+ public function testMissingTailwindDirectorySkipsNpmBuild(): void
+ {
+ $themePath = 'app/design/frontend/Vendor/theme';
+ $this->configureSuccessfulDetection($themePath);
+ $this->staticContentCleaner->method('cleanIfNeeded')->willReturn(true);
+ $this->nodePackageManager->method('isNodeModulesInSync')->willReturn(true);
+ $this->symlinkCleaner->method('cleanSymlinks')->willReturn(true);
+ $this->fileDriver->method('isDirectory')->willReturn(false);
+ $this->shell->expects($this->never())->method('execute');
+ $this->staticContentDeployer->expects($this->never())->method('deploy');
+
+ $this->assertFalse($this->builder->build('Vendor/theme', $themePath, $this->io, $this->output, false));
+ }
+
+ public function testWatchStopsBeforeCleaningWhenDetectFails(): void
+ {
+ $this->fileDriver->method('isExists')->willReturn(false);
+ $this->staticContentCleaner->expects($this->never())->method('cleanIfNeeded');
+
+ $this->assertFalse(
+ $this->builder->watch('Vendor/theme', 'app/design/frontend/Vendor/theme', $this->io, $this->output, false),
+ );
+ }
+
+ public function testWatchStopsBeforeDirectoryCheckWhenCleaningFails(): void
+ {
+ $themePath = 'app/design/frontend/Vendor/theme';
+ $this->configureSuccessfulDetection($themePath);
+ $this->staticContentCleaner->method('cleanIfNeeded')->willReturn(false);
+ $this->fileDriver->expects($this->never())->method('isDirectory');
+
+ $this->assertFalse($this->builder->watch('Vendor/theme', $themePath, $this->io, $this->output, false));
+ }
+
+ public function testWatchReportsExactErrorForMissingTailwindDirectory(): void
+ {
+ $themePath = 'app/design/frontend/Vendor/theme';
+ $this->configureSuccessfulDetection($themePath);
+ $this->staticContentCleaner->method('cleanIfNeeded')->willReturn(true);
+ $this->fileDriver->method('isDirectory')->willReturn(false);
+ $this->io
+ ->expects($this->once())
+ ->method('error')
+ ->with('Tailwind directory not found in: ' . $themePath . '/web/tailwind');
+ $this->nodePackageManager->expects($this->never())->method('isNodeModulesInSync');
+ $this->io->expects($this->never())->method('text');
+
+ $this->assertFalse($this->builder->watch('Vendor/theme', $themePath . '/', $this->io, $this->output, false));
+ }
+}