Skip to content

feat!: add framework-aware file copying for multi-framework module support#12

Merged
sauce-base merged 3 commits into
mainfrom
dev
May 13, 2026
Merged

feat!: add framework-aware file copying for multi-framework module support#12
sauce-base merged 3 commits into
mainfrom
dev

Conversation

@roble

@roble roble commented May 13, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Reads frontend.json at project root to determine the selected framework (vue, react, etc.)
  • After install/update, copies files from resources/js/{framework}/ flat into resources/js/ and removes all framework subdirs
  • Hard-fails with a clear error if the module does not support the requested framework (missing subdir)
  • Silently skips when: no resources/js/ (PHP-only module), no frontend.json, or dev: true in frontend.json
  • Dev mode guard preserves framework subdirs so contributor-mode page resolution still works

Breaking change

copyFrameworkFiles() throws RuntimeException when a module has resources/js/ but lacks the selected framework subdir — any vue-only module installed with "framework":"react" will now hard-fail on install.

Test plan

  • 11 new PHPUnit tests pass (vendor/bin/phpunit)
  • Integration scenarios A–E pass (../test-module-installer/run-scenarios.sh)
  • CI php.yml green on this PR

Copilot AI review requested due to automatic review settings May 13, 2026 10:17

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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's frontend.json (with dev-mode and missing-file silent skips).
  • New copyFrameworkFiles() (+ copyDirectory() helper) wired into both install() and update().
  • New parentInstall() indirection plus 11 PHPUnit tests covering the new behavior via a TestableInstaller extension.

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-downloaded installPath but is NOT applied to $basePath (the "original" version downloaded into a temp dir). When mergeStash() then iterates the stash (which is the user's previously-installed copy — already flattened, so files live at e.g. app.ts), it computes baseFile = $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 $basePath as 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 use tearDown() so any exception before the cleanup line leaks the directory. Consider including uniqid() 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 thread src/Installer.php Outdated
Comment on lines +351 to +352
$fs->removeDirectory($jsRoot.'/vue');
$fs->removeDirectory($jsRoot.'/react');
Comment thread src/Installer.php
Comment on lines +314 to +317
$framework = $data['framework'] ?? null;

return is_string($framework) ? $framework : null;
}
Comment thread src/Installer.php
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 thread src/Installer.php
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 thread src/Installer.php
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);
}
@sauce-base sauce-base merged commit ea4a5a1 into main May 13, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants