diff --git a/README-es.md b/README-es.md index b234a5ca..98bb8a54 100644 --- a/README-es.md +++ b/README-es.md @@ -294,6 +294,9 @@ Cuando no se especifica `type`, DLoad automáticamente usa todos los manejadores > Usa `type="phar"` para herramientas PHP que deben mantenerse como archivos `.phar`. > Usar `type="archive"` extraerá incluso archivos `.phar`. +> [!NOTE] +> Cuando hay un `` configurado, con `type="binary"` o sin `type`, los paquetes del sistema operativo (`.deb`, `.rpm`, `.apk`, `.snap`, `.flatpak`, `.msi`, `.msix`, `.msixbundle`, `.appx`, `.appxbundle`, `.nupkg`, `.dmg`, `.pkg`) nunca se seleccionan: DLoad no puede desempaquetarlos. Un release que solo ofrece un paquete para el host se omite en favor de uno más antiguo, entre los 10 releases más recientes que cumplen la restricción de versión; fija `version` para ir más atrás. + #### Extracción de Archivos (Preservando la Estructura) `type="archive"` descomprime el asset **completo** en `extract-path`, manteniendo la estructura interna de directorios del archivo en lugar de aplanar los archivos coincidentes en una sola carpeta: diff --git a/README-ru.md b/README-ru.md index 9c006242..0db71419 100644 --- a/README-ru.md +++ b/README-ru.md @@ -295,6 +295,9 @@ DLoad поддерживает три типа загрузки, которые > Используйте `type="phar"` для PHP-инструментов, которые должны остаться как `.phar` файлы. > Использование `type="archive"` распакует даже `.phar` архивы. +> [!NOTE] +> Если задан `` — с `type="binary"` или без `type`, — пакеты ОС (`.deb`, `.rpm`, `.apk`, `.snap`, `.flatpak`, `.msi`, `.msix`, `.msixbundle`, `.appx`, `.appxbundle`, `.nupkg`, `.dmg`, `.pkg`) никогда не выбираются: DLoad не умеет их распаковывать. Релиз, в котором для хоста есть только пакет, пропускается ради более старого — среди 10 новейших релизов, удовлетворяющих ограничению версии; чтобы уйти дальше в прошлое, закрепите `version`. + #### Распаковка архива с сохранением структуры `type="archive"` распаковывает **весь** ассет в `extract-path`, сохраняя внутреннюю структуру каталогов архива, а не сплющивая совпавшие файлы в одну папку: diff --git a/README-zh.md b/README-zh.md index 3c4c3fd4..c8071243 100644 --- a/README-zh.md +++ b/README-zh.md @@ -294,6 +294,9 @@ DLoad 支持三种下载类型,它们决定了资源的处理方式: > 对于应该保持为 `.phar` 文件的 PHP 工具,使用 `type="phar"`。 > 使用 `type="archive"` 会解包甚至 `.phar` 压缩包。 +> [!NOTE] +> 配置了 `` 时(无论是 `type="binary"` 还是未指定 `type`),永远不会选择操作系统安装包(`.deb`、`.rpm`、`.apk`、`.snap`、`.flatpak`、`.msi`、`.msix`、`.msixbundle`、`.appx`、`.appxbundle`、`.nupkg`、`.dmg`、`.pkg`):DLoad 无法解包它们。如果某个发布版本只为当前主机提供安装包,则会跳过它,改用更早的版本,范围限于满足版本约束的最新 10 个发布版本;要回溯更早的版本,请固定 `version`。 + #### 解包压缩包(保留结构) `type="archive"` 会将**整个**资源解包到 `extract-path`,保留压缩包内部的目录结构,而不是把匹配的文件平铺到单个目录中: diff --git a/README.md b/README.md index ef308c48..7c328f74 100644 --- a/README.md +++ b/README.md @@ -296,6 +296,9 @@ When `type` is not specified, DLoad automatically uses all available handlers: > Use `type="phar"` for PHP tools that should remain as `.phar` files. > Using `type="archive"` will unpack even `.phar` archives. +> [!NOTE] +> When a `` is configured, with `type="binary"` or without a `type`, OS packages (`.deb`, `.rpm`, `.apk`, `.snap`, `.flatpak`, `.msi`, `.msix`, `.msixbundle`, `.appx`, `.appxbundle`, `.nupkg`, `.dmg`, `.pkg`) are never selected: DLoad cannot unpack them. A release that offers only a package for the host is skipped for an older one, among the 10 newest releases that satisfy the version constraint; pin `version` to go further back. + #### Archive Extraction (Preserving Structure) `type="archive"` unpacks the **entire** asset into `extract-path`, keeping the archive's internal directory layout instead of flattening matched files into a single folder: diff --git a/skills/dload-fetch-tool/references/registry-entry.md b/skills/dload-fetch-tool/references/registry-entry.md index 2659a959..9ca81dd7 100644 --- a/skills/dload-fetch-tool/references/registry-entry.md +++ b/skills/dload-fetch-tool/references/registry-entry.md @@ -22,7 +22,7 @@ Filters the release's asset list. dload then runs OS/arch detection on every mat - match all OS/arch variants of the tool; - leave out sibling tools, checksums, signatures, source archives. -Build variants stay in the pattern too. Among the assets for the host, dload prefers the host libc (`musl` builds on Alpine and Android, the others elsewhere), then the name with the fewest extra tokens — so `bun-linux-x64.zip` wins over `-baseline`, `-profile` and `-debug` twins — then archives. Android builds are dropped on Linux whenever the entry has a `binary` (without one, every other platform is only ranked lower). On ARM macOS and Windows, x86-64 builds are a fallback: Rosetta 2 and the Windows emulation run them. +Build variants stay in the pattern too. Among the assets for the host, dload prefers the host libc (`musl` builds on Alpine and Android, the others elsewhere), then the name with the fewest extra tokens — so `bun-linux-x64.zip` wins over `-baseline`, `-profile` and `-debug` twins — then archives. Android builds are dropped on Linux whenever the entry has a `binary` (without one, every other platform is only ranked lower), and so are OS packages (`.deb`, `.rpm`, `.apk`, `.snap`, `.flatpak`, `.msi`, `.msix`, `.msixbundle`, `.appx`, `.appxbundle`, `.nupkg`, `.dmg`, `.pkg`) everywhere: dload cannot unpack them, so a release that has only a package for the host gives way to an older one among the 10 newest releases that satisfy `version` (pin `version` to go further back). On ARM macOS and Windows, x86-64 builds are a fallback: Rosetta 2 and the Windows emulation run them. Tokens the OS/arch matchers recognise (case-insensitive, bounded by `_` or a word boundary): diff --git a/skills/dload-fetch-tool/references/troubleshooting.md b/skills/dload-fetch-tool/references/troubleshooting.md index 3d8cc34e..5be68519 100644 --- a/skills/dload-fetch-tool/references/troubleshooting.md +++ b/skills/dload-fetch-tool/references/troubleshooting.md @@ -44,6 +44,8 @@ A candidate that matches neither is discarded. Common offenders: Compare with the host: `php -r "echo PHP_OS_FAMILY, ' / ', php_uname('m'), PHP_EOL;"`. +With a `binary`, OS packages (`.deb`, `.msi`… — the list is in [`registry-entry.md`](registry-entry.md#asset-pattern)) are discarded too, since dload cannot unpack them, and a release offering only packages is skipped for an older one among the 10 newest releases that satisfy `version`, with a warning naming both versions. When the package names carry the host OS and arch, the report says "only OS packages match" and lists them under "OS packages are skipped because a binary is expected"; a package without an OS token (`tool.dmg`) is discarded by the OS check above instead. When no older release fits, pin a `version` that ships an archive or a raw binary for the host — pinning also reaches past the 10 newest. + Several candidates left: dload tries them best first — host libc, then the fewest extra name tokens, then archives, then the release order. Run with `-vvv` to see the rank of every asset (`Asset ... ranked: os=0 arch=0 libc=0 extras=1 archive=0`); a wrong pick usually means the wanted build carries an extra token the others lack. ## Stage 4 — binary extraction diff --git a/src/Module/Downloader/Downloader.php b/src/Module/Downloader/Downloader.php index 203cad75..a1b36b52 100644 --- a/src/Module/Downloader/Downloader.php +++ b/src/Module/Downloader/Downloader.php @@ -19,7 +19,9 @@ use Internal\DLoad\Module\Downloader\Exception\ReleaseGone; use Internal\DLoad\Module\Downloader\Internal\Diagnostics\DownloadDiagnostics; use Internal\DLoad\Module\Downloader\Internal\AssetSelection\AssetSelector; +use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Candidate; use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule\ArchitectureRule; +use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule\PackageRule; use Internal\DLoad\Module\Downloader\Internal\DownloadContext; use Internal\DLoad\Module\Downloader\Task\DownloadResult; use Internal\DLoad\Module\Downloader\Task\DownloadTask; @@ -63,6 +65,12 @@ final class Downloader /** Number of release names collected for the failure report. */ private const FETCHED_RELEASES_LIMIT = 10; + /** + * Number of the newest matching releases tried. Iteration stops after this many matches; a higher cap + * reads further release pages whenever the first pages hold fewer matches. + */ + private const TRIED_RELEASES_LIMIT = 10; + public function __construct( private readonly DownloaderConfig $config, private readonly Logger $logger, @@ -177,6 +185,7 @@ public function download( private function processRepository(Repository $repository, DownloadContext $context, bool $mayRetry = true): \Closure { return function () use ($repository, $context, $mayRetry): ReleaseInterface { + $context->packagesOnlyRelease = null; // Set when a release turned out to be deleted: the release list is outdated then $forgotten = false; @@ -199,7 +208,7 @@ private function processRepository(Repository $repository, DownloadContext $cont } /** @var ReleaseInterface[] $releases */ - $releases = $releasesCollection->limit(10)->sortByVersion()->toArray(); + $releases = $releasesCollection->limit(self::TRIED_RELEASES_LIMIT)->sortByVersion()->toArray(); $this->logger->debug('%d releases found.', \count($releases)); @@ -233,6 +242,18 @@ private function processRepository(Repository $repository, DownloadContext $cont try { await(coroutine($this->processRelease($context))); + + /** @var ReleaseInterface|null $skipped */ + $skipped = $context->packagesOnlyRelease; + $skipped === null or $this->logger->warning( + '`%s` %s has only OS packages for `%s`/`%s`, downloaded %s instead.', + $context->software->getId(), + $skipped->getVersion()->string, + $this->operatingSystem->value, + $this->architecture->value, + $context->release->getVersion()->string, + ); + return $context->release; } catch (ReleaseGone $e) { // The registry must not offer this release again, and the list needs a fresh check @@ -299,10 +320,18 @@ private function processRelease(DownloadContext $context): \Closure ); $this->logger->debug('%d matching assets found.', \count($selection->candidates)); + $skippedPackages = \array_map( + static fn(Candidate $candidate): string => $candidate->asset->getName(), + $selection->removed[PackageRule::KEY] ?? [], + ); + $context->releaseAttempt->registerSkippedPackages($skippedPackages); + + $selection->isEmpty() && $skippedPackages !== [] and $context->packagesOnlyRelease ??= $context->release; $selection->isEmpty() and throw new NotFound( $strict ? \sprintf( - 'no asset matches OS `%s`, architecture `%s`, name pattern `%s`%s', + '%s OS `%s`, architecture `%s`, name pattern `%s`%s', + $skippedPackages === [] ? 'no asset matches' : 'only OS packages match', $this->operatingSystem->value, $this->architecture->value, $context->repoConfig->assetPattern, diff --git a/src/Module/Downloader/Internal/AssetSelection/AssetSelector.php b/src/Module/Downloader/Internal/AssetSelection/AssetSelector.php index 26bcd209..4acbaac6 100644 --- a/src/Module/Downloader/Internal/AssetSelection/AssetSelector.php +++ b/src/Module/Downloader/Internal/AssetSelection/AssetSelector.php @@ -17,6 +17,7 @@ use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule\LibcRule; use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule\NamePatternRule; use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule\OperatingSystemRule; +use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule\PackageRule; use Internal\DLoad\Module\Repository\AssetInterface; use Psr\Container\ContainerInterface; @@ -54,6 +55,9 @@ public function __construct( new CompanionRule(), new OperatingSystemRule($operatingSystem), new ArchitectureRule($architecture, $operatingSystem), + // After the platform rules, so the report names only packages built for the host. A package whose + // name carries no OS token, like `tool.dmg`, is already gone then and stays out of the report. + new PackageRule(), new LibcRule($container), new ExtrasRule(), new ArchiveRule($archiveFactory), diff --git a/src/Module/Downloader/Internal/AssetSelection/Rule/PackageRule.php b/src/Module/Downloader/Internal/AssetSelection/Rule/PackageRule.php new file mode 100644 index 00000000..29bdf75c --- /dev/null +++ b/src/Module/Downloader/Internal/AssetSelection/Rule/PackageRule.php @@ -0,0 +1,41 @@ +strict and $selection = $selection->remove( + static fn(Candidate $candidate): bool => $candidate->hasExtension(self::EXTENSIONS), + self::KEY, + ); + + return $next($selection); + } +} diff --git a/src/Module/Downloader/Internal/AssetSelection/Selection.php b/src/Module/Downloader/Internal/AssetSelection/Selection.php index 6bc105a3..b064e247 100644 --- a/src/Module/Downloader/Internal/AssetSelection/Selection.php +++ b/src/Module/Downloader/Internal/AssetSelection/Selection.php @@ -24,12 +24,15 @@ final class Selection * @param non-empty-string $assetPattern Pattern the asset names must match. * @param Type|null $type Download action type restricting the asset format. * @param bool $strict Whether assets for another OS or architecture are removed rather than ranked lower. + * @param array> $removed Candidates removed with a reason, by the reason, + * for a failure report to name them. */ private function __construct( public readonly array $candidates, public readonly string $assetPattern, public readonly ?Type $type, public readonly bool $strict, + public readonly array $removed = [], ) {} /** @@ -53,13 +56,25 @@ public static function create(iterable $assets, string $assetPattern, ?Type $typ * is a rank, see {@see self::rank()}. * * @param \Closure(Candidate): bool $predicate + * @param non-empty-string|null $reason Key to keep the removed candidates under, see {@see self::$removed}. */ - public function remove(\Closure $predicate): self + public function remove(\Closure $predicate, ?string $reason = null): self { - return $this->withCandidates(\array_values(\array_filter( - $this->candidates, - static fn(Candidate $candidate): bool => !$predicate($candidate), - ))); + $kept = $removed = []; + foreach ($this->candidates as $candidate) { + if ($predicate($candidate)) { + $removed[] = $candidate; + } else { + $kept[] = $candidate; + } + } + + return $this->withCandidates( + $kept, + $reason === null || $removed === [] + ? $this->removed + : [...$this->removed, $reason => [...$this->removed[$reason] ?? [], ...$removed]], + ); } /** @@ -121,9 +136,10 @@ public function assets(): array /** * @param list $candidates + * @param array>|null $removed */ - private function withCandidates(array $candidates): self + private function withCandidates(array $candidates, ?array $removed = null): self { - return new self($candidates, $this->assetPattern, $this->type, $this->strict); + return new self($candidates, $this->assetPattern, $this->type, $this->strict, $removed ?? $this->removed); } } diff --git a/src/Module/Downloader/Internal/Diagnostics/ReleaseAttempt.php b/src/Module/Downloader/Internal/Diagnostics/ReleaseAttempt.php index ec0c57e0..8ea5225b 100644 --- a/src/Module/Downloader/Internal/Diagnostics/ReleaseAttempt.php +++ b/src/Module/Downloader/Internal/Diagnostics/ReleaseAttempt.php @@ -23,6 +23,9 @@ final class ReleaseAttempt /** @var list Names of all assets in the release */ private array $assetNames = []; + /** @var list Names of the OS packages for the host, skipped because a binary is expected */ + private array $skippedPackages = []; + /** @var list Errors occurred while downloading matched assets */ private array $failures = []; @@ -42,6 +45,14 @@ public function registerAssets(array $names): void $this->assetNames = $names; } + /** + * @param list $names Names of the OS packages for the host, skipped because a binary is expected + */ + public function registerSkippedPackages(array $names): void + { + $this->skippedPackages = $names; + } + /** * Registers a failure of a matched asset, e.g. a broken download. * @@ -79,6 +90,11 @@ public function describe(): array ); } + $this->skippedPackages === [] or $lines[] = \sprintf( + ' OS packages are skipped because a binary is expected: %s', + \implode(', ', $this->skippedPackages), + ); + foreach ($this->failures as $failure) { $lines[] = ' Failed asset ' . $failure; } diff --git a/src/Module/Downloader/Internal/DownloadContext.php b/src/Module/Downloader/Internal/DownloadContext.php index e224dcc0..e10e23a5 100644 --- a/src/Module/Downloader/Internal/DownloadContext.php +++ b/src/Module/Downloader/Internal/DownloadContext.php @@ -47,6 +47,13 @@ final class DownloadContext /** @var ReleaseAttempt Diagnostics of the release being processed */ public ReleaseAttempt $releaseAttempt; + /** + * @var ReleaseInterface|null Newest release of the current repository pass skipped because it has only + * OS packages for the host. Another repository or a refetched release list starts over, so a version + * found elsewhere is not reported against it. + */ + public ?ReleaseInterface $packagesOnlyRelease = null; + /** * Creates a new download context. * diff --git a/tests/Unit/Module/Downloader/DownloaderTest.php b/tests/Unit/Module/Downloader/DownloaderTest.php index 45b01680..9efa9204 100644 --- a/tests/Unit/Module/Downloader/DownloaderTest.php +++ b/tests/Unit/Module/Downloader/DownloaderTest.php @@ -19,6 +19,7 @@ use Internal\DLoad\Module\Downloader\Exception\DownloadFailed; use Internal\DLoad\Module\Downloader\Task\DownloadResult; use Internal\DLoad\Module\Repository\Collection\ReleasesCollection; +use Internal\DLoad\Module\Repository\Exception\AssetNotFoundException; use Internal\DLoad\Module\Repository\Exception\RateLimitException; use Internal\DLoad\Module\Repository\Repository; use Internal\DLoad\Module\Repository\RepositoryProvider; @@ -34,6 +35,7 @@ use Internal\DLoad\Tests\Unit\Module\Repository\Stub\RepositoryStub; use Internal\Path; use Internal\DLoad\Tests\Unit\Module\Downloader\Internal\AssetSelection\Stub\LibcContainer; +use Symfony\Component\Console\Output\BufferedOutput; use Testo\Assert; use Testo\Codecov\Covers; use Testo\Data\DataProvider; @@ -61,6 +63,60 @@ public static function provideGradualStrategies(): \Generator yield 'architecture only' => [null, Architecture::X86_64]; } + public static function providePackagesOnlyWarnings(): \Generator + { + yield 'the newest packages-only release is named' => [ + [self::repoWithReleases([ + 'v3.5.0' => ['roadrunner-3.5.0-linux-arm64.deb'], + 'v3.4.0' => ['roadrunner-3.4.0-linux-arm64.rpm'], + 'v3.2.0' => ['roadrunner-3.2.0-linux-arm64.tar.gz'], + ])], + 'v3.2.0', + '`rr` v3.5.0 has only OS packages for `linux`/`arm64`, downloaded v3.2.0 instead.', + ]; + yield 'a failed binary is not a packages-only release' => [ + [self::repoWithReleases( + [ + 'v3.4.0' => ['roadrunner-3.4.0-linux-arm64.deb', 'roadrunner-3.4.0-linux-arm64.tar.gz'], + 'v3.2.0' => ['roadrunner-3.2.0-linux-arm64.tar.gz'], + ], + ['roadrunner-3.4.0-linux-arm64.tar.gz' => new \RuntimeException('broken')], + )], + 'v3.2.0', + null, + ]; + yield 'a release without packages is not a packages-only release' => [ + [self::repoWithReleases([ + 'v3.4.0' => ['roadrunner-3.4.0-darwin-arm64.tar.gz'], + 'v3.2.0' => ['roadrunner-3.2.0-linux-arm64.tar.gz'], + ])], + 'v3.2.0', + null, + ]; + yield 'a packages-only release of another repository is forgotten' => [ + [ + self::repoWithReleases(['v3.4.0' => ['roadrunner-3.4.0-linux-arm64.deb']]), + self::repoWithReleases(['v3.4.0' => ['roadrunner-3.4.0-linux-arm64.tar.gz']]), + ], + 'v3.4.0', + null, + ]; + yield 'a packages-only release of an outdated list is forgotten' => [ + new SequenceRepositoryFactoryStub([ + self::repoWithReleases( + [ + 'v3.4.0' => ['roadrunner-3.4.0-linux-arm64.deb'], + 'v3.3.0' => ['roadrunner-3.3.0-linux-arm64.tar.gz'], + ], + ['roadrunner-3.3.0-linux-arm64.tar.gz' => new AssetNotFoundException('gone', 'owner/repo')], + ), + self::repoWithReleases(['v3.5.0' => ['roadrunner-3.5.0-linux-arm64.tar.gz']]), + ]), + 'v3.5.0', + null, + ]; + } + #[Test] public function deletedReleaseIsForgottenAndTheNextOneIsUsed(): void { @@ -241,6 +297,82 @@ public function strictFilteringFailsWhenNoAssetMatchesAllCriteria(): void } } + #[Test] + public function aReleaseWithOnlyAPackageForTheHostGivesWayToAnOlderOne(): void + { + $repository = self::repoWithReleases([ + 'v3.4.0' => ['roadrunner-3.4.0-linux-arm64.deb', 'roadrunner-3.4.0-linux-amd64.tar.gz', 'roadrunner-3.4.0-linux-amd64.deb'], + 'v3.2.0' => ['roadrunner-3.2.0-linux-arm64.tar.gz', 'roadrunner-3.2.0-linux-amd64.tar.gz'], + ]); + $config = DownloadConfig::fromSoftwareId('rr'); + $config->version = '3.*'; + + $output = new BufferedOutput(); + + $result = $this->run([$repository], self::software(binary: true), $config, Architecture::ARM_64, new Logger($output)); + + Assert::same($result->version->string, 'v3.2.0'); + Assert::same($result->file->getFilename(), 'roadrunner-3.2.0-linux-arm64.tar.gz'); + Assert::string($output->fetch())->contains('`rr` v3.4.0 has only OS packages for `linux`/`arm64`, downloaded v3.2.0 instead.'); + } + + /** + * @param list|SequenceRepositoryFactoryStub $repositories + * @param non-empty-string $version Expected downloaded version. + * @param non-empty-string|null $warning Expected warning; `null` when none is expected. + */ + #[Test] + #[DataProvider('providePackagesOnlyWarnings')] + public function packagesOnlyWarningNamesOnlyAReleaseSkippedForItsPackages( + array|SequenceRepositoryFactoryStub $repositories, + string $version, + ?string $warning, + ): void { + $software = self::software(binary: true, repositories: \is_array($repositories) ? \count($repositories) : 1); + $output = new BufferedOutput(); + + $result = $this->run($repositories, $software, architecture: Architecture::ARM_64, logger: new Logger($output)); + + Assert::same($result->version->string, $version); + $warning === null + ? Assert::string($output->fetch())->notContains('has only OS packages') + : Assert::string($output->fetch())->contains($warning); + } + + #[Test] + public function withoutABinaryThePackageIsStillChosen(): void + { + $repository = self::repoWithReleases([ + 'v3.4.0' => ['roadrunner-3.4.0-linux-arm64.deb', 'roadrunner-3.4.0-linux-amd64.tar.gz'], + 'v3.2.0' => ['roadrunner-3.2.0-linux-arm64.tar.gz'], + ]); + $config = DownloadConfig::fromSoftwareId('rr'); + $config->version = '3.*'; + + $result = $this->run([$repository], self::software(), $config, Architecture::ARM_64); + + Assert::same($result->version->string, 'v3.4.0'); + Assert::same($result->file->getFilename(), 'roadrunner-3.4.0-linux-arm64.deb'); + } + + #[Test] + public function skippedPackagesAreNamedInTheReport(): void + { + $repository = self::repoWithReleases([ + 'v3.4.0' => ['roadrunner-3.4.0-linux-arm64.deb', 'roadrunner-3.4.0-linux-amd64.deb', 'roadrunner-3.4.0-linux-arm64.rpm', 'roadrunner-3.4.0-linux-amd64.tar.gz'], + ]); + + try { + $this->run([$repository], self::software(binary: true), architecture: Architecture::ARM_64); + Assert::fail('DownloadFailed is expected when the release has only packages for the host.'); + } catch (DownloadFailed $e) { + Assert::string($e->report) + ->contains('v3.4.0: 4 asset(s), only OS packages match OS `linux`, architecture `arm64`') + ->contains('OS packages are skipped because a binary is expected: roadrunner-3.4.0-linux-arm64.deb, roadrunner-3.4.0-linux-arm64.rpm') + ->notContains('no asset matches'); + } + } + #[Test] public function versionConstraintSelectsMatchingRelease(): void { @@ -407,6 +539,34 @@ private static function software(bool $binary = false, int $repositories = 1): S return Software::fromArray($data); } + /** + * A repository with a release for each tag; the OS and architecture of an asset come from its name. + * + * @param array> $releases Asset names by release tag. + * @param array $failures Download failures by asset name. + */ + private static function repoWithReleases(array $releases, array $failures = []): RepositoryStub + { + $repository = new RepositoryStub('owner/repo'); + $list = []; + foreach ($releases as $tag => $names) { + $release = new ReleaseStub($repository, $tag, Version::fromVersionString($tag)); + $release->setAssets(\array_map( + static fn(string $name): ConfigurableAssetStub => new ConfigurableAssetStub( + $release, + $name, + OperatingSystem::tryFromBuildName($name), + Architecture::tryFromBuildName($name), + $failures[$name] ?? null, + ), + $names, + )); + $list[] = $release; + } + + return new RepositoryStub('owner/repo', ReleasesCollection::create($list)); + } + /** * @param list|SequenceRepositoryFactoryStub $repositories * @param non-empty-string $uri Repository URI as written in the config. @@ -449,6 +609,8 @@ private function run( array|SequenceRepositoryFactoryStub $repositories, Software $software, ?DownloadConfig $config = null, + Architecture $architecture = Architecture::X86_64, + Logger $logger = new Logger(), ): DownloadResult { $factory = $repositories instanceof SequenceRepositoryFactoryStub ? $repositories @@ -457,7 +619,7 @@ private function run( $downloaderConfig = new DownloaderConfig(); $downloaderConfig->tmpDir = $this->tempDir; - $task = $this->makeDownloader($factory, $downloaderConfig)->download( + $task = $this->makeDownloader($factory, $downloaderConfig, $architecture, $logger)->download( $software, $config ?? DownloadConfig::fromSoftwareId('rr'), static fn(): null => null, @@ -467,18 +629,22 @@ private function run( return await(($task->handler)()); } - private function makeDownloader(SequenceRepositoryFactoryStub $factory, DownloaderConfig $config): Downloader - { + private function makeDownloader( + SequenceRepositoryFactoryStub $factory, + DownloaderConfig $config, + Architecture $architecture = Architecture::X86_64, + Logger $logger = new Logger(), + ): Downloader { return new Downloader( config: $config, - logger: new Logger(), + logger: $logger, repositoryProvider: (new RepositoryProvider())->addRepositoryFactory($factory), - architecture: Architecture::tryFromString('amd64') ?? throw new \LogicException(), + architecture: $architecture, operatingSystem: OperatingSystem::tryFromString('linux') ?? throw new \LogicException(), stability: Stability::Stable, archiveService: new ArchiveFactory(), registry: $this->registry, - assetSelector: new AssetSelector(OperatingSystem::Linux, Architecture::X86_64, new LibcContainer(Libc::Gnu), new ArchiveFactory()), + assetSelector: new AssetSelector(OperatingSystem::Linux, $architecture, new LibcContainer(Libc::Gnu), new ArchiveFactory()), ); } diff --git a/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php b/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php index 364fbf95..beac7e58 100644 --- a/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php +++ b/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php @@ -19,6 +19,7 @@ use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule\LibcRule; use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule\NamePatternRule; use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule\OperatingSystemRule; +use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule\PackageRule; use Internal\DLoad\Module\Repository\AssetInterface; use Internal\DLoad\Tests\Unit\Module\Downloader\Internal\AssetSelection\Stub\NamedAssets; use Internal\DLoad\Tests\Unit\Module\Downloader\Internal\AssetSelection\Stub\LibcContainer; @@ -29,6 +30,7 @@ #[Covers(AssetSelector::class)] #[Covers(NamePatternRule::class)] #[Covers(FormatRule::class)] +#[Covers(PackageRule::class)] #[Covers(CompanionRule::class)] #[Covers(OperatingSystemRule::class)] #[Covers(ArchitectureRule::class)] @@ -206,11 +208,19 @@ public function aChecksumNeverOutranksABuild(): void #[Test] public function archivesComeBeforeOtherFiles(): void { - $names = self::select(['tool-linux-amd64', 'tool-linux-amd64.deb', 'tool-linux-amd64.tar.gz']); + $names = self::select(['tool-linux-amd64', 'tool-linux-amd64.deb', 'tool-linux-amd64.tar.gz'], strict: false); Assert::same($names, ['tool-linux-amd64.tar.gz', 'tool-linux-amd64', 'tool-linux-amd64.deb']); } + #[Test] + public function archivesAndPlainBinariesOutliveOsPackages(): void + { + $names = self::select(['tool-linux-amd64', 'tool-linux-amd64.deb', 'tool-linux-amd64.tar.gz']); + + Assert::same($names, ['tool-linux-amd64.tar.gz', 'tool-linux-amd64']); + } + /** * @param list $assets * @param non-empty-string $pattern diff --git a/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/PackageRuleTest.php b/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/PackageRuleTest.php new file mode 100644 index 00000000..8cb8e713 --- /dev/null +++ b/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/PackageRuleTest.php @@ -0,0 +1,60 @@ +removed), [PackageRule::KEY]); + Assert::same( + \array_map(static fn(Candidate $candidate): string => $candidate->asset->getName(), $selection->removed[PackageRule::KEY]), + self::PACKAGES, + ); + } + + #[Test] + public function packagesAreKeptWhenNoBinaryIsExpected(): void + { + $selection = RuleRunner::run(new PackageRule(), [...self::OTHERS, ...self::PACKAGES], strict: false); + + Assert::same(RuleRunner::names($selection), [...self::OTHERS, ...self::PACKAGES]); + Assert::same($selection->removed, []); + } +} diff --git a/tests/Unit/Module/Downloader/Internal/AssetSelection/SelectionTest.php b/tests/Unit/Module/Downloader/Internal/AssetSelection/SelectionTest.php index 76dd730e..b0e758ad 100644 --- a/tests/Unit/Module/Downloader/Internal/AssetSelection/SelectionTest.php +++ b/tests/Unit/Module/Downloader/Internal/AssetSelection/SelectionTest.php @@ -31,6 +31,24 @@ public function removedCandidatesAreGone(): void Assert::same(self::names($selection), ['a', 'c']); } + #[Test] + public function candidatesRemovedWithAReasonAreKeptUnderIt(): void + { + $selection = self::selection('a', 'b', 'c', 'd') + ->remove(static fn(Candidate $candidate): bool => $candidate->asset->getName() === 'a') + ->remove(static fn(Candidate $candidate): bool => $candidate->asset->getName() === 'b', 'package') + ->rank('length', static fn(): int => 0) + ->remove(static fn(Candidate $candidate): bool => $candidate->asset->getName() === 'c', 'package') + ->remove(static fn(): bool => false, 'nothing'); + + Assert::same(self::names($selection), ['d']); + Assert::same(\array_keys($selection->removed), ['package']); + Assert::same( + \array_map(static fn(Candidate $candidate): string => $candidate->asset->getName(), $selection->removed['package']), + ['b', 'c'], + ); + } + #[Test] public function anEarlierRankOutweighsALaterOne(): void { diff --git a/tests/Unit/Module/Downloader/Internal/Diagnostics/DownloadDiagnosticsTest.php b/tests/Unit/Module/Downloader/Internal/Diagnostics/DownloadDiagnosticsTest.php index df1e7d30..ab0adc1b 100644 --- a/tests/Unit/Module/Downloader/Internal/Diagnostics/DownloadDiagnosticsTest.php +++ b/tests/Unit/Module/Downloader/Internal/Diagnostics/DownloadDiagnosticsTest.php @@ -84,6 +84,39 @@ public function reportListsCheckedReleasesWithTheirAssets(): void Assert::string($report)->contains('Failed asset `app-1.1.0-linux-amd64.tar.gz`: Broken archive'); } + #[Test] + public function reportNamesTheSkippedPackages(): void + { + $diagnostics = self::diagnostics(); + $repository = $diagnostics->addRepository('github', 'owner/repo', '/^app-.*/'); + $repository->matchedReleases = 1; + + $release = $repository->addRelease('v1.2.0'); + $release->registerAssets(['app-1.2.0-linux-amd64.deb', 'app-1.2.0-linux-amd64.rpm']); + $release->registerSkippedPackages(['app-1.2.0-linux-amd64.deb', 'app-1.2.0-linux-amd64.rpm']); + $release->addFailure('app-1.2.0-linux-amd64.tar.gz', new \RuntimeException('broken')); + + // Between the asset list and the failures, indented like them + Assert::same(\array_slice($release->describe(), 1), [ + ' Assets: app-1.2.0-linux-amd64.deb, app-1.2.0-linux-amd64.rpm', + ' OS packages are skipped because a binary is expected: app-1.2.0-linux-amd64.deb, app-1.2.0-linux-amd64.rpm', + ' Failed asset `app-1.2.0-linux-amd64.tar.gz`: broken', + ]); + Assert::string($diagnostics->render()) + ->contains('OS packages are skipped because a binary is expected: app-1.2.0-linux-amd64.deb, app-1.2.0-linux-amd64.rpm'); + } + + #[Test] + public function reportOmitsTheSkippedPackagesLineWhenNoneAreSkipped(): void + { + $diagnostics = self::diagnostics(); + $repository = $diagnostics->addRepository('github', 'owner/repo', '/^app-.*/'); + $repository->matchedReleases = 1; + $repository->addRelease('v1.2.0')->registerAssets(['app-1.2.0-darwin-arm64.tar.gz']); + + Assert::string($diagnostics->render())->notContains('OS packages are skipped'); + } + #[Test] public function reportContainsRepositoryLevelError(): void {