Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
10 changes: 10 additions & 0 deletions src/Solver/Solver.php
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,16 @@ private function getMockPackagePath(PackageInterface $package, string $assetDir,

$targetJsonFile->write($packageValue);

try {
$targetJsonFile->read();
} catch (Throwable $exception) {
throw new RuntimeException(
sprintf('Unable to write asset manifest "%s".', $newFilename),
0,
$exception,
);
}

return [$packageName, $newFilename];
}

Expand Down
152 changes: 152 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,11 +21,13 @@
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;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
use Seld\JsonLint\ParsingException;
use Xepozz\InternalMocker\MockerState;

use function chdir;
Expand Down Expand Up @@ -824,6 +827,121 @@ 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 testSolveRestoresComposerWhenAssetManifestIsTruncated(): 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";
$truncatedTargetContent = substr($targetContent, 0, -2);

MockerState::addCondition(
'Composer\\Json',
'file_put_contents',
[$target, $targetContent, 0, null],
static function (string $filename, mixed $data, int $flags, mixed $context): false {
self::assertNotFalse(
file_put_contents($filename, substr((string) $data, 0, -2), $flags, $context),
);

return 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 validating the generated asset manifest to fail.');
} catch (RuntimeException $exception) {
self::assertSame(sprintf('Unable to write asset manifest "%s".', $target), $exception->getMessage());
self::assertInstanceOf(ParsingException::class, $exception->getPrevious());
}

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

public function testSolveRestoresComposerWhenManagerThrows(): void
{
$this->addInstalledPackages();
Expand Down Expand Up @@ -1045,4 +1163,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