feat!: add framework-aware file copying for multi-framework module support#12
Merged
Conversation
There was a problem hiding this comment.
Pull request overview
Adds framework-aware post-install/update file handling to the module installer. After a module is installed or updated, the installer reads frontend.json from the project root, picks the configured framework (vue/react/...), copies that subdirectory's contents flat into resources/js/, and removes the framework subdirs. Modules whose resources/js/ lacks the requested framework subdir hard-fail with a RuntimeException.
Changes:
- New
getSelectedFramework()/getFrontendJsonPath()reading the project'sfrontend.json(with dev-mode and missing-file silent skips). - New
copyFrameworkFiles()(+copyDirectory()helper) wired into bothinstall()andupdate(). - New
parentInstall()indirection plus 11 PHPUnit tests covering the new behavior via aTestableInstallerextension.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 8 comments.
| File | Description |
|---|---|
| src/Installer.php | Adds frontend.json reader, framework-flatten logic, parent-install proxy, and hooks them into install/update flows |
| tests/ModuleInstallerTest.php | Extends TestableInstaller with hooks, adds 11 tests for getSelectedFramework, copyFrameworkFiles, and install integration |
Comments suppressed due to low confidence (2)
src/Installer.php:498
- The 3-way merge update strategy is broken by the new framework-flattening step.
copyFrameworkFiles($target)is invoked on the freshly-downloadedinstallPathbut is NOT applied to$basePath(the "original" version downloaded into a temp dir). WhenmergeStash()then iterates the stash (which is the user's previously-installed copy — already flattened, so files live at e.g.app.ts), it computesbaseFile = $base.'/app.ts'. Because base was never flattened, that file actually lives at$base/vue/app.ts, so! file_exists($baseFile)is true and every file is misclassified as "user-added" — meaning the user's old copy is unconditionally written over the new upstream version, silently losing all upstream changes during a merge update.copyFrameworkFiles()(or an equivalent flattening pass) must be applied to$basePathas well so the 3-way merge sees a consistent flattened tree on all three sides.
$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);
(new Filesystem)->removeDirectory($basePath);
tests/ModuleInstallerTest.php:836
- Tests use fixed path names directly under
sys_get_temp_dir()based only on the package short-name (vue-only,flatten-test,no-js,skip-module). If a previous test run failed and left these directories behind, or if two CI jobs share a temp dir, the tests will produce confusing failures or false passes. They also do not usetearDown()so any exception before the cleanup line leaks the directory. Consider includinguniqid()in the package name (or wrapping each test in try/finally cleanup using a unique base dir like the helper used elsewhere in this file).
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);
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+351
to
+352
| $fs->removeDirectory($jsRoot.'/vue'); | ||
| $fs->removeDirectory($jsRoot.'/react'); |
Comment on lines
+314
to
+317
| $framework = $data['framework'] ?? null; | ||
|
|
||
| return is_string($framework) ? $framework : null; | ||
| } |
Comment on lines
+358
to
+366
| 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); | ||
| } | ||
| } |
Comment on lines
+340
to
+346
| if (! is_dir($fwPath)) { | ||
| throw new \RuntimeException(sprintf( | ||
| '%s does not support %s. Check the module\'s documentation for framework support.', | ||
| $package->getName(), | ||
| $framework | ||
| )); | ||
| } |
Comment on lines
+308
to
+309
| $data = json_decode(file_get_contents($path), true); | ||
|
|
Comment on lines
+745
to
+761
| 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); | ||
| } |
Comment on lines
+867
to
+872
|
|
||
| /** @param array<string, mixed> $data */ | ||
| private function writeFrontendJson(array $data): string | ||
| { | ||
| $path = sys_get_temp_dir().'/frontend-'.uniqid('', true).'.json'; | ||
| file_put_contents($path, json_encode($data)); |
Comment on lines
+763
to
+786
| 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); | ||
| } |
…ge-base flattening, and test correctness
sauce-base
approved these changes
May 13, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
frontend.jsonat project root to determine the selected framework (vue,react, etc.)resources/js/{framework}/flat intoresources/js/and removes all framework subdirsresources/js/(PHP-only module), nofrontend.json, ordev: trueinfrontend.jsonBreaking change
copyFrameworkFiles()throwsRuntimeExceptionwhen a module hasresources/js/but lacks the selected framework subdir — any vue-only module installed with"framework":"react"will now hard-fail on install.Test plan
vendor/bin/phpunit)../test-module-installer/run-scenarios.sh)