Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- fix: run manager commands in the configured root directory without changing the PHP working directory, and prevent manager probes and npm dependency cleanup when manager execution is disabled.
- feat!: add secure frontend audits with CVE reporting, CI formats, and strict npm, pnpm, Yarn, and Bun validation.
- perf: write generated Composer asset manifests directly without first copying their source files.
- fix: detect generated asset manifest write failures before running the frontend manager.

## 0.2.0 January 24, 2026

Expand Down
6 changes: 6 additions & 0 deletions src/Solver/Solver.php
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,12 @@ private function getMockPackagePath(PackageInterface $package, string $assetDir,

$targetJsonFile->write($packageValue);

if (!$targetJsonFile->exists()) {
throw new RuntimeException(
sprintf('Unable to write asset manifest "%s".', $newFilename),
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

return [$packageName, $newFilename];
}

Expand Down
113 changes: 113 additions & 0 deletions tests/Solver/SolverTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Composer\EventDispatcher\EventDispatcher;
use Composer\Installer\InstallationManager;
use Composer\IO\IOInterface;
use Composer\Json\JsonFile;
use Composer\Package\{Link, PackageInterface, RootPackageInterface};
use Composer\Repository\{InstalledArrayRepository, RepositoryManager, WritableRepositoryInterface};
use Composer\Semver\Constraint\Constraint;
Expand All @@ -20,6 +21,7 @@
use Foxy\Fallback\FallbackInterface;
use Foxy\FoxyEvents;
use Foxy\Solver\{Solver, SolverInterface};
use JsonException;
use LogicException;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\MockObject;
Expand Down Expand Up @@ -824,6 +826,83 @@ public function testSolveRejectsWhitespaceOnlyAssetDirectory(): void
$solver->solve($this->composer, $this->io);
}

public function testSolveRestoresComposerWhenAssetManifestCannotBeRead(): void
{
$sourceContent = '{"name":"source-package","version":"1.0.0"}';
['source' => $source, 'target' => $target] = $this->prepareAssetPackageManifest($sourceContent);

MockerState::addCondition(
'Foxy\\Solver',
'file_get_contents',
[$source, false, null, 0, null],
false,
);

$this->manager->expects(self::never())->method('addDependencies');
$this->manager->expects(self::never())->method('run');
$this->composerFallback->expects(self::once())->method('restore');

try {
$this->solver->solve($this->composer, $this->io);
self::fail('Expected reading the asset manifest to fail.');
} catch (RuntimeException $exception) {
self::assertSame(sprintf('Unable to read asset manifest "%s".', $source), $exception->getMessage());
}

self::assertSame($sourceContent, file_get_contents($source));
self::assertFileDoesNotExist($target);
}

public function testSolveRestoresComposerWhenAssetManifestCannotBeWritten(): void
{
$sourceContent = '{"name":"source-package","version":"1.0.0"}';
['source' => $source, 'target' => $target] = $this->prepareAssetPackageManifest($sourceContent);
$targetContent = JsonFile::encode(
['name' => '@composer-asset/foo--bar', 'version' => '1.0.0'],
) . "\n";

MockerState::addCondition(
'Composer\\Json',
'file_put_contents',
[$target, $targetContent, 0, null],
false,
);

$this->manager->expects(self::never())->method('addDependencies');
$this->manager->expects(self::never())->method('run');
$this->composerFallback->expects(self::once())->method('restore');

try {
$this->solver->solve($this->composer, $this->io);
self::fail('Expected writing the asset manifest to fail.');
} catch (RuntimeException $exception) {
self::assertSame(sprintf('Unable to write asset manifest "%s".', $target), $exception->getMessage());
}

self::assertSame($sourceContent, file_get_contents($source));
self::assertFileDoesNotExist($target);
}

public function testSolveRestoresComposerWhenAssetManifestContainsInvalidJson(): void
{
$sourceContent = '{';
['source' => $source, 'target' => $target] = $this->prepareAssetPackageManifest($sourceContent);

$this->manager->expects(self::never())->method('addDependencies');
$this->manager->expects(self::never())->method('run');
$this->composerFallback->expects(self::once())->method('restore');

try {
$this->solver->solve($this->composer, $this->io);
self::fail('Expected decoding the asset manifest to fail.');
} catch (JsonException $exception) {
self::assertSame('Syntax error', $exception->getMessage());
}

self::assertSame($sourceContent, file_get_contents($source));
self::assertFileDoesNotExist($target);
}

public function testSolveRestoresComposerWhenManagerThrows(): void
{
$this->addInstalledPackages();
Expand Down Expand Up @@ -1045,4 +1124,38 @@ private function invokeSolverMethodOn(SolverInterface $solver, string $method, m
{
return (new ReflectionClass($solver))->getMethod($method)->invoke($solver, ...$arguments);
}

/**
* @return array{source: string, target: string}
*/
private function prepareAssetPackageManifest(string $sourceContent): array
{
$package = $this->createMock(PackageInterface::class);
$package->method('getName')->willReturn('foo/bar');
$package
->method('getRequires')
->willReturn([new Link('root/package', 'php-forge/foxy', new Constraint('=', '1.0.0'))]);

$this->addInstalledPackages([$package]);

$installPath = "{$this->cwd}/vendor/foo/bar";
$source = "{$installPath}/package.json";

$this->im->expects(self::once())->method('getInstallPath')->with($package)->willReturn($installPath);
$this->manager->expects(self::once())->method('getPackageName')->willReturn('package.json');
$this->sfs->mkdir($installPath);

self::assertNotFalse(file_put_contents($source, $sourceContent));

$source = realpath($source);

if (false === $source) {
self::fail('Unable to resolve the source asset manifest.');
}

return [
'source' => $this->fs->normalizePath($source),
'target' => $this->getCanonicalPath('/composer-asset-dir/foo/bar/package.json'),
];
}
}
4 changes: 4 additions & 0 deletions tests/Support/InternalMockerExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ public function notify(Finished $event): void
public static function load(): void
{
$mocks = [
[
'namespace' => 'Composer\\Json',
'name' => 'file_put_contents',
],
[
'namespace' => 'Foxy\\Asset',
'name' => 'getcwd',
Expand Down
Loading