From 201b36ce7dca532fc488e84ac2866701d8fe4024 Mon Sep 17 00:00:00 2001 From: roble Date: Wed, 13 May 2026 11:12:41 +0100 Subject: [PATCH 1/3] feat!: add framework-aware file copying for multi-framework module support --- src/Installer.php | 95 +++++++++++++- tests/ModuleInstallerTest.php | 241 ++++++++++++++++++++++++++++++++++ 2 files changed, 334 insertions(+), 2 deletions(-) diff --git a/src/Installer.php b/src/Installer.php index 8ea3794..8f3354c 100644 --- a/src/Installer.php +++ b/src/Installer.php @@ -286,7 +286,96 @@ 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, or framework not set. + */ + 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; + + return is_string($framework) ? $framework : null; + } + + /** + * Copies the selected framework's JS files flat into resources/js/ and removes 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->copyDirectory($fwPath, $jsRoot); + + $fs = new Filesystem; + $fs->removeDirectory($jsRoot.'/vue'); + $fs->removeDirectory($jsRoot.'/react'); + } + + /** + * 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 +388,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,6 +491,7 @@ 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 $this->mergeStash($stashPath, $basePath, $installPath); diff --git a/tests/ModuleInstallerTest.php b/tests/ModuleInstallerTest.php index 5b2c87a..82bfbab 100644 --- a/tests/ModuleInstallerTest.php +++ b/tests/ModuleInstallerTest.php @@ -84,6 +84,43 @@ 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 bool $copyFrameworkFilesInvoked = false; + + protected function copyFrameworkFiles(PackageInterface $package): void + { + $this->copyFrameworkFilesInvoked = true; + parent::copyFrameworkFiles($package); + } + + protected function parentInstall(\Composer\Repository\InstalledRepositoryInterface $repo, PackageInterface $package): \React\Promise\PromiseInterface + { + return \React\Promise\resolve(null); + } } final class ModuleInstallerTest extends TestCase @@ -643,4 +680,208 @@ 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()); + } + + // ------------------------------------------------------------------------- + // copyFrameworkFiles + // ------------------------------------------------------------------------- + + public function test_copy_framework_files_silent_skips_when_no_resources_js_dir(): void + { + $moduleDir = sys_get_temp_dir().'/module-fw-test-'.uniqid('', true); + mkdir($moduleDir); + + $installer = $this->makeInstallerWithModuleDir(sys_get_temp_dir()); + $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 + $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 + { + $installer = new TestableInstaller($this->createStub(IOInterface::class), null); + $installer->setFrontendJsonPath('/nonexistent/frontend.json'); + + $pkg = $this->createStub(PackageInterface::class); + $pkg->method('getName')->willReturn('saucebase/auth'); + + // getInstallPath would fail without composer, but framework is null so we return before using it + // Use a real package with a temp module dir to avoid issues + $moduleDir = sys_get_temp_dir().'/module-skip-test-'.uniqid('', true); + mkdir($moduleDir.'/resources/js', 0755, true); + + $installer2 = $this->makeInstallerWithModuleDir(sys_get_temp_dir()); + $installer2->setFrontendJsonPath('/nonexistent/frontend.json'); + + $pkg2 = new Package('saucebase/skip-module', '1.0.0.0', '1.0.0'); + + $installer2->callCopyFrameworkFiles($pkg2); + + $this->assertDirectoryExists($moduleDir.'/resources/js'); + + (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); + } + + // ------------------------------------------------------------------------- + // 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); + } } From a97a1a910b8b4ab93ab584dfad80829802e3dc7c Mon Sep 17 00:00:00 2001 From: roble <3231587+roble@users.noreply.github.com> Date: Wed, 13 May 2026 10:17:46 +0000 Subject: [PATCH 2/3] PHP Linting (Pint) --- tests/ModuleInstallerTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ModuleInstallerTest.php b/tests/ModuleInstallerTest.php index 82bfbab..bc73650 100644 --- a/tests/ModuleInstallerTest.php +++ b/tests/ModuleInstallerTest.php @@ -117,7 +117,7 @@ protected function copyFrameworkFiles(PackageInterface $package): void parent::copyFrameworkFiles($package); } - protected function parentInstall(\Composer\Repository\InstalledRepositoryInterface $repo, PackageInterface $package): \React\Promise\PromiseInterface + protected function parentInstall(InstalledRepositoryInterface $repo, PackageInterface $package): PromiseInterface { return \React\Promise\resolve(null); } From d8ba201b1e276170f42211e7882724728818b02c Mon Sep 17 00:00:00 2001 From: roble Date: Wed, 13 May 2026 11:56:58 +0100 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20address=20code=20review=20=E2=80=94?= =?UTF-8?q?=20framework=20allowlist,=20input=20validation,=20merge-base=20?= =?UTF-8?q?flattening,=20and=20test=20correctness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Installer.php | 39 ++++++++++--- tests/ModuleInstallerTest.php | 100 +++++++++++++++++++++++++++------- 2 files changed, 113 insertions(+), 26 deletions(-) diff --git a/src/Installer.php b/src/Installer.php index 8f3354c..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'; @@ -295,7 +297,7 @@ protected function getFrontendJsonPath(): string /** * Reads the selected framework from frontend.json. - * Returns null when: file missing, invalid JSON, dev mode active, or framework not set. + * Returns null when: file missing, invalid JSON, dev mode active, framework not set, or invalid name. */ protected function getSelectedFramework(): ?string { @@ -313,11 +315,15 @@ protected function getSelectedFramework(): ?string $framework = $data['framework'] ?? null; - return is_string($framework) ? $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 framework subdirs. + * 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. */ @@ -345,11 +351,21 @@ protected function copyFrameworkFiles(PackageInterface $package): void )); } - $this->copyDirectory($fwPath, $jsRoot); + $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; - $fs->removeDirectory($jsRoot.'/vue'); - $fs->removeDirectory($jsRoot.'/react'); + foreach (self::KNOWN_FRAMEWORKS as $fw) { + $fs->removeDirectory($jsRoot.'/'.$fw); + } } /** @@ -493,7 +509,16 @@ 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 bc73650..ef4bfde 100644 --- a/tests/ModuleInstallerTest.php +++ b/tests/ModuleInstallerTest.php @@ -109,6 +109,11 @@ 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 @@ -738,21 +743,33 @@ public function test_get_selected_framework_returns_react_when_set(): void $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 { - $moduleDir = sys_get_temp_dir().'/module-fw-test-'.uniqid('', true); - mkdir($moduleDir); + $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(sys_get_temp_dir()); + $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 + // No resources/js dir — should not throw and should not create anything $installer->callCopyFrameworkFiles($pkg); $this->assertDirectoryDoesNotExist($moduleDir.'/resources/js'); @@ -762,25 +779,23 @@ public function test_copy_framework_files_silent_skips_when_no_resources_js_dir( public function test_copy_framework_files_silent_skips_when_framework_is_null(): void { - $installer = new TestableInstaller($this->createStub(IOInterface::class), null); - $installer->setFrontendJsonPath('/nonexistent/frontend.json'); - - $pkg = $this->createStub(PackageInterface::class); - $pkg->method('getName')->willReturn('saucebase/auth'); - - // getInstallPath would fail without composer, but framework is null so we return before using it - // Use a real package with a temp module dir to avoid issues - $moduleDir = sys_get_temp_dir().'/module-skip-test-'.uniqid('', true); - mkdir($moduleDir.'/resources/js', 0755, true); + $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'); - $installer2 = $this->makeInstallerWithModuleDir(sys_get_temp_dir()); - $installer2->setFrontendJsonPath('/nonexistent/frontend.json'); + $installer = $this->makeInstallerWithModuleDir($baseDir); + $installer->setFrontendJsonPath('/nonexistent/frontend.json'); // framework = null - $pkg2 = new Package('saucebase/skip-module', '1.0.0.0', '1.0.0'); + $pkg = new Package('saucebase/skip-module', '1.0.0.0', '1.0.0'); - $installer2->callCopyFrameworkFiles($pkg2); + $installer->callCopyFrameworkFiles($pkg); - $this->assertDirectoryExists($moduleDir.'/resources/js'); + // Framework subdirs must be untouched — no flattening occurred + $this->assertDirectoryExists($jsRoot.'/vue'); + $this->assertFileDoesNotExist($jsRoot.'/app.ts'); (new Filesystem)->remove($moduleDir); } @@ -835,6 +850,53 @@ public function test_copy_framework_files_flattens_files_and_removes_framework_s (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 // -------------------------------------------------------------------------