Files
composer-satis/tests/Console/Command/BuildCommandTest.php
T

1125 lines
39 KiB
PHP

<?php
declare(strict_types=1);
/*
* This file is part of composer/satis.
*
* (c) Composer <https://github.com/composer>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Composer\Satis\Console\Command;
use Composer\Config;
use Composer\Console\Application;
use Composer\Factory;
use Composer\IO\NullIO;
use Composer\Json\JsonValidationException;
use Composer\Package\Loader\RootPackageLoader;
use Composer\Package\Version\VersionGuesser;
use Composer\Package\Version\VersionParser;
use Composer\Repository\ConfigurableRepositoryInterface;
use Composer\Repository\RepositoryManager;
use Composer\Satis\Console\Application as SatisApplication;
use Composer\Util\ProcessExecutor;
use org\bovigo\vfs\vfsStream;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
use Seld\JsonLint\ParsingException;
use Symfony\Component\Console\Output\BufferedOutput;
use Symfony\Component\Console\Output\NullOutput;
use Symfony\Component\Console\Tester\CommandTester;
#[TestDox('BuildCommand')]
class BuildCommandTest extends TestCase
{
/** @var array<string, array<string, mixed>> */
private static array $savedDefaultRepositories;
private string $configPath;
public static function setUpBeforeClass(): void
{
self::$savedDefaultRepositories = Config::$defaultRepositories;
}
protected function setUp(): void
{
vfsStream::setup('satis');
$this->configPath = vfsStream::url('satis/satis.json');
unset(Config::$defaultRepositories['packagist'], Config::$defaultRepositories['packagist.org']);
}
protected function tearDown(): void
{
Config::$defaultRepositories = self::$savedDefaultRepositories;
}
/**
* @return array<string, mixed>
*/
public static function dataRepositoryDisableDirectives(): array
{
$data = [];
$data['packagist.org disable only'] = [
0,
[
['packagist.org' => false],
],
];
$data['packagist.org disable with valid repos'] = [
2,
[
['packagist.org' => false],
[
'type' => 'package',
'package' => [
'name' => 'vendor/alpha',
'version' => '1.0.0',
'dist' => ['url' => 'https://example.com/alpha.zip', 'type' => 'zip'],
],
],
[
'type' => 'package',
'package' => [
'name' => 'vendor/beta',
'version' => '2.0.0',
'dist' => ['url' => 'https://example.com/beta.zip', 'type' => 'zip'],
],
],
],
];
$data['multiple disable directives'] = [
1,
[
['packagist.org' => false],
['my-private-repo' => false],
[
'type' => 'package',
'package' => [
'name' => 'vendor/gamma',
'version' => '1.0.0',
'dist' => ['url' => 'https://example.com/gamma.zip', 'type' => 'zip'],
],
],
],
];
$data['disable directive at end'] = [
1,
[
[
'type' => 'package',
'package' => [
'name' => 'vendor/delta',
'version' => '1.0.0',
'dist' => ['url' => 'https://example.com/delta.zip', 'type' => 'zip'],
],
],
['packagist.org' => false],
],
];
$data['no disable directives'] = [
2,
[
[
'type' => 'package',
'package' => [
'name' => 'vendor/alpha',
'version' => '1.0.0',
'dist' => ['url' => 'https://example.com/alpha.zip', 'type' => 'zip'],
],
],
[
'type' => 'package',
'package' => [
'name' => 'vendor/beta',
'version' => '2.0.0',
'dist' => ['url' => 'https://example.com/beta.zip', 'type' => 'zip'],
],
],
],
];
return $data;
}
/**
* Reproduces the repository iteration logic from BuildCommand::execute()
* to verify that disable directives are skipped and valid repos are added.
*
* Without the fix, configs containing {"packagist.org": false} would throw
* "Undefined array key 'type'"
*
* @param array<int, mixed> $repositories
*/
#[DataProvider('dataRepositoryDisableDirectives')]
#[TestDox('Repository loop skips disable directives')]
public function testRepositoryLoopSkipsDisableDirectives(int $expectedRepoCount, array $repositories): void
{
$config = [
'name' => 'test/satis-repo',
'homepage' => 'https://example.com',
'repositories' => $repositories,
];
$composer = (new Factory())->createComposer(new NullIO(), $config, true, null, false);
$manager = $composer->getRepositoryManager();
$initialCount = count($manager->getRepositories());
$disabledRepoNames = [];
foreach ($config['repositories'] as $repo) {
if (is_array($repo) && !isset($repo['type']) && 1 === count($repo) && false === current($repo)) {
$disabledRepoNames[] = (string) key($repo);
continue;
}
if (!isset($repo['type'])) {
continue;
}
$manager->addRepository($manager->createRepository($repo['type'], $repo));
}
if ([] !== $disabledRepoNames) {
$this->invokeRemoveDisabledRepositories($manager, $disabledRepoNames);
}
$addedRepos = count($manager->getRepositories()) - $initialCount;
self::assertSame($expectedRepoCount, $addedRepos);
}
/**
* Proves the disable directive has a functional effect: when packagist is
* in the RepositoryManager's defaults, {"packagist.org": false} removes it.
*
* The Composer instance is created WITHOUT the directive (so packagist
* survives Factory initialization), then the directive is applied after
* the fact -- matching how BuildCommand processes it in plugin mode.
*/
#[TestDox('Disable directive removes packagist from RepositoryManager')]
public function testDisableDirectiveRemovesPackagistFromRepositoryManager(): void
{
Config::$defaultRepositories = [
'packagist.org' => ['type' => 'composer', 'url' => 'https://repo.packagist.org'],
];
$configWithoutDirective = [
'name' => 'test/satis-repo',
'homepage' => 'https://example.com',
'repositories' => [
[
'type' => 'package',
'package' => [
'name' => 'vendor/test',
'version' => '1.0.0',
'dist' => ['url' => 'https://example.com/test.zip', 'type' => 'zip'],
],
],
],
];
$composer = (new Factory())->createComposer(new NullIO(), $configWithoutDirective, true, null, false);
$manager = $composer->getRepositoryManager();
self::assertTrue(
$this->repositoryManagerContainsPackagist($manager),
'Packagist should be present before processing disable directive'
);
$this->invokeRemoveDisabledRepositories($manager, ['packagist.org']);
self::assertFalse(
$this->repositoryManagerContainsPackagist($manager),
'Packagist should be removed after processing disable directive'
);
}
#[TestDox('Packagist remains when no disable directive is present')]
public function testPackagistRemainsWithoutDisableDirective(): void
{
Config::$defaultRepositories = [
'packagist.org' => ['type' => 'composer', 'url' => 'https://repo.packagist.org'],
];
$config = [
'name' => 'test/satis-repo',
'homepage' => 'https://example.com',
'repositories' => [
[
'type' => 'package',
'package' => [
'name' => 'vendor/test',
'version' => '1.0.0',
'dist' => ['url' => 'https://example.com/test.zip', 'type' => 'zip'],
],
],
],
];
$composer = (new Factory())->createComposer(new NullIO(), $config, true, null, false);
$manager = $composer->getRepositoryManager();
foreach ($config['repositories'] as $repo) {
$manager->addRepository($manager->createRepository($repo['type'], $repo));
}
self::assertTrue(
$this->repositoryManagerContainsPackagist($manager),
'Packagist should remain when no disable directive is present'
);
}
#[TestDox('Removing disabled repositories produces verbose output')]
public function testRemoveDisabledRepositoriesOutputsInVerboseMode(): void
{
Config::$defaultRepositories = [
'packagist.org' => ['type' => 'composer', 'url' => 'https://repo.packagist.org'],
];
$config = [
'name' => 'test/satis-repo',
'homepage' => 'https://example.com',
'repositories' => [],
];
$composer = (new Factory())->createComposer(new NullIO(), $config, true, null, false);
$manager = $composer->getRepositoryManager();
self::assertTrue($this->repositoryManagerContainsPackagist($manager));
$output = new BufferedOutput();
$this->invokeRemoveDisabledRepositories($manager, ['packagist.org'], $output, true);
self::assertFalse($this->repositoryManagerContainsPackagist($manager));
self::assertStringContainsString(
'Removed repository packagist.org (disabled by config)',
$output->fetch()
);
}
#[TestDox('Removing disabled repositories is silent in non-verbose mode')]
public function testRemoveDisabledRepositoriesSilentInNonVerboseMode(): void
{
Config::$defaultRepositories = [
'packagist.org' => ['type' => 'composer', 'url' => 'https://repo.packagist.org'],
];
$config = [
'name' => 'test/satis-repo',
'homepage' => 'https://example.com',
'repositories' => [],
];
$composer = (new Factory())->createComposer(new NullIO(), $config, true, null, false);
$manager = $composer->getRepositoryManager();
$output = new BufferedOutput();
$this->invokeRemoveDisabledRepositories($manager, ['packagist.org'], $output, false);
self::assertSame('', $output->fetch());
}
#[TestDox('Repositories registered several times are only kept once')]
public function testDeduplicateRepositoriesRemovesDuplicates(): void
{
$repoConfig = ['type' => 'composer', 'url' => 'https://example.com/repo'];
$config = [
'name' => 'test/satis-repo',
'homepage' => 'https://example.com',
'repositories' => [$repoConfig],
];
$composer = (new Factory())->createComposer(new NullIO(), $config, true, null, false);
$manager = $composer->getRepositoryManager();
// the same registration Factory::create(), BuildCommand and RootPackageLoader each perform
$manager->addRepository($manager->createRepository($repoConfig['type'], $repoConfig));
$manager->addRepository($manager->createRepository($repoConfig['type'], $repoConfig));
self::assertSame(3, $this->countRepositoriesWithUrl($manager, 'https://example.com/repo'));
$output = new BufferedOutput();
$this->invokeDeduplicateRepositories($manager, $output, true);
self::assertSame(1, $this->countRepositoriesWithUrl($manager, 'https://example.com/repo'));
self::assertStringContainsString(
'Removed 2 duplicate registration(s) of repository composer https://example.com/repo',
$output->fetch()
);
}
#[TestDox('The registration sequence of the build command yields a single repository')]
public function testRepositoriesOfTheConfigAreRegisteredOnce(): void
{
$repoConfig = ['type' => 'composer', 'url' => 'https://example.com/repo'];
$config = [
'name' => 'test/satis-repo',
'homepage' => 'https://example.com',
'repositories' => [$repoConfig],
];
// 1. the Composer instance built from the Satis config, as in standalone mode
$composer = (new Factory())->createComposer(new NullIO(), $config, true, null, false);
$manager = $composer->getRepositoryManager();
// 2. the repositories the build command feeds into the manager
$manager->addRepository($manager->createRepository($repoConfig['type'], $repoConfig));
// 3. the root package load, which adds the repositories of the config once more
$parser = new VersionParser();
$process = new ProcessExecutor(new NullIO());
$process->enableAsync();
$guesser = new VersionGuesser($composer->getConfig(), $process, $parser);
$loader = new RootPackageLoader($manager, $composer->getConfig(), $parser, $guesser);
$loader->load($config);
self::assertSame(3, $this->countRepositoriesWithUrl($manager, 'https://example.com/repo'));
$this->invokeDeduplicateRepositories($manager);
self::assertSame(1, $this->countRepositoriesWithUrl($manager, 'https://example.com/repo'));
}
#[TestDox('Deduplication keeps distinct repositories')]
public function testDeduplicateRepositoriesKeepsDistinctRepositories(): void
{
$first = ['type' => 'composer', 'url' => 'https://example.com/first'];
$second = ['type' => 'composer', 'url' => 'https://example.com/second'];
$config = [
'name' => 'test/satis-repo',
'homepage' => 'https://example.com',
'repositories' => [$first, $second],
];
$composer = (new Factory())->createComposer(new NullIO(), $config, true, null, false);
$manager = $composer->getRepositoryManager();
$output = new BufferedOutput();
$this->invokeDeduplicateRepositories($manager, $output, false);
self::assertSame(1, $this->countRepositoriesWithUrl($manager, 'https://example.com/first'));
self::assertSame(1, $this->countRepositoriesWithUrl($manager, 'https://example.com/second'));
self::assertSame('', $output->fetch());
}
private function invokeDeduplicateRepositories(RepositoryManager $manager, ?\Symfony\Component\Console\Output\OutputInterface $output = null, bool $verbose = false): void
{
$command = new BuildCommand();
$method = new \ReflectionMethod($command, 'deduplicateRepositories');
$method->invokeArgs($command, [$manager, $output ?? new NullOutput(), $verbose]);
}
private function countRepositoriesWithUrl(RepositoryManager $manager, string $url): int
{
$count = 0;
foreach ($manager->getRepositories() as $repo) {
if ($repo instanceof ConfigurableRepositoryInterface && ($repo->getRepoConfig()['url'] ?? null) === $url) {
++$count;
}
}
return $count;
}
/**
* @param string[] $disabledRepoNames
*/
private function invokeRemoveDisabledRepositories(RepositoryManager $manager, array $disabledRepoNames, ?\Symfony\Component\Console\Output\OutputInterface $output = null, bool $verbose = false): void
{
$command = new BuildCommand();
$method = new \ReflectionMethod($command, 'removeDisabledRepositories');
$method->invokeArgs($command, [$manager, $disabledRepoNames, $output ?? new NullOutput(), $verbose]);
}
private function repositoryManagerContainsPackagist(RepositoryManager $manager): bool
{
foreach ($manager->getRepositories() as $repo) {
if ($repo instanceof ConfigurableRepositoryInterface) {
$url = $repo->getRepoConfig()['url'] ?? '';
if (1 === preg_match('{^https?://(?:[a-z0-9-.]+\.)?packagist\.org(/|$)}i', $url)) {
return true;
}
}
}
return false;
}
/** @param array<string, mixed> $config */
private function writeConfig(array $config): void
{
file_put_contents(
$this->configPath,
json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
);
}
private function createTester(): CommandTester
{
$app = new Application();
$app->addCommand(new BuildCommand());
return new CommandTester($app->find('build'));
}
private function createSatisTester(): CommandTester
{
$app = new SatisApplication();
$ioRef = new \ReflectionProperty($app, 'io');
$ioRef->setValue($app, new NullIO());
return new CommandTester($app->find('build'));
}
private function makeOutputDir(string $prefix = 'output'): string
{
$outputDir = vfsStream::url('satis/' . $prefix . '_' . uniqid());
mkdir($outputDir);
return $outputDir;
}
/** @return array<string, mixed> */
private function packageRepo(string $name = 'vendor/test-pkg', string $version = '1.0.0', ?string $distUrl = null): array
{
return [
'type' => 'package',
'package' => [
'name' => $name,
'version' => $version,
'dist' => [
'url' => $distUrl ?? 'https://example.com/test.zip',
'type' => 'zip',
],
],
];
}
/**
* @param array<string, mixed> $overrides
*
* @return array<string, mixed>
*/
private function baseSatisConfig(array $overrides = []): array
{
$config = [
'name' => 'test/satis-repo',
'homepage' => 'https://example.com',
'repositories' => [
$this->packageRepo(),
],
'require-all' => true,
];
return array_replace($config, $overrides);
}
/**
* @param array<string, mixed> $buildArgs
* @param array<string, mixed> $config
*/
private function executeSatis(array $buildArgs, array $config): CommandTester
{
$this->writeConfig($config);
$tester = $this->createSatisTester();
$tester->execute($buildArgs);
return $tester;
}
#[TestDox('check() returns true for valid JSON matching the schema')]
public function testCheckReturnsTrueForValidConfig(): void
{
$this->writeConfig([
'name' => 'test/satis-repo',
'homepage' => 'https://example.com',
'repositories' => [],
]);
$command = new BuildCommand();
$method = new \ReflectionMethod($command, 'check');
self::assertTrue($method->invoke($command, $this->configPath));
}
/**
* @return array<string, array{array<string, mixed>}>
*/
public static function dataRelativeUriReferences(): array
{
return [
'relative homepage' => [['homepage' => '/satis']],
'relative archive prefix-url' => [['archive' => ['directory' => 'dist', 'prefix-url' => '/satis']]],
];
}
/**
* @param array<string, mixed> $overrides
*/
#[DataProvider('dataRelativeUriReferences')]
#[TestDox('check() accepts a relative URI reference for homepage and archive prefix-url')]
public function testCheckAcceptsRelativeUriReferences(array $overrides): void
{
$this->writeConfig(array_replace([
'name' => 'test/satis-repo',
'homepage' => 'https://example.com',
'repositories' => [],
], $overrides));
$command = new BuildCommand();
$method = new \ReflectionMethod($command, 'check');
self::assertTrue($method->invoke($command, $this->configPath));
}
#[TestDox('check() still rejects an archive prefix-url that is not a URI reference')]
public function testCheckRejectsInvalidPrefixUrl(): void
{
$this->writeConfig([
'name' => 'test/satis-repo',
'homepage' => 'https://example.com',
'repositories' => [],
'archive' => ['directory' => 'dist', 'prefix-url' => 'not a uri'],
]);
$command = new BuildCommand();
$method = new \ReflectionMethod($command, 'check');
$this->expectException(JsonValidationException::class);
$method->invoke($command, $this->configPath);
}
#[TestDox('check() throws ParsingException for invalid JSON')]
public function testCheckThrowsParsingExceptionForInvalidJson(): void
{
file_put_contents($this->configPath, '{invalid json!!!}');
$command = new BuildCommand();
$method = new \ReflectionMethod($command, 'check');
$this->expectException(ParsingException::class);
$this->expectExceptionMessage('does not contain valid JSON');
$method->invoke($command, $this->configPath);
}
#[TestDox('check() throws JsonValidationException when schema validation fails')]
public function testCheckThrowsJsonValidationExceptionForSchemaFailure(): void
{
file_put_contents($this->configPath, json_encode(['repositories' => []]));
$command = new BuildCommand();
$method = new \ReflectionMethod($command, 'check');
$this->expectException(JsonValidationException::class);
$this->expectExceptionMessage('does not match the expected JSON schema');
$method->invoke($command, $this->configPath);
}
#[TestDox('check() throws ParsingException when file cannot be read')]
public function testCheckThrowsParsingExceptionForUnreadableFile(): void
{
$command = new BuildCommand();
$method = new \ReflectionMethod($command, 'check');
$this->expectException(ParsingException::class);
$this->expectExceptionMessage('does not contain valid JSON');
@$method->invoke($command, vfsStream::url('satis/nonexistent.json'));
}
#[TestDox('getComposerHome() returns COMPOSER_HOME when set')]
public function testGetComposerHomeReturnsComposerHome(): void
{
$original = getenv('COMPOSER_HOME');
putenv('COMPOSER_HOME=/custom/composer/home');
try {
$command = new BuildCommand();
$method = new \ReflectionMethod($command, 'getComposerHome');
self::assertSame('/custom/composer/home', $method->invoke($command));
} finally {
putenv(false === $original ? 'COMPOSER_HOME' : 'COMPOSER_HOME=' . $original);
}
}
#[TestDox('getComposerHome() falls back to HOME/.composer')]
public function testGetComposerHomeFallsBackToHome(): void
{
if (defined('PHP_WINDOWS_VERSION_MAJOR')) {
self::markTestSkipped('Non-Windows test');
}
$originalComposerHome = getenv('COMPOSER_HOME');
$originalHome = getenv('HOME');
putenv('COMPOSER_HOME');
putenv('HOME=/test/user');
try {
$command = new BuildCommand();
$method = new \ReflectionMethod($command, 'getComposerHome');
self::assertSame('/test/user/.composer', $method->invoke($command));
} finally {
putenv(false === $originalHome ? 'HOME' : 'HOME=' . $originalHome);
putenv(false === $originalComposerHome ? 'COMPOSER_HOME' : 'COMPOSER_HOME=' . $originalComposerHome);
}
}
#[TestDox('getComposerHome() throws when HOME and COMPOSER_HOME are unset')]
public function testGetComposerHomeThrowsWhenNoEnvVars(): void
{
if (defined('PHP_WINDOWS_VERSION_MAJOR')) {
self::markTestSkipped('Non-Windows test');
}
$originalComposerHome = getenv('COMPOSER_HOME');
$originalHome = getenv('HOME');
putenv('COMPOSER_HOME');
putenv('HOME');
try {
$command = new BuildCommand();
$method = new \ReflectionMethod($command, 'getComposerHome');
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('HOME or COMPOSER_HOME');
$method->invoke($command);
} finally {
putenv(false === $originalHome ? 'HOME' : 'HOME=' . $originalHome);
putenv(false === $originalComposerHome ? 'COMPOSER_HOME' : 'COMPOSER_HOME=' . $originalComposerHome);
}
}
#[TestDox('execute() returns exit code 1 when config file is not found')]
public function testExecuteReturnsErrorWhenFileNotFound(): void
{
$tester = $this->createTester();
$tester->execute([
'file' => vfsStream::url('satis/nonexistent.json'),
'output-dir' => vfsStream::url('satis/output'),
]);
self::assertSame(1, $tester->getStatusCode());
self::assertStringContainsString('File not found', $tester->getDisplay());
}
#[TestDox('execute() throws when --repository-url and packages are both provided')]
public function testExecuteThrowsOnRepositoryUrlWithPackages(): void
{
$this->writeConfig([
'name' => 'test/satis-repo',
'homepage' => 'https://example.com',
'repositories' => [],
]);
$tester = $this->createTester();
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('can not be used together');
$tester->execute([
'file' => $this->configPath,
'output-dir' => vfsStream::url('satis/output'),
'packages' => ['vendor/alpha'],
'--repository-url' => ['https://example.com/repo.git'],
'--skip-errors' => true,
]);
}
#[TestDox('execute() throws when output directory is not specified')]
public function testExecuteThrowsWhenOutputDirMissing(): void
{
$this->writeConfig([
'name' => 'test/satis-repo',
'homepage' => 'https://example.com',
'repositories' => [],
]);
$tester = $this->createTester();
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('output dir must be specified');
$tester->execute([
'file' => $this->configPath,
'--skip-errors' => true,
]);
}
#[TestDox('shouldBuildHtml() returns false when --no-html-output is passed')]
public function testShouldBuildHtmlReturnsFalseWithNoHtmlFlag(): void
{
$command = new BuildCommand();
$method = new \ReflectionMethod($command, 'shouldBuildHtml');
$input = new \Symfony\Component\Console\Input\ArrayInput(
['--no-html-output' => true],
$command->getDefinition()
);
self::assertFalse($method->invoke($command, $input, []));
}
#[TestDox('shouldBuildHtml() returns true by default')]
public function testShouldBuildHtmlReturnsTrueByDefault(): void
{
$command = new BuildCommand();
$method = new \ReflectionMethod($command, 'shouldBuildHtml');
$input = new \Symfony\Component\Console\Input\ArrayInput([], $command->getDefinition());
self::assertTrue($method->invoke($command, $input, []));
}
#[TestDox('shouldBuildHtml() returns true when output-html is true in config')]
public function testShouldBuildHtmlReturnsTrueWhenConfigEnabled(): void
{
$command = new BuildCommand();
$method = new \ReflectionMethod($command, 'shouldBuildHtml');
$input = new \Symfony\Component\Console\Input\ArrayInput([], $command->getDefinition());
self::assertTrue($method->invoke($command, $input, ['output-html' => true]));
}
#[TestDox('shouldBuildHtml() returns false when output-html is false in config')]
public function testShouldBuildHtmlReturnsFalseWhenConfigDisabled(): void
{
$command = new BuildCommand();
$method = new \ReflectionMethod($command, 'shouldBuildHtml');
$input = new \Symfony\Component\Console\Input\ArrayInput([], $command->getDefinition());
self::assertFalse($method->invoke($command, $input, ['output-html' => false]));
}
#[TestDox('execute() runs a full build with package repositories')]
public function testExecuteRunsFullBuild(): void
{
$outputDir = $this->makeOutputDir();
$tester = $this->executeSatis(
[
'file' => $this->configPath,
'output-dir' => $outputDir,
],
$this->baseSatisConfig()
);
self::assertSame(0, $tester->getStatusCode());
self::assertFileExists($outputDir . '/packages.json');
}
#[TestDox('execute() reads output-dir from config when not passed as argument')]
public function testExecuteReadsOutputDirFromConfig(): void
{
$outputDir = $this->makeOutputDir();
$tester = $this->executeSatis(
['file' => $this->configPath],
$this->baseSatisConfig(['output-dir' => $outputDir])
);
self::assertSame(0, $tester->getStatusCode());
self::assertFileExists($outputDir . '/packages.json');
}
#[TestDox('execute() picks up SATIS_HOMEPAGE from environment')]
public function testExecuteUsesSatisHomepageEnv(): void
{
$outputDir = $this->makeOutputDir();
$original = getenv('SATIS_HOMEPAGE');
putenv('SATIS_HOMEPAGE=https://custom.example.com');
try {
$tester = $this->executeSatis(
[
'file' => $this->configPath,
'output-dir' => $outputDir,
],
$this->baseSatisConfig()
);
self::assertSame(0, $tester->getStatusCode());
self::assertStringContainsString('SATIS_HOMEPAGE', $tester->getDisplay());
} finally {
putenv(false === $original ? 'SATIS_HOMEPAGE' : 'SATIS_HOMEPAGE=' . $original);
}
}
#[TestDox('execute() processes disable directives during build')]
public function testExecuteProcessesDisableDirectives(): void
{
$outputDir = $this->makeOutputDir();
$tester = $this->executeSatis(
[
'file' => $this->configPath,
'output-dir' => $outputDir,
],
$this->baseSatisConfig([
'repositories' => [
['packagist.org' => false],
$this->packageRepo(),
],
])
);
self::assertSame(0, $tester->getStatusCode());
}
#[TestDox('execute() builds HTML output by default')]
public function testExecuteBuildsHtmlByDefault(): void
{
$outputDir = $this->makeOutputDir();
$tester = $this->executeSatis(
[
'file' => $this->configPath,
'output-dir' => $outputDir,
],
$this->baseSatisConfig()
);
self::assertSame(0, $tester->getStatusCode());
self::assertFileExists($outputDir . '/index.html');
}
#[TestDox('execute() suppresses HTML output with --no-html-output')]
public function testExecuteSuppressesHtmlWithFlag(): void
{
$outputDir = $this->makeOutputDir();
$tester = $this->executeSatis(
[
'file' => $this->configPath,
'output-dir' => $outputDir,
'--no-html-output' => true,
],
$this->baseSatisConfig()
);
self::assertSame(0, $tester->getStatusCode());
self::assertFileDoesNotExist($outputDir . '/index.html');
}
#[TestDox('execute() suppresses HTML output when output-html is false in config')]
public function testExecuteSuppressesHtmlWithConfig(): void
{
$outputDir = $this->makeOutputDir();
$tester = $this->executeSatis(
[
'file' => $this->configPath,
'output-dir' => $outputDir,
],
$this->baseSatisConfig(['output-html' => false])
);
self::assertSame(0, $tester->getStatusCode());
self::assertFileDoesNotExist($outputDir . '/index.html');
}
#[TestDox('execute() continues on schema validation failure with --skip-errors')]
public function testExecuteContinuesOnSchemaFailureWithSkipErrors(): void
{
$outputDir = $this->makeOutputDir();
$tester = $this->executeSatis(
[
'file' => $this->configPath,
'output-dir' => $outputDir,
'--skip-errors' => true,
],
[
// Intentionally missing "homepage" to trigger schema validation failure.
'name' => 'test/satis-repo',
'repositories' => [
$this->packageRepo(),
],
'require-all' => true,
]
);
self::assertSame(0, $tester->getStatusCode());
self::assertStringContainsString('homepage', $tester->getDisplay());
}
#[TestDox('execute() throws schema validation error without --skip-errors')]
public function testExecuteThrowsOnSchemaFailureWithoutSkipErrors(): void
{
$outputDir = $this->makeOutputDir();
$this->expectException(JsonValidationException::class);
$this->executeSatis(
[
'file' => $this->configPath,
'output-dir' => $outputDir,
],
[
// Intentionally missing "homepage" to trigger schema validation failure.
'name' => 'test/satis-repo',
'repositories' => [],
]
);
}
#[TestDox('execute() applies --repository-url filter')]
public function testExecuteAppliesRepositoryUrlFilter(): void
{
$outputDir = $this->makeOutputDir();
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('do not exist');
$this->executeSatis([
'file' => $this->configPath,
'output-dir' => $outputDir,
'--repository-url' => ['https://example.com/nonexistent.git'],
], $this->baseSatisConfig());
}
#[TestDox('removeDisabledRepositories retains non-matching ConfigurableRepositoryInterface repos')]
public function testRemoveDisabledRepositoriesRetainsNonMatchingRepos(): void
{
Config::$defaultRepositories = [
'packagist.org' => ['type' => 'composer', 'url' => 'https://repo.packagist.org'],
'private' => ['type' => 'composer', 'url' => 'https://private.example.com'],
];
$config = [
'name' => 'test/satis-repo',
'homepage' => 'https://example.com',
'repositories' => [],
];
$composer = (new Factory())->createComposer(new NullIO(), $config, true, null, false);
$manager = $composer->getRepositoryManager();
$countBefore = count($manager->getRepositories());
$this->invokeRemoveDisabledRepositories($manager, ['packagist.org']);
$countAfter = count($manager->getRepositories());
self::assertFalse($this->repositoryManagerContainsPackagist($manager));
self::assertSame($countBefore - 1, $countAfter);
}
#[TestDox('getConfiguration() loads auth.json when present')]
public function testGetConfigurationLoadsAuthJson(): void
{
$homeDir = vfsStream::url('satis/composer-home');
mkdir($homeDir);
file_put_contents(
$homeDir . '/auth.json',
json_encode(['http-basic' => ['example.com' => ['username' => 'user', 'password' => 'pass']]])
);
$originalComposerHome = getenv('COMPOSER_HOME');
putenv('COMPOSER_HOME=' . $homeDir);
try {
$command = new BuildCommand();
$method = new \ReflectionMethod($command, 'getConfiguration');
$config = $method->invoke($command);
self::assertInstanceOf(Config::class, $config);
} finally {
putenv(false === $originalComposerHome ? 'COMPOSER_HOME' : 'COMPOSER_HOME=' . $originalComposerHome);
}
}
#[TestDox('execute() works via ComposerApplication path')]
public function testExecuteWorksViaComposerApplication(): void
{
$outputDir = $this->makeOutputDir();
$tester = $this->createTester();
$this->writeConfig($this->baseSatisConfig());
$tester->execute([
'file' => $this->configPath,
'output-dir' => $outputDir,
]);
self::assertSame(0, $tester->getStatusCode());
self::assertFileExists($outputDir . '/packages.json');
}
#[TestDox('Repository loop skips entries without a type key')]
public function testRepositoryLoopSkipsTypelessEntries(): void
{
$validConfig = [
'name' => 'test/satis-repo',
'homepage' => 'https://example.com',
'repositories' => [],
];
$composer = (new Factory())->createComposer(new NullIO(), $validConfig, true, null, false);
$manager = $composer->getRepositoryManager();
$initialCount = count($manager->getRepositories());
/** @var array<int, mixed> $repositories */
$repositories = [
['url' => 'https://example.com/no-type-repo'],
[
'type' => 'package',
'package' => [
'name' => 'vendor/test-pkg',
'version' => '1.0.0',
'dist' => ['url' => 'https://example.com/test.zip', 'type' => 'zip'],
],
],
];
foreach ($repositories as $repo) {
if (!is_array($repo) || !isset($repo['type'])) {
continue;
}
$manager->addRepository($manager->createRepository($repo['type'], $repo));
}
$addedRepos = count($manager->getRepositories()) - $initialCount;
self::assertSame(1, $addedRepos);
}
#[TestDox('execute() runs ArchiveBuilder when archive directory is configured')]
public function testExecuteRunsArchiveBuilder(): void
{
$outputDir = $this->makeOutputDir();
$this->writeConfig(
$this->baseSatisConfig([
'archive' => ['directory' => 'dist'],
])
);
$tester = $this->createSatisTester();
$tester->execute([
'file' => $this->configPath,
'output-dir' => $outputDir,
'--skip-errors' => true,
]);
self::assertSame(0, $tester->getStatusCode());
}
}