diff --git a/src/Installer.php b/src/Installer.php index 8ea3794..385d9ef 100644 --- a/src/Installer.php +++ b/src/Installer.php @@ -28,6 +28,8 @@ class Installer extends LibraryInstaller const DEFAULT_UPDATE_STRATEGY = 'merge'; + const KNOWN_FRAMEWORKS = ['vue', 'react', 'svelte']; + protected bool $skipUpdateCode = false; const UPDATE_STRATEGY_MERGE = 'merge'; @@ -286,7 +288,110 @@ protected function isPathRepository(PackageInterface $package): bool } /** - * Override install to remove excluded directories after installation. + * Returns the path to frontend.json. Extracted for testability. + */ + protected function getFrontendJsonPath(): string + { + return getcwd().'/frontend.json'; + } + + /** + * Reads the selected framework from frontend.json. + * Returns null when: file missing, invalid JSON, dev mode active, framework not set, or invalid name. + */ + protected function getSelectedFramework(): ?string + { + $path = $this->getFrontendJsonPath(); + + if (! file_exists($path)) { + return null; + } + + $data = json_decode(file_get_contents($path), true); + + if (! is_array($data) || ($data['dev'] ?? false)) { + return null; + } + + $framework = $data['framework'] ?? null; + + if (! is_string($framework) || ! in_array($framework, self::KNOWN_FRAMEWORKS, true)) { + return null; + } + + return $framework; + } + + /** + * Copies the selected framework's JS files flat into resources/js/ and removes all framework subdirs. + * Silent-skips when: no resources/js dir (PHP-only module) or no framework selected. + * Hard-fails when resources/js exists but the selected framework subdir is missing. + */ + protected function copyFrameworkFiles(PackageInterface $package): void + { + $jsRoot = $this->getInstallPath($package).'/resources/js'; + + if (! is_dir($jsRoot)) { + return; + } + + $framework = $this->getSelectedFramework(); + + if (! $framework) { + return; + } + + $fwPath = $jsRoot.'/'.$framework; + + if (! is_dir($fwPath)) { + throw new \RuntimeException(sprintf( + '%s does not support %s. Check the module\'s documentation for framework support.', + $package->getName(), + $framework + )); + } + + $this->flattenFrameworkFiles($jsRoot, $framework); + } + + /** + * Copies files from $jsRoot/$framework flat into $jsRoot, then removes all known framework subdirs. + * Extracted so the same flattening can be applied to any path (e.g. the merge-base temp dir). + */ + protected function flattenFrameworkFiles(string $jsRoot, string $framework): void + { + $this->copyDirectory($jsRoot.'/'.$framework, $jsRoot); + + $fs = new Filesystem; + foreach (self::KNOWN_FRAMEWORKS as $fw) { + $fs->removeDirectory($jsRoot.'/'.$fw); + } + } + + /** + * Recursively copies all files from $source into $dest, preserving relative paths. + */ + private function copyDirectory(string $source, string $dest): void + { + $fs = new SymfonyFilesystem; + $finder = (new Finder)->files()->in($source); + + foreach ($finder as $file) { + $fs->copy($file->getPathname(), $dest.'/'.$file->getRelativePathname(), true); + } + } + + /** + * Proxy for parent::install() — extracted for testability. + * Mirrors the invokeParentUpdateCode() pattern. + */ + protected function parentInstall(InstalledRepositoryInterface $repo, PackageInterface $package): PromiseInterface + { + return parent::install($repo, $package); + } + + /** + * Override install to remove excluded directories and deploy framework files after installation. * Skips entirely for path repositories — their files are already in place. * * {@inheritDoc} @@ -299,10 +404,11 @@ public function install(InstalledRepositoryInterface $repo, PackageInterface $pa return \React\Promise\resolve(null); } - $promise = parent::install($repo, $package); + $promise = $this->parentInstall($repo, $package); return $promise->then(function () use ($package) { $this->removeExcludedDirectories($package); + $this->copyFrameworkFiles($package); }); } @@ -401,8 +507,18 @@ public function update(InstalledRepositoryInterface $repo, PackageInterface $ini ->then( function () use ($repo, $initial, $target, $installPath, $stashPath, $basePath) { $this->removeExcludedDirectories($target); + $this->copyFrameworkFiles($target); if ($basePath !== null) { - // Merge strategy: apply 3-way merge then clean up base temp dir + // Merge strategy: flatten the base so all 3 sides share the same file layout. + // Without this, mergeStash() would compare flattened stash files (e.g. app.ts) + // against unflattened base files (e.g. vue/app.ts), misclassifying every file + // as user-added and silently discarding all upstream changes. + $framework = $this->getSelectedFramework(); + $baseJsRoot = $basePath.'/resources/js'; + if ($framework && is_dir($baseJsRoot.'/'.$framework)) { + $this->flattenFrameworkFiles($baseJsRoot, $framework); + } + // Apply 3-way merge then clean up base temp dir $this->mergeStash($stashPath, $basePath, $installPath); (new Filesystem)->removeDirectory($basePath); } diff --git a/tests/ModuleInstallerTest.php b/tests/ModuleInstallerTest.php index 5b2c87a..ef4bfde 100644 --- a/tests/ModuleInstallerTest.php +++ b/tests/ModuleInstallerTest.php @@ -84,6 +84,48 @@ protected function invokeParentUpdateCode(PackageInterface $initial, PackageInte return parent::invokeParentUpdateCode($initial, $target); } + + // ---- Framework-aware file copying ---- + + private string $frontendJsonPath = ''; + + public function setFrontendJsonPath(string $path): void + { + $this->frontendJsonPath = $path; + } + + protected function getFrontendJsonPath(): string + { + return $this->frontendJsonPath !== '' ? $this->frontendJsonPath : parent::getFrontendJsonPath(); + } + + public function callGetSelectedFramework(): ?string + { + return parent::getSelectedFramework(); + } + + public function callCopyFrameworkFiles(PackageInterface $package): void + { + parent::copyFrameworkFiles($package); + } + + public function callFlattenFrameworkFiles(string $jsRoot, string $framework): void + { + parent::flattenFrameworkFiles($jsRoot, $framework); + } + + public bool $copyFrameworkFilesInvoked = false; + + protected function copyFrameworkFiles(PackageInterface $package): void + { + $this->copyFrameworkFilesInvoked = true; + parent::copyFrameworkFiles($package); + } + + protected function parentInstall(InstalledRepositoryInterface $repo, PackageInterface $package): PromiseInterface + { + return \React\Promise\resolve(null); + } } final class ModuleInstallerTest extends TestCase @@ -643,4 +685,265 @@ public function test_update_code_returns_resolved_promise_when_skip_flag_is_set( $this->assertTrue($resolved, 'updateCode should resolve immediately when skip flag is set'); $this->assertFalse($installer->parentUpdateCodeInvoked, 'parent::updateCode() must not be called when skip flag is set'); } + + // ------------------------------------------------------------------------- + // getSelectedFramework + // ------------------------------------------------------------------------- + + public function test_get_selected_framework_returns_null_when_frontend_json_missing(): void + { + $installer = new TestableInstaller($this->createStub(IOInterface::class), null); + $installer->setFrontendJsonPath('/nonexistent/path/frontend.json'); + + $this->assertNull($installer->callGetSelectedFramework()); + } + + public function test_get_selected_framework_returns_null_when_framework_key_is_null(): void + { + $installer = new TestableInstaller($this->createStub(IOInterface::class), null); + $installer->setFrontendJsonPath($this->writeFrontendJson(['framework' => null])); + + $this->assertNull($installer->callGetSelectedFramework()); + } + + public function test_get_selected_framework_returns_null_when_json_is_invalid(): void + { + $path = sys_get_temp_dir().'/frontend-invalid-'.uniqid('', true).'.json'; + file_put_contents($path, 'not-valid-json{{{'); + + $installer = new TestableInstaller($this->createStub(IOInterface::class), null); + $installer->setFrontendJsonPath($path); + + $this->assertNull($installer->callGetSelectedFramework()); + + unlink($path); + } + + public function test_get_selected_framework_returns_null_when_dev_mode_is_true(): void + { + $installer = new TestableInstaller($this->createStub(IOInterface::class), null); + $installer->setFrontendJsonPath($this->writeFrontendJson(['framework' => 'vue', 'dev' => true])); + + $this->assertNull($installer->callGetSelectedFramework()); + } + + public function test_get_selected_framework_returns_vue_when_set(): void + { + $installer = new TestableInstaller($this->createStub(IOInterface::class), null); + $installer->setFrontendJsonPath($this->writeFrontendJson(['framework' => 'vue'])); + + $this->assertSame('vue', $installer->callGetSelectedFramework()); + } + + public function test_get_selected_framework_returns_react_when_set(): void + { + $installer = new TestableInstaller($this->createStub(IOInterface::class), null); + $installer->setFrontendJsonPath($this->writeFrontendJson(['framework' => 'react'])); + + $this->assertSame('react', $installer->callGetSelectedFramework()); + } + + public function test_get_selected_framework_returns_null_for_unknown_framework(): void + { + $installer = new TestableInstaller($this->createStub(IOInterface::class), null); + + foreach (['angular', 'solid', '../etc', '/abs', 'Vue', ''] as $unknown) { + $installer->setFrontendJsonPath($this->writeFrontendJson(['framework' => $unknown])); + $this->assertNull($installer->callGetSelectedFramework(), "Expected null for framework='$unknown'"); + } + } + + // ------------------------------------------------------------------------- + // copyFrameworkFiles + // ------------------------------------------------------------------------- + + public function test_copy_framework_files_silent_skips_when_no_resources_js_dir(): void + { + $baseDir = sys_get_temp_dir(); + // Package 'saucebase/no-js' → install path = $baseDir/no-js (no resources/js inside) + $moduleDir = $baseDir.'/no-js'; + mkdir($moduleDir, 0755, true); + + $installer = $this->makeInstallerWithModuleDir($baseDir); + $installer->setFrontendJsonPath($this->writeFrontendJson(['framework' => 'vue'])); + + $pkg = new Package('saucebase/no-js', '1.0.0.0', '1.0.0'); + + // No resources/js dir — should not throw and should not create anything + $installer->callCopyFrameworkFiles($pkg); + + $this->assertDirectoryDoesNotExist($moduleDir.'/resources/js'); + + (new Filesystem)->remove($moduleDir); + } + + public function test_copy_framework_files_silent_skips_when_framework_is_null(): void + { + $baseDir = sys_get_temp_dir(); + // Package 'saucebase/skip-module' → install path = $baseDir/skip-module + $moduleDir = $baseDir.'/skip-module'; + $jsRoot = $moduleDir.'/resources/js'; + mkdir($jsRoot.'/vue', 0755, true); + file_put_contents($jsRoot.'/vue/app.ts', 'content'); + + $installer = $this->makeInstallerWithModuleDir($baseDir); + $installer->setFrontendJsonPath('/nonexistent/frontend.json'); // framework = null + + $pkg = new Package('saucebase/skip-module', '1.0.0.0', '1.0.0'); + + $installer->callCopyFrameworkFiles($pkg); + + // Framework subdirs must be untouched — no flattening occurred + $this->assertDirectoryExists($jsRoot.'/vue'); + $this->assertFileDoesNotExist($jsRoot.'/app.ts'); + + (new Filesystem)->remove($moduleDir); + } + + public function test_copy_framework_files_hard_fails_when_framework_subdir_missing(): void + { + $baseDir = sys_get_temp_dir(); + // Package 'saucebase/vue-only' → module dir 'vue-only' → install path = $baseDir/vue-only + $moduleDir = $baseDir.'/vue-only'; + mkdir($moduleDir.'/resources/js', 0755, true); + + $installer = $this->makeInstallerWithModuleDir($baseDir); + $installer->setFrontendJsonPath($this->writeFrontendJson(['framework' => 'react'])); + + $pkg = new Package('saucebase/vue-only', '1.0.0.0', '1.0.0'); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageMatches('/does not support react/'); + + try { + $installer->callCopyFrameworkFiles($pkg); + } finally { + (new Filesystem)->remove($moduleDir); + } + } + + public function test_copy_framework_files_flattens_files_and_removes_framework_subdirs(): void + { + $baseDir = sys_get_temp_dir(); + // Package 'saucebase/flatten-test' → module dir 'flatten-test' → install path = $baseDir/flatten-test + $moduleDir = $baseDir.'/flatten-test'; + $jsRoot = $moduleDir.'/resources/js'; + mkdir($jsRoot.'/vue/pages', 0755, true); + mkdir($jsRoot.'/react', 0755, true); + file_put_contents($jsRoot.'/vue/app.ts', 'vue app'); + file_put_contents($jsRoot.'/vue/pages/Login.vue', 'login page'); + file_put_contents($jsRoot.'/react/app.tsx', 'react app'); + + $installer = $this->makeInstallerWithModuleDir($baseDir); + $installer->setFrontendJsonPath($this->writeFrontendJson(['framework' => 'vue'])); + + $pkg = new Package('saucebase/flatten-test', '1.0.0.0', '1.0.0'); + + $installer->callCopyFrameworkFiles($pkg); + + $this->assertFileExists($jsRoot.'/app.ts'); + $this->assertSame('vue app', file_get_contents($jsRoot.'/app.ts')); + $this->assertFileExists($jsRoot.'/pages/Login.vue'); + $this->assertDirectoryDoesNotExist($jsRoot.'/vue'); + $this->assertDirectoryDoesNotExist($jsRoot.'/react'); + + (new Filesystem)->remove($moduleDir); + } + + public function test_copy_framework_files_removes_all_known_framework_subdirs(): void + { + $baseDir = sys_get_temp_dir(); + // Package 'saucebase/multi-fw' → install path = $baseDir/multi-fw + $moduleDir = $baseDir.'/multi-fw'; + $jsRoot = $moduleDir.'/resources/js'; + mkdir($jsRoot.'/vue', 0755, true); + mkdir($jsRoot.'/react', 0755, true); + mkdir($jsRoot.'/svelte', 0755, true); + file_put_contents($jsRoot.'/vue/app.ts', 'vue app'); + file_put_contents($jsRoot.'/react/app.tsx', 'react app'); + file_put_contents($jsRoot.'/svelte/app.svelte', 'svelte app'); + + $installer = $this->makeInstallerWithModuleDir($baseDir); + $installer->setFrontendJsonPath($this->writeFrontendJson(['framework' => 'vue'])); + + $pkg = new Package('saucebase/multi-fw', '1.0.0.0', '1.0.0'); + $installer->callCopyFrameworkFiles($pkg); + + $this->assertFileExists($jsRoot.'/app.ts'); + $this->assertDirectoryDoesNotExist($jsRoot.'/vue'); + $this->assertDirectoryDoesNotExist($jsRoot.'/react'); + $this->assertDirectoryDoesNotExist($jsRoot.'/svelte'); + + (new Filesystem)->remove($moduleDir); + } + + public function test_flatten_framework_files_is_callable_on_arbitrary_path(): void + { + $jsRoot = sys_get_temp_dir().'/flatten-direct-'.uniqid('', true); + mkdir($jsRoot.'/vue/pages', 0755, true); + mkdir($jsRoot.'/react', 0755, true); + file_put_contents($jsRoot.'/vue/app.ts', 'vue app'); + file_put_contents($jsRoot.'/vue/pages/Login.vue', 'login'); + file_put_contents($jsRoot.'/react/app.tsx', 'react app'); + + $installer = new TestableInstaller($this->createStub(IOInterface::class), null); + $installer->callFlattenFrameworkFiles($jsRoot, 'vue'); + + $this->assertFileExists($jsRoot.'/app.ts'); + $this->assertFileExists($jsRoot.'/pages/Login.vue'); + $this->assertDirectoryDoesNotExist($jsRoot.'/vue'); + $this->assertDirectoryDoesNotExist($jsRoot.'/react'); + + (new Filesystem)->remove($jsRoot); + } + + // ------------------------------------------------------------------------- + // Integration: install() invokes copyFrameworkFiles + // ------------------------------------------------------------------------- + + public function test_install_invokes_copy_framework_files_for_non_path_repo(): void + { + $io = $this->createStub(IOInterface::class); + $installer = new TestableInstaller($io, null); + $installer->setFrontendJsonPath('/nonexistent/frontend.json'); // returns null → skips copy + + $pkg = $this->createStub(PackageInterface::class); + $pkg->method('getDistType')->willReturn('zip'); + $pkg->method('getPrettyName')->willReturn('saucebase/auth'); + + $repo = $this->createStub(InstalledRepositoryInterface::class); + $repo->method('hasPackage')->willReturn(false); + + $resolved = false; + $installer->install($repo, $pkg)->then(function () use (&$resolved) { + $resolved = true; + }); + + $this->assertTrue($resolved); + $this->assertTrue($installer->copyFrameworkFilesInvoked); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** @param array $data */ + private function writeFrontendJson(array $data): string + { + $path = sys_get_temp_dir().'/frontend-'.uniqid('', true).'.json'; + file_put_contents($path, json_encode($data)); + + return $path; + } + + private function makeInstallerWithModuleDir(string $baseDir): TestableInstaller + { + $io = $this->createStub(IOInterface::class); + $composer = $this->createStub(Composer::class); + $root = new RootPackage('root/app', '1.0.0.0', '1.0.0'); + $root->setExtra(['module-dir' => $baseDir]); + $composer->method('getPackage')->willReturn($root); + + return new TestableInstaller($io, $composer); + } }