[PHP-WEBMASTER] [web-downloads] main: Add a command to generate listing

Author: Shivam Mathur (shivammathur)
Date: 2025-02-04T15:47:35+05:30

Commit: Add a command to generate listing · php/web-downloads@676f111 · GitHub
Raw diff: https://github.com/php/web-downloads/commit/676f111092dd99e605497b1a421d68e13399c282.diff

Add a command to generate listing

Add support to do DI in commands

Fix autoloading in tests

Changed paths:
  A src/Actions/GetListing.php
  A src/Actions/UpdateReleasesJson.php
  A src/Console/Command/GenerateListingCommand.php
  A tests/Actions/GetListingTest.php
  A tests/Actions/UpdateReleasesJsonTest.php
  A tests/Console/Command/GenerateListingCommandTest.php
  M phpunit.xml.dist
  M runner.php
  M src/Console/Command.php
  M src/Console/Command/PhpCommand.php
  M tests/CommandTest.php
  M tests/Console/Command/PhpCommandTest.php

Diff:

diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index 659c34b..c4916f0 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance&quot;
          xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
- bootstrap="vendor/autoload.php"
+ bootstrap="autoloader.php"
          cacheDirectory=".phpunit.cache"
          executionOrder="depends,defects"
          shortenArraysForExportThreshold="10"
diff --git a/runner.php b/runner.php
index ca3638d..970cb9f 100644
--- a/runner.php
+++ b/runner.php
@@ -27,7 +27,8 @@ function discoverCommands(string $directory, $argc, $argv): array
         if ($file->isFile() && $file->getExtension() === 'php') {
             $className = getClassName($directory, $file);
             if (is_subclass_of($className, Command::class)) {
- $instance = new $className($argc, $argv);
+ $instance = resolve($className);
+ $instance->setCliArguments($argc, $argv);
                 $commands[$instance->getSignature()] = $instance;
             }
         }
@@ -35,6 +36,28 @@ function discoverCommands(string $directory, $argc, $argv): array
     return $commands;
}

+function resolve(string $className) {
+ $reflection = new ReflectionClass($className);
+ $constructor = $reflection->getConstructor();
+ if (!$constructor) {
+ return new $className;
+ }
+ $parameters = $constructor->getParameters();
+ $dependencies = ;
+ foreach ($parameters as $parameter) {
+ $type = $parameter->getType();
+ if ($type && !$type->isBuiltin()) {
+ $dependencyClass = $type->getName();
+ $dependencies = resolve($dependencyClass);
+ } elseif ($parameter->isDefaultValueAvailable()) {
+ $dependencies = $parameter->getDefaultValue();
+ } else {
+ throw new Exception("Cannot resolve dependency: " . $parameter->getName());
+ }
+ }
+ return $reflection->newInstanceArgs($dependencies);
+}
+
function listCommands(array $commands): void
{
     echo "Available commands:\n";
diff --git a/src/Actions/GetListing.php b/src/Actions/GetListing.php
new file mode 100644
index 0000000..c36e2dc
--- /dev/null
+++ b/src/Actions/GetListing.php
@@ -0,0 +1,134 @@
+<?php
+
+namespace App\Actions;
+
+class GetListing
+{
+ public function handle(string $directory): array
+ {
+ $builds = glob($directory . '/php-[678].*[0-9]-latest.zip');
+ if (empty($builds)) {
+ $builds = glob($directory . '/php-[678].*[0-9].zip');
+ }
+
+ $releases = ;
+ $sha256sums = $this->getSha256Sums($directory);
+ foreach ($builds as $file) {
+ $file_ori = $file;
+ $mtime = date('Y-M-d H:i:s', filemtime($file));
+
+ $parts = $this->parseFileName(basename($file));
+ $key = ($parts['nts'] ? 'nts-' : 'ts-') . $parts['vc'] . '-' . $parts['arch'];
+ $version_short = $parts['version_short'];
+ if (!isset($releases['version'])) {
+ $releases[$version_short]['version'] = $parts['version'];
+ }
+ $releases[$version_short][$key]['mtime'] = $mtime;
+ $releases[$version_short][$key]['zip'] = [
+ 'path' => basename($file_ori),
+ 'size' => $this->bytes2string(filesize($file_ori)),
+ 'sha256' => $sha256sums[strtolower(basename($file_ori))]
+ ];
+ $namingPattern = $parts['version'] . ($parts['nts'] ? '-' . $parts['nts'] : '') . '-Win32-' . $parts['vc'] . '-' . $parts['arch'] . ($parts['ts'] ? '-' . $parts['ts'] : '');
+ $build_types = [
+ 'source' => 'php-' . $parts['version'] . '-src.zip',
+ 'debug_pack' => 'php-debug-pack-' . $namingPattern . '.zip',
+ 'devel_pack' => 'php-devel-pack-' . $namingPattern . '.zip',
+ 'installer' => 'php-' . $namingPattern . '.msi',
+ 'test_pack' => 'php-test-pack-' . $parts['version'] . '.zip',
+ ];
+ foreach ($build_types as $type => $fileName) {
+ $filePath = $directory . '/' . $fileName;
+ if (file_exists($filePath)) {
+ if(in_array($type, ['test_pack', 'source'])) {
+ $releases[$version_short][$type] = [
+ 'path' => $fileName,
+ 'size' => $this->bytes2string(filesize($filePath)),
+ 'sha256' => $sha256sums[strtolower(basename($file_ori))]
+ ];
+ } else {
+ $releases[$version_short][$key][$type] = [
+ 'path' => $fileName,
+ 'size' => $this->bytes2string(filesize($filePath)),
+ 'sha256' => $sha256sums[strtolower(basename($file_ori))]
+ ];
+ }
+ }
+ }
+ }
+ return $releases;
+ }
+
+ public function getSha256Sums($directory): array
+ {
+ $result = ;
+ if(!file_exists("$directory/sha256sum.txt")) {
+ file_put_contents("$directory/sha256sum.txt", '');
+ }
+ $sha_file = fopen("$directory/sha256sum.txt", 'w');
+ foreach (scandir($directory) as $filename) {
+ if (pathinfo($filename, PATHINFO_EXTENSION) !== 'zip') {
+ continue;
+ }
+ $sha256 = hash_file('sha256', "$directory/$filename");
+ fwrite($sha_file, "$sha256 *$filename\n");
+ $result[strtolower(basename($filename))] = $sha256;
+ }
+ fclose($sha_file);
+ return $result;
+ }
+
+ public function bytes2string(int $size): string
+ {
+ $sizes = ['YB', 'ZB', 'EB', 'PB', 'TB', 'GB', 'MB', 'kB', 'B'];
+
+ $total = count($sizes);
+
+ while ($total-- && $size > 1024) $size /= 1024;
+
+ return round($size, 2) . $sizes[$total];
+ }
+
+ public function parseFileName($fileName): array
+ {
+ $fileName = str_replace(['-Win32', '.zip'], ['', ''], $fileName);
+
+ $parts = explode('-', $fileName);
+ if (is_numeric($parts[2]) || $parts[2] == 'dev') {
+ $version = $parts[1] . '-' . $parts[2];
+ $nts = $parts[3] == 'nts' ? 'nts' : false;
+ if ($nts) {
+ $vc = $parts[4];
+ $arch = $parts[5];
+ } else {
+ $vc = $parts[3];
+ $arch = $parts[4];
+ }
+ } elseif ($parts[2] == 'nts') {
+ $nts = 'nts';
+ $version = $parts[1];
+ $vc = $parts[3];
+ $arch = $parts[4];
+ } else {
+ $nts = false;
+ $version = $parts[1];
+ $vc = $parts[2];
+ $arch = $parts[3];
+ }
+ if (is_numeric($vc)) {
+ $vc = 'VC6';
+ $arch = 'x86';
+ }
+ $t = count($parts) - 1;
+ $ts = is_numeric($parts[$t]) ? $parts[$t] : false;
+
+ return [
+ 'version' => $version,
+ 'version_short' => substr($version, 0, 3),
+ 'nts' => $nts,
+ 'vc' => $vc,
+ 'arch' => $arch,
+ 'ts' => $ts
+ ];
+ }
+}
\ No newline at end of file
diff --git a/src/Actions/UpdateReleasesJson.php b/src/Actions/UpdateReleasesJson.php
new file mode 100644
index 0000000..faad68e
--- /dev/null
+++ b/src/Actions/UpdateReleasesJson.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace App\Actions;
+
+use DateTimeImmutable;
+use Exception;
+
+class UpdateReleasesJson
+{
+ /**
+ * @throws Exception
+ */
+ public function handle(array $releases, string $directory): void
+ {
+ try {
+ foreach ($releases as &$release) {
+ foreach ($release as &$build_type) {
+ if (!is_array($build_type) || !isset($build_type['mtime'])) {
+ continue;
+ }
+
+ $date = new DateTimeImmutable($build_type['mtime']);
+ $build_type['mtime'] = $date->format('c');
+ }
+ unset($build_type);
+ }
+ unset($release);
+ file_put_contents(
+ $directory . '/releases.json',
+ json_encode($releases, JSON_PRETTY_PRINT)
+ );
+ } catch (Exception $exception) {
+ throw new Exception('Failed to generate releases.json: ' . $exception->getMessage());
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Console/Command.php b/src/Console/Command.php
index 56d9ac6..a59f9d1 100644
--- a/src/Console/Command.php
+++ b/src/Console/Command.php
@@ -9,21 +9,19 @@ abstract class Command
     public const INVALID = 2;

     protected string $signature = '';
-
     protected string $description = '';
+ protected array $arguments = ;
+ protected array $options = ;

- public function __construct(
- protected ?int $argc = null,
- protected ?array $argv = null,
- protected array $arguments = ,
- protected array $options = ,
- ) {
- if ($argc !== null && $argv !== null) {
- $this->parse($argc, $argv);
- }
+ public function __construct() {
+ //
+ }
+
+ public function setCliArguments(int $argc, array $argv): void {
+ $this->parse($argc, $argv);
     }
- abstract public function handle();

+ abstract public function handle(): int;

     private function parse($argc, $argv): void {
         $pattern = '/\{(\w+)}|\{--(\w+)}/';
diff --git a/src/Console/Command/GenerateListingCommand.php b/src/Console/Command/GenerateListingCommand.php
new file mode 100644
index 0000000..ecae3b5
--- /dev/null
+++ b/src/Console/Command/GenerateListingCommand.php
@@ -0,0 +1,38 @@
+<?php
+
+namespace App\Console\Command;
+
+use App\Actions\GetListing;
+use App\Actions\UpdateReleasesJson;
+use App\Console\Command;
+use Exception;
+
+class GenerateListingCommand extends Command
+{
+ protected string $signature = 'php:add --directory=';
+ protected string $description = 'Generate Listing for PHP builds in a directory';
+
+ public function __construct(
+ protected GetListing $generateListing,
+ protected UpdateReleasesJson $updateReleasesJson,
+ ) {
+ parent::__construct();
+ }
+
+ public function handle(): int
+ {
+ try {
+ $directory = $this->getOption('directory');
+ if (!$directory) {
+ throw new Exception('Directory is required');
+ }
+
+ $releases = $this->generateListing->handle($directory);
+ $this->updateReleasesJson->handle($releases, $directory);
+ return Command::SUCCESS;
+ } catch (Exception $e) {
+ echo $e->getMessage();
+ return Command::FAILURE;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Console/Command/PhpCommand.php b/src/Console/Command/PhpCommand.php
index 2d98190..86f8044 100644
--- a/src/Console/Command/PhpCommand.php
+++ b/src/Console/Command/PhpCommand.php
@@ -2,9 +2,10 @@

namespace App\Console\Command;

+use App\Actions\GetListing;
+use App\Actions\UpdateReleasesJson;
use App\Console\Command;
use App\Helpers\Helpers;
-use DateTimeImmutable;
use Exception;
use ZipArchive;

@@ -15,6 +16,13 @@ class PhpCommand extends Command

     protected ?string $baseDirectory = null;

+ public function __construct(
+ protected GetListing $generateListing,
+ protected UpdateReleasesJson $updateReleasesJson,
+ ) {
+ parent::__construct();
+ }
+
     public function handle(): int
     {
         try {
@@ -71,7 +79,12 @@ public function handle(): int

                 $this->moveBuild($tempDirectory, $destinationDirectory);

- $this->generateListing($destinationDirectory);
+ $releases = $this->generateListing->handle($destinationDirectory);
+
+ $this->updateReleasesJson->handle($releases, $destinationDirectory);
+ if ($destinationDirectory === $this->baseDirectory . '/releases') {
+ $this->updateLatestBuilds($releases, $destinationDirectory);
+ }

                 (new Helpers)->rmdirr($tempDirectory);

@@ -156,96 +169,6 @@ private function getFileVersion(string $file): string
         return str_replace('.zip', '', $parts[0]);
     }

- /**
- * @throws Exception
- */
- private function generateListing(string $directory): void
- {
- $builds = glob($directory . '/php-[678].*[0-9]-latest.zip');
- if (empty($builds)) {
- $builds = glob($directory . '/php-[678].*[0-9].zip');
- }
-
- $releases = ;
- $sha256sums = $this->getSha256Sums($directory);
- foreach ($builds as $file) {
- $file_ori = $file;
- $mtime = date('Y-M-d H:i:s', filemtime($file));
-
- $parts = $this->parseFileName(basename($file));
- $key = ($parts['nts'] ? 'nts-' : 'ts-') . $parts['vc'] . '-' . $parts['arch'];
- $version_short = $parts['version_short'];
- if (!isset($releases['version'])) {
- $releases[$version_short]['version'] = $parts['version'];
- }
- $releases[$version_short][$key]['mtime'] = $mtime;
- $releases[$version_short][$key]['zip'] = [
- 'path' => basename($file_ori),
- 'size' => $this->bytes2string(filesize($file_ori)),
- 'sha256' => $sha256sums[strtolower(basename($file_ori))]
- ];
- $namingPattern = $parts['version'] . ($parts['nts'] ? '-' . $parts['nts'] : '') . '-Win32-' . $parts['vc'] . '-' . $parts['arch'] . ($parts['ts'] ? '-' . $parts['ts'] : '');
- $build_types = [
- 'source' => 'php-' . $parts['version'] . '-src.zip',
- 'debug_pack' => 'php-debug-pack-' . $namingPattern . '.zip',
- 'devel_pack' => 'php-devel-pack-' . $namingPattern . '.zip',
- 'installer' => 'php-' . $namingPattern . '.msi',
- 'test_pack' => 'php-test-pack-' . $parts['version'] . '.zip',
- ];
- foreach ($build_types as $type => $fileName) {
- $filePath = $directory . '/' . $fileName;
- if (file_exists($filePath)) {
- if(in_array($type, ['test_pack', 'source'])) {
- $releases[$version_short][$type] = [
- 'path' => $fileName,
- 'size' => $this->bytes2string(filesize($filePath)),
- 'sha256' => $sha256sums[strtolower(basename($file_ori))]
- ];
- } else {
- $releases[$version_short][$key][$type] = [
- 'path' => $fileName,
- 'size' => $this->bytes2string(filesize($filePath)),
- 'sha256' => $sha256sums[strtolower(basename($file_ori))]
- ];
- }
- }
- }
- }
-
- $this->updateReleasesJson($releases, $directory);
- if ($directory === $this->baseDirectory . '/releases') {
- $this->updateLatestBuilds($releases, $directory);
- }
- }
-
- /**
- * @throws Exception
- */
- private function updateReleasesJson(array $releases, string $directory): void
- {
- foreach ($releases as &$release) {
- foreach ($release as &$build_type) {
- if (!is_array($build_type) || !isset($build_type['mtime'])) {
- continue;
- }
-
- try {
- $date = new DateTimeImmutable($build_type['mtime']);
- $build_type['mtime'] = $date->format('c');
- } catch (Exception $exception) {
- throw new Exception('Failed to generate releases.json: ' . $exception->getMessage());
- }
- }
- unset($build_type);
- }
- unset($release);
-
- file_put_contents(
- $directory . '/releases.json',
- json_encode($releases, JSON_PRETTY_PRINT)
- );
- }
-
     private function updateLatestBuilds($releases, $directory): void
     {
         if(!is_dir($directory . '/latest')) {
@@ -262,77 +185,4 @@ private function updateLatestBuilds($releases, $directory): void
             });
         }
     }
-
- private function getSha256Sums($directory): array
- {
- $result = ;
- if(!file_exists("$directory/sha256sum.txt")) {
- file_put_contents("$directory/sha256sum.txt", '');
- }
- $sha_file = fopen("$directory/sha256sum.txt", 'w');
- foreach (scandir($directory) as $filename) {
- if (pathinfo($filename, PATHINFO_EXTENSION) !== 'zip') {
- continue;
- }
- $sha256 = hash_file('sha256', "$directory/$filename");
- fwrite($sha_file, "$sha256 *$filename\n");
- $result[strtolower(basename($filename))] = $sha256;
- }
- fclose($sha_file);
- return $result;
- }
-
- private function bytes2string(int $size): string
- {
- $sizes = ['YB', 'ZB', 'EB', 'PB', 'TB', 'GB', 'MB', 'kB', 'B'];
-
- $total = count($sizes);
-
- while ($total-- && $size > 1024) $size /= 1024;
-
- return round($size, 2) . $sizes[$total];
- }
-
- private function parseFileName($fileName): array
- {
- $fileName = str_replace(['-Win32', '.zip'], ['', ''], $fileName);
-
- $parts = explode('-', $fileName);
- if (is_numeric($parts[2]) || $parts[2] == 'dev') {
- $version = $parts[1] . '-' . $parts[2];
- $nts = $parts[3] == 'nts' ? 'nts' : false;
- if ($nts) {
- $vc = $parts[4];
- $arch = $parts[5];
- } else {
- $vc = $parts[3];
- $arch = $parts[4];
- }
- } elseif ($parts[2] == 'nts') {
- $nts = 'nts';
- $version = $parts[1];
- $vc = $parts[3];
- $arch = $parts[4];
- } else {
- $nts = false;
- $version = $parts[1];
- $vc = $parts[2];
- $arch = $parts[3];
- }
- if (is_numeric($vc)) {
- $vc = 'VC6';
- $arch = 'x86';
- }
- $t = count($parts) - 1;
- $ts = is_numeric($parts[$t]) ? $parts[$t] : false;
-
- return [
- 'version' => $version,
- 'version_short' => substr($version, 0, 3),
- 'nts' => $nts,
- 'vc' => $vc,
- 'arch' => $arch,
- 'ts' => $ts
- ];
- }
}
\ No newline at end of file
diff --git a/tests/Actions/GetListingTest.php b/tests/Actions/GetListingTest.php
new file mode 100644
index 0000000..985bcf7
--- /dev/null
+++ b/tests/Actions/GetListingTest.php
@@ -0,0 +1,205 @@
+<?php
+
+namespace Actions;
+
+use App\Actions\GetListing;
+use App\Helpers\Helpers;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\TestCase;
+use RuntimeException;
+
+class GetListingTest extends TestCase
+{
+ private string $tempDir;
+ private GetListing $getListing;
+
+ protected function setUp(): void
+ {
+ $this->tempDir = sys_get_temp_dir() . '/get_listing_test_' . uniqid();
+ if (!mkdir($this->tempDir) && !is_dir($this->tempDir)) {
+ throw new RuntimeException(sprintf('Directory "%s" was not created', $this->tempDir));
+ }
+ $this->getListing = new GetListing();
+ }
+
+ protected function tearDown(): void
+ {
+ (new Helpers())->rmdirr($this->tempDir);
+ }
+
+ public static function bytes2StringProvider(): array
+ {
+ return [
+ [100, "100B"],
+ [1024, "1024B"],
+ [1025, "1kB"],
+ [5000, "4.88kB"],
+ [1048576, "1024kB"],
+ [1048577, "1MB"],
+ [1073741824, "1024MB"],
+ [1073741825, "1GB"],
+ [1099511627776, "1024GB"],
+ [1099511627777, "1TB"],
+ ];
+ }
+
+ public static function parseFileNameProvider(): array
+ {
+ return [
+ 'with nts' => [
+ 'php-7.4.0-nts-Win32-VC15-x64-latest.zip',
+ [
+ 'version' => '7.4.0',
+ 'version_short' => '7.4',
+ 'nts' => 'nts',
+ 'vc' => 'VC15',
+ 'arch' => 'x64',
+ 'ts' => false,
+ ],
+ ],
+ 'without nts' => [
+ 'php-7.4.0-Win32-VC15-x64-latest.zip',
+ [
+ 'version' => '7.4.0',
+ 'version_short' => '7.4',
+ 'nts' => false,
+ 'vc' => 'VC15',
+ 'arch' => 'x64',
+ 'ts' => false,
+ ],
+ ],
+ 'with numeric vc' => [
+ 'php-5.6.0-nts-Win32-7-x86-latest.zip',
+ [
+ 'version' => '5.6.0',
+ 'version_short' => '5.6',
+ 'nts' => 'nts',
+ 'vc' => 'VC6',
+ 'arch' => 'x86',
+ 'ts' => false,
+ ],
+ ],
+ ];
+ }
+
+ #[DataProvider('bytes2StringProvider')]
+ public function testBytes2String(int $bytes, string $expected): void
+ {
+ $result = $this->getListing->bytes2string($bytes);
+ $this->assertEquals($expected, $result);
+ }
+ #[DataProvider('parseFileNameProvider')]
+ public function testParseFileName(string $fileName, array $expected): void
+ {
+ $parts = $this->getListing->parseFileName($fileName);
+ $this->assertEquals($expected, $parts);
+ }
+
+ public function testGetSha256SumsCreatesFileAndReturnsHashes(): void
+ {
+ $dummyZip = $this->tempDir . '/dummy.zip';
+ $content = "dummy content";
+ file_put_contents($dummyZip, $content);
+
+ $sums = $this->getListing->getSha256Sums($this->tempDir);
+
+ $key = strtolower(basename($dummyZip));
+ $expectedHash = hash_file('sha256', $dummyZip);
+
+ $this->assertArrayHasKey($key, $sums);
+ $this->assertEquals($expectedHash, $sums[$key]);
+
+ $shaFile = $this->tempDir . '/sha256sum.txt';
+ $this->assertFileExists($shaFile);
+ $this->assertNotEmpty(file_get_contents($shaFile));
+ }
+
+ public function testHandleWithNoMatchingFiles(): void
+ {
+ $result = $this->getListing->handle($this->tempDir);
+ $this->assertEmpty($result, "Expected an empty result when no build files are present.");
+ }
+
+ public function testHandleWithMatchingFiles(): void
+ {
+ $mainBuildFile = $this->tempDir . '/php-7.4.0-nts-Win32-VC15-x64-latest.zip';
+ file_put_contents($mainBuildFile, 'build content');
+ $fixedTime = 1609459200; // 2021-01-01 00:00:00
+ touch($mainBuildFile, $fixedTime);
+
+ $sourceFile = $this->tempDir . '/php-7.4.0-src.zip';
+ $debugPackFile = $this->tempDir . '/php-debug-pack-7.4.0-nts-Win32-VC15-x64.zip';
+ $develPackFile = $this->tempDir . '/php-devel-pack-7.4.0-nts-Win32-VC15-x64.zip';
+ $installerFile = $this->tempDir . '/php-7.4.0-nts-Win32-VC15-x64.msi';
+ $testPackFile = $this->tempDir . '/php-test-pack-7.4.0.zip';
+
+ file_put_contents($sourceFile, 'source content');
+ file_put_contents($debugPackFile, 'debug content');
+ file_put_contents($develPackFile, 'devel content');
+ file_put_contents($installerFile, 'installer content');
+ file_put_contents($testPackFile, 'test content');
+
+ $result = $this->getListing->handle($this->tempDir);
+ $versionShortKey = '7.4';
+ $buildKey = 'nts-VC15-x64';
+
+ $this->assertArrayHasKey($versionShortKey, $result);
+ $versionData = $result[$versionShortKey];
+
+ $this->assertArrayHasKey('version', $versionData);
+ $this->assertEquals('7.4.0', $versionData['version']);
+
+ $this->assertArrayHasKey($buildKey, $versionData);
+ $buildDetails = $versionData[$buildKey];
+
+ $expectedMtime = date('Y-M-d H:i:s', filemtime($mainBuildFile));
+ $this->assertEquals($expectedMtime, $buildDetails['mtime']);
+
+ $this->assertArrayHasKey('zip', $buildDetails);
+ $zipInfo = $buildDetails['zip'];
+ $this->assertEquals(basename($mainBuildFile), $zipInfo['path']);
+
+ $expectedZipSize = $this->getListing->bytes2string(filesize($mainBuildFile));
+ $this->assertEquals($expectedZipSize, $zipInfo['size']);
+
+ $expectedSha256 = hash_file('sha256', $mainBuildFile);
+ $this->assertEquals($expectedSha256, $zipInfo['sha256']);
+
+ $this->assertArrayHasKey('source', $versionData);
+ $sourceInfo = $versionData['source'];
+ $this->assertEquals(basename($sourceFile), $sourceInfo['path']);
+ $expectedSourceSize = $this->getListing->bytes2string(filesize($sourceFile));
+ $this->assertEquals($expectedSourceSize, $sourceInfo['size']);
+ $this->assertEquals($expectedSha256, $sourceInfo['sha256']);
+
+ $this->assertArrayHasKey('test_pack', $versionData);
+ $testPackInfo = $versionData['test_pack'];
+ $this->assertEquals(basename($testPackFile), $testPackInfo['path']);
+ $expectedTestPackSize = $this->getListing->bytes2string(filesize($testPackFile));
+ $this->assertEquals($expectedTestPackSize, $testPackInfo['size']);
+ $this->assertEquals($expectedSha256, $testPackInfo['sha256']);
+
+ $this->assertArrayHasKey('debug_pack', $buildDetails);
+ $debugInfo = $buildDetails['debug_pack'];
+ $this->assertEquals(basename($debugPackFile), $debugInfo['path']);
+ $expectedDebugSize = $this->getListing->bytes2string(filesize($debugPackFile));
+ $this->assertEquals($expectedDebugSize, $debugInfo['size']);
+ $this->assertEquals($expectedSha256, $debugInfo['sha256']);
+
+ $this->assertArrayHasKey('devel_pack', $buildDetails);
+ $develInfo = $buildDetails['devel_pack'];
+ $this->assertEquals(basename($develPackFile), $develInfo['path']);
+ $expectedDevelSize = $this->getListing->bytes2string(filesize($develPackFile));
+ $this->assertEquals($expectedDevelSize, $develInfo['size']);
+ $this->assertEquals($expectedSha256, $develInfo['sha256']);
+
+ $this->assertArrayHasKey('installer', $buildDetails);
+ $installerInfo = $buildDetails['installer'];
+ $this->assertEquals(basename($installerFile), $installerInfo['path']);
+ $expectedInstallerSize = $this->getListing->bytes2string(filesize($installerFile));
+ $this->assertEquals($expectedInstallerSize, $installerInfo['size']);
+ $this->assertEquals($expectedSha256, $installerInfo['sha256']);
+
+ $this->assertFileExists($this->tempDir . '/sha256sum.txt');
+ }
+}
diff --git a/tests/Actions/UpdateReleasesJsonTest.php b/tests/Actions/UpdateReleasesJsonTest.php
new file mode 100644
index 0000000..12093c6
--- /dev/null
+++ b/tests/Actions/UpdateReleasesJsonTest.php
@@ -0,0 +1,138 @@
+<?php
+declare(strict_types=1);
+
+namespace Actions;
+
+use App\Actions\UpdateReleasesJson;
+use App\Helpers\Helpers;
+use DateTimeImmutable;
+use Exception;
+use PHPUnit\Framework\TestCase;
+
+class UpdateReleasesJsonTest extends TestCase
+{
+ private string $tempDir;
+
+ /**
+ * @throws Exception
+ */
+ protected function setUp(): void
+ {
+ date_default_timezone_set('UTC');
+ $this->tempDir = sys_get_temp_dir() . '/update_releases_test_' . uniqid();
+ if (!mkdir($this->tempDir, 0777, true) && !is_dir($this->tempDir)) {
+ throw new Exception(sprintf('Directory "%s" was not created', $this->tempDir));
+ }
+ }
+
+ protected function tearDown(): void
+ {
+ (new Helpers())->rmdirr($this->tempDir);
+ }
+
+ /**
+ * @throws Exception
+ */
+ public function testHandleValidReleases(): void
+ {
+ $releases = [
+ '7.4' => [
+ 'version' => '7.4.0',
+ 'nts-VC15-x64' => [
+ 'mtime' => "2023-01-01 10:00:00",
+ 'zip' => [
+ 'path' => 'php-7.4.0-nts-Win32-VC15-x64-latest.zip',
+ 'size' => '12kB',
+ 'sha256' => 'abcdef'
+ ],
+ 'debug_pack' => [
+ 'mtime' => "2023-01-01 11:00:00",
+ 'path' => 'php-debug-pack-7.4.0-nts-Win32-VC15-x64.zip',
+ 'size' => '3kB',
+ 'sha256' => '123456'
+ ],
+ ],
+ 'source' => [
+ 'path' => 'php-7.4.0-src.zip',
+ 'size' => '5MB',
+ 'sha256' => '987654'
+ ],
+ ],
+ ];
+
+ $updater = new UpdateReleasesJson();
+ $updater->handle($releases, $this->tempDir);
+
+ $jsonFile = $this->tempDir . '/releases.json';
+ $this->assertFileExists($jsonFile);
+
+ $jsonData = json_decode(file_get_contents($jsonFile), true);
+ $this->assertNotNull($jsonData, 'Decoded JSON should not be null.');
+
+ $expectedDate = (new DateTimeImmutable("2023-01-01 10:00:00"))->format('c');
+
+ $this->assertEquals(
+ $expectedDate,
+ $jsonData['7.4']['nts-VC15-x64']['mtime'],
+ 'Main build mtime should be in ISO 8601 format.'
+ );
+
+ $this->assertArrayHasKey('source', $jsonData['7.4']);
+ $this->assertArrayNotHasKey('mtime', $jsonData['7.4']['source']);
+ }
+
+ public function testHandleWithInvalidMtimeThrowsException(): void
+ {
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessage('Failed to generate releases.json:');
+
+ $releases = [
+ '7.4' => [
+ 'nts-VC15-x64' => [
+ 'mtime' => "invalid date string",
+ 'zip' => [
+ 'path' => 'php-7.4.0-nts-Win32-VC15-x64-latest.zip',
+ 'size' => '12kB',
+ 'sha256' => 'abcdef'
+ ],
+ ],
+ ],
+ ];
+
+ (new UpdateReleasesJson())->handle($releases, $this->tempDir);
+ }
+
+ /**
+ * @throws Exception
+ */
+ public function testHandleWithNoMtime(): void
+ {
+ $releases = [
+ '7.4' => [
+ 'nts-VC15-x64' => [
+ 'zip' => [
+ 'path' => 'php-7.4.0-nts-Win32-VC15-x64-latest.zip',
+ 'size' => '12kB',
+ 'sha256' => 'abcdef'
+ ],
+ ],
+ ],
+ ];
+
+ $updater = new UpdateReleasesJson();
+ $updater->handle($releases, $this->tempDir);
+
+ $jsonFile = $this->tempDir . '/releases.json';
+ $this->assertFileExists($jsonFile);
+
+ $jsonData = json_decode(file_get_contents($jsonFile), true);
+ $this->assertNotNull($jsonData, 'Decoded JSON should not be null.');
+
+ $this->assertArrayHasKey('zip', $jsonData['7.4']['nts-VC15-x64']);
+ $this->assertEquals(
+ 'php-7.4.0-nts-Win32-VC15-x64-latest.zip',
+ $jsonData['7.4']['nts-VC15-x64']['zip']['path']
+ );
+ $this->assertArrayNotHasKey('mtime', $jsonData['7.4']['nts-VC15-x64']);
+ }
+}
diff --git a/tests/CommandTest.php b/tests/CommandTest.php
index 3d0f799..ea3a03d 100644
--- a/tests/CommandTest.php
+++ b/tests/CommandTest.php
@@ -13,7 +13,8 @@ public function handle(): int {
class CommandTest extends TestCase {
     public function testParseArgumentsAndOptions() {
         $argv = ["script.php", "value", "--option=optValue"];
- $command = new TestCommand(count($argv), $argv);
+ $command = new TestCommand();
+ $command->setCliArguments(count($argv), $argv);

         $this->assertEquals("value", $command->getArgument("arg"), "Argument parsing failed.");
         $this->assertEquals("optValue", $command->getOption("option"), "Option parsing failed.");
diff --git a/tests/Console/Command/GenerateListingCommandTest.php b/tests/Console/Command/GenerateListingCommandTest.php
new file mode 100644
index 0000000..ec53b67
--- /dev/null
+++ b/tests/Console/Command/GenerateListingCommandTest.php
@@ -0,0 +1,96 @@
+<?php
+
+namespace Console\Command;
+
+use App\Actions\GetListing;
+use App\Actions\UpdateReleasesJson;
+use App\Console\Command as BaseCommand;
+use App\Console\Command\GenerateListingCommand;
+use Exception;
+use PHPUnit\Framework\MockObject\Exception as MockObjectException;
+use PHPUnit\Framework\TestCase;
+
+class GenerateListingCommandTest extends TestCase
+{
+ /**
+ * @throws MockObjectException
+ */
+ public function testHandleWithoutDirectory(): void
+ {
+ $getListing = $this->createMock(GetListing::class);
+ $updateReleasesJson = $this->createMock(UpdateReleasesJson::class);
+ $command = new GenerateListingCommand($getListing, $updateReleasesJson);
+ $argv = ['script.php', 'php:add'];
+ $argc = count($argv);
+ $command->setCliArguments($argc, $argv);
+
+ ob_start();
+ $result = $command->handle();
+ $output = ob_get_clean();
+
+ $this->assertStringContainsString('Directory is required', $output);
+ $this->assertEquals(BaseCommand::FAILURE, $result);
+ }
+
+ /**
+ * @throws MockObjectException
+ */
+ public function testHandleSuccess(): void
+ {
+ $directory = '/some/directory';
+ $dummyReleases = ['dummy' => 'value'];
+
+ $getListing = $this->createMock(GetListing::class);
+ $updateReleasesJson = $this->createMock(UpdateReleasesJson::class);
+
+ $getListing->expects($this->once())
+ ->method('handle')
+ ->with($directory)
+ ->willReturn($dummyReleases);
+
+ $updateReleasesJson->expects($this->once())
+ ->method('handle')
+ ->with($dummyReleases, $directory);
+
+ $command = new GenerateListingCommand($getListing, $updateReleasesJson);
+
+ $argv = ['script.php', 'php:add', '--directory=' . $directory];
+ $argc = count($argv);
+ $command->setCliArguments($argc, $argv);
+
+ $result = $command->handle();
+
+ $this->assertEquals(BaseCommand::SUCCESS, $result);
+ }
+
+ /**
+ * @throws MockObjectException
+ */
+ public function testHandleWhenExceptionThrown(): void
+ {
+ $directory = '/some/directory';
+
+ $getListing = $this->createMock(GetListing::class);
+ $updateReleasesJson = $this->createMock(UpdateReleasesJson::class);
+
+ $getListing->expects($this->once())
+ ->method('handle')
+ ->with($directory)
+ ->will($this->throwException(new Exception("Test exception")));
+
+ $updateReleasesJson->expects($this->never())
+ ->method('handle');
+
+ $command = new GenerateListingCommand($getListing, $updateReleasesJson);
+ $argv = ['script.php', 'php:add', '--directory=' . $directory];
+ $argc = count($argv);
+ $command->setCliArguments($argc, $argv);
+
+ ob_start();
+ $result = $command->handle();
+ $output = ob_get_clean();
+
+ $this->assertStringContainsString("Test exception", $output);
+ $this->assertEquals(BaseCommand::FAILURE, $result);
+ }
+}
diff --git a/tests/Console/Command/PhpCommandTest.php b/tests/Console/Command/PhpCommandTest.php
index cddce3b..5178423 100644
--- a/tests/Console/Command/PhpCommandTest.php
+++ b/tests/Console/Command/PhpCommandTest.php
@@ -2,6 +2,8 @@

namespace Console\Command;

+use App\Actions\GetListing;
+use App\Actions\UpdateReleasesJson;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use App\Console\Command\PhpCommand;
@@ -94,7 +96,7 @@ private function stageBuilds(array $phpZips, $zipPath): void
     #[DataProvider('buildsProvider')]
     public function testCommandHandlesSuccessfulExecution(array $phpZips): void
     {
- $command = new PhpCommand();
+ $command = new PhpCommand((new GetListing()), (new UpdateReleasesJson()));
         $command->setOption('base-directory', $this->baseDirectory);
         $command->setOption('builds-directory', $this->buildsDirectory);

@@ -110,7 +112,7 @@ public function testCommandHandlesSuccessfulExecution(array $phpZips): void

     public function testCommandHandlerWithMissingTestPackZip(): void
     {
- $command = new PhpCommand();
+ $command = new PhpCommand(new GetListing(), new UpdateReleasesJson());
         $command->setOption('base-directory', $this->baseDirectory);
         $command->setOption('builds-directory', $this->buildsDirectory);

@@ -124,7 +126,7 @@ public function testCommandHandlerWithMissingTestPackZip(): void

     public function testCommandHandlesMissingBaseDirectory(): void
     {
- $command = new PhpCommand();
+ $command = new PhpCommand(new GetListing(), new UpdateReleasesJson());
         ob_start();
         $result = $command->handle();
         $output = ob_get_clean();
@@ -136,7 +138,7 @@ public function testFailsToOpenZip(): void
     {
         $zipPath = $this->buildsDirectory . '/php/broken.zip';
         file_put_contents($zipPath, "invalid zip content");
- $command = new PhpCommand();
+ $command = new PhpCommand(new GetListing(), new UpdateReleasesJson());
         $command->setOption('base-directory', $this->baseDirectory);
         $command->setOption('builds-directory', $this->buildsDirectory);
         ob_start();
@@ -147,7 +149,7 @@ public function testFailsToOpenZip(): void

     public function testCleanupAfterCommand(): void
     {
- $command = new PhpCommand();
+ $command = new PhpCommand(new GetListing(), new UpdateReleasesJson());
         $command->setOption('base-directory', $this->baseDirectory);
         $command->setOption('builds-directory', $this->buildsDirectory);
         $command->handle();