From f95091f2551447b4e7d99eaed018b5f62f63cdca Mon Sep 17 00:00:00 2001 From: crazywhalecc Date: Sun, 21 Jun 2026 22:32:26 +0800 Subject: [PATCH] fix: suppress warnings when loading files and clean up reflection access in tests --- src/StaticPHP/Command/BaseCommand.php | 4 + src/StaticPHP/Config/ArtifactConfig.php | 2 +- src/StaticPHP/Config/PackageConfig.php | 2 +- src/StaticPHP/Registry/PackageLoader.php | 14 +- tests/GlobalsFunctionsTest.php | 2 + .../Artifact/ArtifactDownloaderTest.php | 5 - .../Artifact/ArtifactExtractorTest.php | 7 - tests/StaticPHP/Artifact/ArtifactTest.php | 3 - .../Dev/GenExtTestMatrixCommandTest.php | 291 ++++++++++ tests/StaticPHP/Config/ArtifactConfigTest.php | 2 - tests/StaticPHP/Config/PackageConfigTest.php | 2 - .../StaticPHP/Registry/ArtifactLoaderTest.php | 5 - tests/StaticPHP/Registry/DoctorLoaderTest.php | 4 - .../StaticPHP/Registry/PackageLoaderTest.php | 17 - .../StaticPHP/Util/DependencyResolverTest.php | 531 ++++++++++++++++++ tests/bootstrap.php | 4 +- 16 files changed, 844 insertions(+), 51 deletions(-) create mode 100644 tests/StaticPHP/Command/Dev/GenExtTestMatrixCommandTest.php create mode 100644 tests/StaticPHP/Util/DependencyResolverTest.php diff --git a/src/StaticPHP/Command/BaseCommand.php b/src/StaticPHP/Command/BaseCommand.php index fcd39e5fc..bc189602d 100644 --- a/src/StaticPHP/Command/BaseCommand.php +++ b/src/StaticPHP/Command/BaseCommand.php @@ -54,6 +54,10 @@ public function initialize(InputInterface $input, OutputInterface $output): void } set_error_handler(static function ($error_no, $error_msg, $error_file, $error_line) { + // Respect the @ suppression operator (error_reporting() returns 0 when @ is used) + if (error_reporting() === 0) { + return true; + } $tips = [ E_WARNING => ['PHP Warning: ', 'warning'], E_NOTICE => ['PHP Notice: ', 'notice'], diff --git a/src/StaticPHP/Config/ArtifactConfig.php b/src/StaticPHP/Config/ArtifactConfig.php index 8462091d1..4e3b6d094 100644 --- a/src/StaticPHP/Config/ArtifactConfig.php +++ b/src/StaticPHP/Config/ArtifactConfig.php @@ -38,7 +38,7 @@ public static function loadFromDir(string $dir, string $registry_name): array */ public static function loadFromFile(string $file, string $registry_name): string { - $content = file_get_contents($file); + $content = @file_get_contents($file); if ($content === false) { throw new WrongUsageException("Failed to read artifact config file: {$file}"); } diff --git a/src/StaticPHP/Config/PackageConfig.php b/src/StaticPHP/Config/PackageConfig.php index e4474bf58..25b5b4ab5 100644 --- a/src/StaticPHP/Config/PackageConfig.php +++ b/src/StaticPHP/Config/PackageConfig.php @@ -46,7 +46,7 @@ public static function loadFromDir(string $dir, string $registry_name): array */ public static function loadFromFile(string $file, string $registry_name): string { - $content = file_get_contents($file); + $content = @file_get_contents($file); if ($content === false) { throw new WrongUsageException("Failed to read package config file: {$file}"); } diff --git a/src/StaticPHP/Registry/PackageLoader.php b/src/StaticPHP/Registry/PackageLoader.php index cf0301c04..a61ff0566 100644 --- a/src/StaticPHP/Registry/PackageLoader.php +++ b/src/StaticPHP/Registry/PackageLoader.php @@ -370,7 +370,10 @@ public static function getBeforeStageCallbacks(string $package_name, string $sta // match condition $installer = ApplicationContext::get(PackageInstaller::class); $stages = self::$before_stages[$package_name][$stage] ?? []; - foreach ($stages as [$callback, $only_when_package_resolved, $conditionals]) { + foreach ($stages as $entry) { + $callback = $entry[0]; + $only_when_package_resolved = $entry[1] ?? null; + $conditionals = $entry[2] ?? []; if ($only_when_package_resolved !== null && !$installer->isPackageResolved($only_when_package_resolved)) { continue; } @@ -389,7 +392,10 @@ public static function getAfterStageCallbacks(string $package_name, string $stag $installer = ApplicationContext::get(PackageInstaller::class); $stages = self::$after_stages[$package_name][$stage] ?? []; $result = []; - foreach ($stages as [$callback, $only_when_package_resolved, $conditionals]) { + foreach ($stages as $entry) { + $callback = $entry[0]; + $only_when_package_resolved = $entry[1] ?? null; + $conditionals = $entry[2] ?? []; if ($only_when_package_resolved !== null && !$installer->isPackageResolved($only_when_package_resolved)) { continue; } @@ -433,7 +439,9 @@ public static function checkLoadedStageEvents(): void } $pkg = self::getPackage($package_name); foreach ($stages as $stage_name => $before_events) { - foreach ($before_events as [$event_callable, $only_when_package_resolved, $conditionals]) { + foreach ($before_events as $entry) { + $event_callable = $entry[0]; + $only_when_package_resolved = $entry[1] ?? null; // check only_when_package_resolved package exists if ($only_when_package_resolved !== null && !self::hasPackage($only_when_package_resolved)) { throw new RegistryException("{$event_name} event in package [{$package_name}] for stage [{$stage_name}] has unknown only_when_package_resolved package [{$only_when_package_resolved}]."); diff --git a/tests/GlobalsFunctionsTest.php b/tests/GlobalsFunctionsTest.php index b1b047dbb..c2d159c46 100644 --- a/tests/GlobalsFunctionsTest.php +++ b/tests/GlobalsFunctionsTest.php @@ -21,6 +21,8 @@ protected function setUp(): void protected function tearDown(): void { $GLOBALS['spc_log_filters'] = null; + // Restore logger level to avoid polluting other tests with DEBUG noise + logger()->setLevel(LogLevel::ERROR); } public function testAddLogFilterDeduplicates(): void diff --git a/tests/StaticPHP/Artifact/ArtifactDownloaderTest.php b/tests/StaticPHP/Artifact/ArtifactDownloaderTest.php index 919452d6c..0b033b14f 100644 --- a/tests/StaticPHP/Artifact/ArtifactDownloaderTest.php +++ b/tests/StaticPHP/Artifact/ArtifactDownloaderTest.php @@ -23,12 +23,10 @@ protected function setUp(): void // Reset ArtifactConfig and ArtifactLoader static state $reflection = new \ReflectionClass(ArtifactConfig::class); $property = $reflection->getProperty('artifact_configs'); - $property->setAccessible(true); $property->setValue(null, []); $loaderReflection = new \ReflectionClass(ArtifactLoader::class); $loaderProperty = $loaderReflection->getProperty('artifacts'); - $loaderProperty->setAccessible(true); $loaderProperty->setValue(null, null); } @@ -38,12 +36,10 @@ protected function tearDown(): void $reflection = new \ReflectionClass(ArtifactConfig::class); $property = $reflection->getProperty('artifact_configs'); - $property->setAccessible(true); $property->setValue(null, []); $loaderReflection = new \ReflectionClass(ArtifactLoader::class); $loaderProperty = $loaderReflection->getProperty('artifacts'); - $loaderProperty->setAccessible(true); $loaderProperty->setValue(null, null); } @@ -343,7 +339,6 @@ private function injectArtifactConfig(string $name, array $config): void { $reflection = new \ReflectionClass(ArtifactConfig::class); $property = $reflection->getProperty('artifact_configs'); - $property->setAccessible(true); $configs = $property->getValue(null) ?? []; $configs[$name] = $config; $property->setValue(null, $configs); diff --git a/tests/StaticPHP/Artifact/ArtifactExtractorTest.php b/tests/StaticPHP/Artifact/ArtifactExtractorTest.php index d95275a64..c11031cd7 100644 --- a/tests/StaticPHP/Artifact/ArtifactExtractorTest.php +++ b/tests/StaticPHP/Artifact/ArtifactExtractorTest.php @@ -31,12 +31,10 @@ protected function setUp(): void $reflection = new \ReflectionClass(ArtifactConfig::class); $property = $reflection->getProperty('artifact_configs'); - $property->setAccessible(true); $property->setValue(null, []); $loaderReflection = new \ReflectionClass(ArtifactLoader::class); $loaderProperty = $loaderReflection->getProperty('artifacts'); - $loaderProperty->setAccessible(true); $loaderProperty->setValue(null, null); ApplicationContext::reset(); @@ -51,12 +49,10 @@ protected function tearDown(): void $reflection = new \ReflectionClass(ArtifactConfig::class); $property = $reflection->getProperty('artifact_configs'); - $property->setAccessible(true); $property->setValue(null, []); $loaderReflection = new \ReflectionClass(ArtifactLoader::class); $loaderProperty = $loaderReflection->getProperty('artifacts'); - $loaderProperty->setAccessible(true); $loaderProperty->setValue(null, null); ApplicationContext::reset(); @@ -157,7 +153,6 @@ public function testExtractReturnsAlreadyExtractedForSecondCall(): void // Pre-populate the extracted map for 'my-pkg' via reflection $reflection = new \ReflectionClass(ArtifactExtractor::class); $extractedProperty = $reflection->getProperty('extracted'); - $extractedProperty->setAccessible(true); $extractedProperty->setValue($extractor, ['my-pkg' => true]); $result = $extractor->extract($artifact, false); @@ -181,7 +176,6 @@ public function testExtractWithStringNameLooksUpFromArtifactLoader(): void // Pre-populate the extracted map so we don't need actual downloads $reflection = new \ReflectionClass(ArtifactExtractor::class); $extractedProperty = $reflection->getProperty('extracted'); - $extractedProperty->setAccessible(true); $extractedProperty->setValue($extractor, ['my-pkg' => true]); $result = $extractor->extract('my-pkg', false); @@ -204,7 +198,6 @@ private function injectArtifactConfig(string $name, array $config): void { $reflection = new \ReflectionClass(ArtifactConfig::class); $property = $reflection->getProperty('artifact_configs'); - $property->setAccessible(true); $configs = $property->getValue(null) ?? []; $configs[$name] = $config; $property->setValue(null, $configs); diff --git a/tests/StaticPHP/Artifact/ArtifactTest.php b/tests/StaticPHP/Artifact/ArtifactTest.php index 0effb1094..1a8526726 100644 --- a/tests/StaticPHP/Artifact/ArtifactTest.php +++ b/tests/StaticPHP/Artifact/ArtifactTest.php @@ -29,7 +29,6 @@ protected function setUp(): void // Reset ArtifactConfig static state $reflection = new \ReflectionClass(ArtifactConfig::class); $property = $reflection->getProperty('artifact_configs'); - $property->setAccessible(true); $property->setValue(null, []); // Reset DI container @@ -45,7 +44,6 @@ protected function tearDown(): void $reflection = new \ReflectionClass(ArtifactConfig::class); $property = $reflection->getProperty('artifact_configs'); - $property->setAccessible(true); $property->setValue(null, []); ApplicationContext::reset(); @@ -715,7 +713,6 @@ private function injectArtifactConfig(string $name, array $config): void { $reflection = new \ReflectionClass(ArtifactConfig::class); $property = $reflection->getProperty('artifact_configs'); - $property->setAccessible(true); $configs = $property->getValue(null) ?? []; $configs[$name] = $config; $property->setValue(null, $configs); diff --git a/tests/StaticPHP/Command/Dev/GenExtTestMatrixCommandTest.php b/tests/StaticPHP/Command/Dev/GenExtTestMatrixCommandTest.php new file mode 100644 index 000000000..b6beb6fb7 --- /dev/null +++ b/tests/StaticPHP/Command/Dev/GenExtTestMatrixCommandTest.php @@ -0,0 +1,291 @@ +getProperty('package_configs'); + $prop->setValue(null, []); + + // Register fixture packages + PackageConfig::loadFromArray(self::buildFixture(), 'test'); + + // Set up Symfony Application with the command under test + $this->app = new Application(); + $this->app->add(new GenExtTestMatrixCommand()); + $this->app->setAutoExit(false); + } + + protected function tearDown(): void + { + parent::tearDown(); + + // Reset PackageConfig static state + $ref = new \ReflectionClass(PackageConfig::class); + $prop = $ref->getProperty('package_configs'); + $prop->setValue(null, []); + + // Restore logger level (BaseCommand::execute() may have changed it) + logger()->setLevel(LogLevel::ERROR); + } + + // ────────────────────────────────────────────────────────────────────────── + // Tests + // ────────────────────────────────────────────────────────────────────────── + + /** + * swoole entry must contain all swoole-hook-* virtuals and nothing else. + */ + public function testSwooleBundlesHookVirtuals(): void + { + $matrix = $this->runMatrix(['--os' => 'Linux']); + + $swooleEntries = $this->findEntriesContaining($matrix, 'swoole'); + $this->assertCount(1, $swooleEntries, 'Expected exactly one entry containing swoole'); + + $parts = explode(',', $swooleEntries[0]['extension']); + sort($parts); + + $this->assertContains('swoole', $parts); + $this->assertContains('swoole-hook-mysql', $parts); + $this->assertContains('swoole-hook-pgsql', $parts); + } + + /** + * curl must NOT appear in the same entry as swoole, even though swoole depends on it. + */ + public function testCurlIsNotPulledIntoSwooleEntry(): void + { + $matrix = $this->runMatrix(['--os' => 'Linux']); + + // The swoole entry must not contain 'curl' + $swooleEntries = $this->findEntriesContaining($matrix, 'swoole'); + $this->assertCount(1, $swooleEntries); + $parts = explode(',', $swooleEntries[0]['extension']); + $this->assertNotContains('curl', $parts, 'curl must not appear inside the swoole matrix entry'); + + // curl must appear in a separate entry + $curlEntries = $this->findEntriesContaining($matrix, 'curl'); + $this->assertNotEmpty($curlEntries, 'curl must have its own matrix entry'); + } + + /** + * swow must be fully isolated — its entry should only contain 'swow'. + */ + public function testSwowIsIsolated(): void + { + $matrix = $this->runMatrix(['--os' => 'Linux']); + + $swowEntries = $this->findEntriesContaining($matrix, 'swow'); + $this->assertCount(1, $swowEntries, 'Expected exactly one entry containing swow'); + $this->assertSame('swow', $swowEntries[0]['extension'], 'swow entry must contain only swow'); + } + + /** + * dom and xml must appear in the same matrix entry (DFS chain). + */ + public function testDomXmlChain(): void + { + $matrix = $this->runMatrix(['--os' => 'Linux']); + + $chainEntries = $this->findEntriesContaining($matrix, 'dom', 'xml'); + $this->assertNotEmpty($chainEntries, 'dom and xml must appear in the same matrix entry'); + } + + /** + * --os=Windows must exclude ext-linux-only. + */ + public function testOsFilterExcludesLinuxOnlyFromWindows(): void + { + $matrix = $this->runMatrix(['--os' => 'Windows']); + + $linuxOnlyEntries = $this->findEntriesContaining($matrix, 'linux-only'); + $this->assertEmpty($linuxOnlyEntries, 'ext-linux-only must not appear in the Windows matrix'); + } + + /** + * --os=Linux must include ext-linux-only. + */ + public function testOsFilterIncludesLinuxOnly(): void + { + $matrix = $this->runMatrix(['--os' => 'Linux']); + + $linuxOnlyEntries = $this->findEntriesContaining($matrix, 'linux-only'); + $this->assertNotEmpty($linuxOnlyEntries, 'ext-linux-only must appear in the Linux matrix'); + } + + /** + * All returned entries must reference the requested OS runner when --os is specified. + */ + public function testOsFilterRestrictsRunners(): void + { + $matrix = $this->runMatrix(['--os' => 'Linux']); + + foreach ($matrix as $entry) { + $this->assertSame('linux', $entry['os'], "Entry {$entry['extension']} must only target Linux"); + } + } + + /** + * --for-extensions=redis must return only entries that contain 'redis'. + */ + public function testForExtensionsFilter(): void + { + $matrix = $this->runMatrix(['--os' => 'Linux', '--for-extensions' => 'redis']); + + $this->assertNotEmpty($matrix, '--for-extensions=redis must yield at least one entry'); + foreach ($matrix as $entry) { + $parts = explode(',', $entry['extension']); + $this->assertContains('redis', $parts, "Entry {$entry['extension']} does not contain redis"); + } + } + + /** + * --for-libs=libxml2 must return only entries whose extension(s) depend on libxml2. + */ + public function testForLibsFilter(): void + { + $matrix = $this->runMatrix(['--os' => 'Linux', '--for-libs' => 'libxml2']); + + $this->assertNotEmpty($matrix, '--for-libs=libxml2 must yield at least one entry'); + foreach ($matrix as $entry) { + $parts = explode(',', $entry['extension']); + // xml depends on libxml2 directly; dom depends on xml (which depends on libxml2) + $match = count(array_intersect($parts, ['xml', 'dom'])) > 0; + $this->assertTrue($match, "Entry {$entry['extension']} should not appear in --for-libs=libxml2 results"); + } + } + + /** + * --tier2 must produce only Tier2 runners and no Windows entries. + */ + public function testTier2Flag(): void + { + $matrix = $this->runMatrix(['--tier2' => true]); + + $this->assertNotEmpty($matrix); + foreach ($matrix as $entry) { + $this->assertNotSame('windows', $entry['os'], '--tier2 must not include Windows entries'); + $this->assertContains( + $entry['runner'], + ['ubuntu-24.04-arm', 'macos-15-intel'], + "Runner {$entry['runner']} is not a valid Tier2 runner" + ); + } + } + + /** + * Each entry must have the mandatory keys and correct types. + */ + public function testEntryShape(): void + { + $matrix = $this->runMatrix(['--os' => 'Linux']); + + $this->assertNotEmpty($matrix); + foreach ($matrix as $entry) { + $this->assertArrayHasKey('runner', $entry); + $this->assertArrayHasKey('os', $entry); + $this->assertArrayHasKey('arch', $entry); + $this->assertArrayHasKey('extension', $entry); + $this->assertArrayHasKey('build-args', $entry); + $this->assertIsString($entry['extension']); + $this->assertIsString($entry['build-args']); + $this->assertStringContainsString($entry['extension'], $entry['build-args']); + } + } + + // ────────────────────────────────────────────────────────────────────────── + // Helpers + // ────────────────────────────────────────────────────────────────────────── + + /** + * Run the command with the given options and return the parsed JSON matrix. + */ + private function runMatrix(array $options = []): array + { + $tester = new CommandTester($this->app->find('dev:gen-ext-test-matrix')); + $tester->execute($options, ['decorated' => false]); + $output = $tester->getDisplay(); + $matrix = json_decode($output, true); + $this->assertIsArray($matrix, "Command output is not valid JSON. Output:\n{$output}"); + return $matrix; + } + + /** + * Find matrix entries whose 'extension' field contains all of the given names. + * + * @return array[] matching entries + */ + private function findEntriesContaining(array $matrix, string ...$names): array + { + return array_values(array_filter($matrix, static function (array $entry) use ($names): bool { + $parts = explode(',', $entry['extension']); + foreach ($names as $name) { + if (!in_array($name, $parts, true)) { + return false; + } + } + return true; + })); + } + + /** + * Minimal valid php-extension fixture. + * + * Layout: + * - ext-swow standalone isolated, no ext deps + * - ext-swoole standalone isolated, depends on ext-curl + * - ext-swoole-hook-* virtual (arg-type: none) — must be bundled with swoole + * - ext-curl simple orphan, depended on by swoole but must NOT be pulled into swoole entry + * - ext-redis simple orphan + * - ext-xml depends on lib 'libxml2' + * - ext-dom depends on ext-xml (DFS chain) + * - ext-linux-only restricted to Linux via os: [Linux] + */ + private static function buildFixture(): array + { + // php-extension must be a non-empty assoc array ([] fails is_assoc_array() check). + $ext = static fn (array $phpExt = ['arg-type' => 'standard'], array $topLevel = []): array => array_merge(['type' => 'php-extension', 'php-extension' => $phpExt], $topLevel); + + return [ + // Isolated standalones + 'ext-swow' => $ext(), + 'ext-swoole' => $ext(['arg-type' => 'standard'], ['depends' => ['ext-curl']]), + + // Swoole hook virtuals (arg-type: none → virtual) + 'ext-swoole-hook-mysql' => $ext(['arg-type' => 'none']), + 'ext-swoole-hook-pgsql' => $ext(['arg-type' => 'none']), + + // Simple orphans + 'ext-curl' => $ext(), + 'ext-redis' => $ext(), + + // DFS chain: dom depends on xml; xml depends on lib 'libxml2' + 'ext-xml' => $ext(['arg-type' => 'standard'], ['depends' => ['libxml2']]), + 'ext-dom' => $ext(['arg-type' => 'standard'], ['depends' => ['ext-xml']]), + + // OS-restricted to Linux only + 'ext-linux-only' => $ext(['os' => ['Linux']]), + ]; + } +} diff --git a/tests/StaticPHP/Config/ArtifactConfigTest.php b/tests/StaticPHP/Config/ArtifactConfigTest.php index f99903652..ac726a9a6 100644 --- a/tests/StaticPHP/Config/ArtifactConfigTest.php +++ b/tests/StaticPHP/Config/ArtifactConfigTest.php @@ -25,7 +25,6 @@ protected function setUp(): void // Reset static state $reflection = new \ReflectionClass(ArtifactConfig::class); $property = $reflection->getProperty('artifact_configs'); - $property->setAccessible(true); $property->setValue([]); } @@ -41,7 +40,6 @@ protected function tearDown(): void // Reset static state $reflection = new \ReflectionClass(ArtifactConfig::class); $property = $reflection->getProperty('artifact_configs'); - $property->setAccessible(true); $property->setValue([]); } diff --git a/tests/StaticPHP/Config/PackageConfigTest.php b/tests/StaticPHP/Config/PackageConfigTest.php index ce28aebc0..c243a99ef 100644 --- a/tests/StaticPHP/Config/PackageConfigTest.php +++ b/tests/StaticPHP/Config/PackageConfigTest.php @@ -26,7 +26,6 @@ protected function setUp(): void // Reset static state $reflection = new \ReflectionClass(PackageConfig::class); $property = $reflection->getProperty('package_configs'); - $property->setAccessible(true); $property->setValue([]); } @@ -41,7 +40,6 @@ protected function tearDown(): void // Reset static state $reflection = new \ReflectionClass(PackageConfig::class); $property = $reflection->getProperty('package_configs'); - $property->setAccessible(true); $property->setValue([]); } diff --git a/tests/StaticPHP/Registry/ArtifactLoaderTest.php b/tests/StaticPHP/Registry/ArtifactLoaderTest.php index 75370dfe8..c56521c17 100644 --- a/tests/StaticPHP/Registry/ArtifactLoaderTest.php +++ b/tests/StaticPHP/Registry/ArtifactLoaderTest.php @@ -32,12 +32,10 @@ protected function setUp(): void // Reset ArtifactLoader and ArtifactConfig state $reflection = new \ReflectionClass(ArtifactLoader::class); $property = $reflection->getProperty('artifacts'); - $property->setAccessible(true); $property->setValue(null, null); $configReflection = new \ReflectionClass(ArtifactConfig::class); $configProperty = $configReflection->getProperty('artifact_configs'); - $configProperty->setAccessible(true); $configProperty->setValue(null, []); } @@ -52,12 +50,10 @@ protected function tearDown(): void // Reset ArtifactLoader and ArtifactConfig state $reflection = new \ReflectionClass(ArtifactLoader::class); $property = $reflection->getProperty('artifacts'); - $property->setAccessible(true); $property->setValue(null, null); $configReflection = new \ReflectionClass(ArtifactConfig::class); $configProperty = $configReflection->getProperty('artifact_configs'); - $configProperty->setAccessible(true); $configProperty->setValue(null, []); } @@ -429,7 +425,6 @@ private function createTestArtifactConfig(string $name): void { $reflection = new \ReflectionClass(ArtifactConfig::class); $property = $reflection->getProperty('artifact_configs'); - $property->setAccessible(true); $configs = $property->getValue(); $configs[$name] = [ 'type' => 'source', diff --git a/tests/StaticPHP/Registry/DoctorLoaderTest.php b/tests/StaticPHP/Registry/DoctorLoaderTest.php index 7817a880b..40ebaa7d9 100644 --- a/tests/StaticPHP/Registry/DoctorLoaderTest.php +++ b/tests/StaticPHP/Registry/DoctorLoaderTest.php @@ -26,11 +26,9 @@ protected function setUp(): void // Reset DoctorLoader state $reflection = new \ReflectionClass(DoctorLoader::class); $property = $reflection->getProperty('doctor_items'); - $property->setAccessible(true); $property->setValue(null, []); $property = $reflection->getProperty('fix_items'); - $property->setAccessible(true); $property->setValue(null, []); } @@ -45,11 +43,9 @@ protected function tearDown(): void // Reset DoctorLoader state $reflection = new \ReflectionClass(DoctorLoader::class); $property = $reflection->getProperty('doctor_items'); - $property->setAccessible(true); $property->setValue(null, []); $property = $reflection->getProperty('fix_items'); - $property->setAccessible(true); $property->setValue(null, []); } diff --git a/tests/StaticPHP/Registry/PackageLoaderTest.php b/tests/StaticPHP/Registry/PackageLoaderTest.php index a40c79faf..d850a3340 100644 --- a/tests/StaticPHP/Registry/PackageLoaderTest.php +++ b/tests/StaticPHP/Registry/PackageLoaderTest.php @@ -33,25 +33,20 @@ protected function setUp(): void $reflection = new \ReflectionClass(PackageLoader::class); $property = $reflection->getProperty('packages'); - $property->setAccessible(true); $property->setValue(null, null); $property = $reflection->getProperty('before_stages'); - $property->setAccessible(true); $property->setValue(null, []); $property = $reflection->getProperty('after_stages'); - $property->setAccessible(true); $property->setValue(null, []); $property = $reflection->getProperty('loaded_classes'); - $property->setAccessible(true); $property->setValue(null, []); // Reset PackageConfig state $configReflection = new \ReflectionClass(PackageConfig::class); $configProperty = $configReflection->getProperty('package_configs'); - $configProperty->setAccessible(true); $configProperty->setValue(null, []); } @@ -67,25 +62,20 @@ protected function tearDown(): void $reflection = new \ReflectionClass(PackageLoader::class); $property = $reflection->getProperty('packages'); - $property->setAccessible(true); $property->setValue(null, null); $property = $reflection->getProperty('before_stages'); - $property->setAccessible(true); $property->setValue(null, []); $property = $reflection->getProperty('after_stages'); - $property->setAccessible(true); $property->setValue(null, []); $property = $reflection->getProperty('loaded_classes'); - $property->setAccessible(true); $property->setValue(null, []); // Reset PackageConfig state $configReflection = new \ReflectionClass(PackageConfig::class); $configProperty = $configReflection->getProperty('package_configs'); - $configProperty->setAccessible(true); $configProperty->setValue(null, []); } @@ -359,7 +349,6 @@ public function testCheckLoadedStageEventsThrowsExceptionForUnknownPackage(): vo // Manually add a before_stage for non-existent package $reflection = new \ReflectionClass(PackageLoader::class); $property = $reflection->getProperty('before_stages'); - $property->setAccessible(true); $property->setValue(null, [ 'non-existent-package' => [ 'stage-name' => [[fn () => null, null]], @@ -384,7 +373,6 @@ public function testCheckLoadedStageEventsThrowsExceptionForUnknownStage(): void // Manually add a before_stage for non-existent stage $reflection = new \ReflectionClass(PackageLoader::class); $property = $reflection->getProperty('before_stages'); - $property->setAccessible(true); $property->setValue(null, [ 'test-lib' => [ 'non-existent-stage' => [[fn () => null, null]], @@ -408,7 +396,6 @@ public function testCheckLoadedStageEventsThrowsExceptionForUnknownOnlyWhenPacka // Manually add a before_stage with unknown only_when_package_resolved $reflection = new \ReflectionClass(PackageLoader::class); $property = $reflection->getProperty('before_stages'); - $property->setAccessible(true); $property->setValue(null, [ 'test-lib' => [ 'test-stage' => [[fn () => null, 'non-existent-package']], @@ -435,7 +422,6 @@ public function testCheckLoadedStageEventsDoesNotThrowForNonCurrentOSPackage(): // This should NOT throw an exception because the package has no build function for current OS $reflection = new \ReflectionClass(PackageLoader::class); $property = $reflection->getProperty('before_stages'); - $property->setAccessible(true); $property->setValue(null, [ 'test-lib' => [ 'build' => [[fn () => null, null]], @@ -458,7 +444,6 @@ public function testGetBeforeStageCallbacksReturnsCallbacks(): void $reflection = new \ReflectionClass(PackageLoader::class); $property = $reflection->getProperty('before_stages'); - $property->setAccessible(true); $property->setValue(null, [ 'test-package' => [ 'test-stage' => [ @@ -482,7 +467,6 @@ public function testGetAfterStageCallbacksReturnsCallbacks(): void $reflection = new \ReflectionClass(PackageLoader::class); $property = $reflection->getProperty('after_stages'); - $property->setAccessible(true); $property->setValue(null, [ 'test-package' => [ 'test-stage' => [ @@ -570,7 +554,6 @@ private function createTestPackageConfig(string $name, string $type): void { $reflection = new \ReflectionClass(PackageConfig::class); $property = $reflection->getProperty('package_configs'); - $property->setAccessible(true); $configs = $property->getValue(); $configs[$name] = [ 'type' => $type, diff --git a/tests/StaticPHP/Util/DependencyResolverTest.php b/tests/StaticPHP/Util/DependencyResolverTest.php new file mode 100644 index 000000000..b9098c668 --- /dev/null +++ b/tests/StaticPHP/Util/DependencyResolverTest.php @@ -0,0 +1,531 @@ +resetPackageConfig(); + } + + protected function tearDown(): void + { + parent::tearDown(); + $this->resetPackageConfig(); + } + + // ────────────────────────────────────────────── + // Basic resolution + // ────────────────────────────────────────────── + + public function testResolveSinglePackageNoDependencies(): void + { + $this->loadConfig([ + 'zlib' => ['type' => 'library'], + ]); + + $result = DependencyResolver::resolve(['zlib']); + + $this->assertSame(['zlib'], $result); + } + + public function testResolveLinearChain(): void + { + // a -> b -> c (a depends on b, b depends on c) + $this->loadConfig([ + 'a' => ['type' => 'library', 'depends' => ['b']], + 'b' => ['type' => 'library', 'depends' => ['c']], + 'c' => ['type' => 'library'], + ]); + + $result = DependencyResolver::resolve(['a']); + + // c must be first, then b, then a + $this->assertSame(['c', 'b', 'a'], $result); + } + + public function testResolveMultipleIndependentChains(): void + { + // a -> b, x -> y (two independent dependency chains) + $this->loadConfig([ + 'a' => ['type' => 'library', 'depends' => ['b']], + 'b' => ['type' => 'library'], + 'x' => ['type' => 'library', 'depends' => ['y']], + 'y' => ['type' => 'library'], + ]); + + $result = DependencyResolver::resolve(['a', 'x']); + + // Dependencies must come before their dependants + $posB = array_search('b', $result, true); + $posA = array_search('a', $result, true); + $posY = array_search('y', $result, true); + $posX = array_search('x', $result, true); + + $this->assertIsInt($posB); + $this->assertIsInt($posA); + $this->assertIsInt($posY); + $this->assertIsInt($posX); + $this->assertLessThan($posA, $posB, 'b should come before a'); + $this->assertLessThan($posX, $posY, 'y should come before x'); + } + + public function testResolveSharedDependency(): void + { + // a -> c, b -> c (c is shared) + $this->loadConfig([ + 'a' => ['type' => 'library', 'depends' => ['c']], + 'b' => ['type' => 'library', 'depends' => ['c']], + 'c' => ['type' => 'library'], + ]); + + $result = DependencyResolver::resolve(['a', 'b']); + + // c must appear exactly once and before both a and b + $cCount = count(array_keys($result, 'c', true)); + $this->assertSame(1, $cCount, 'Shared dependency c should appear exactly once'); + + $posC = array_search('c', $result, true); + $posA = array_search('a', $result, true); + $posB = array_search('b', $result, true); + + $this->assertLessThan($posA, $posC, 'c should come before a'); + $this->assertLessThan($posB, $posC, 'c should come before b'); + } + + public function testResolveDiamondDependency(): void + { + // a + // / \ + // b c + // \ / + // d + $this->loadConfig([ + 'a' => ['type' => 'target', 'depends' => ['b', 'c']], + 'b' => ['type' => 'library', 'depends' => ['d']], + 'c' => ['type' => 'library', 'depends' => ['d']], + 'd' => ['type' => 'library'], + ]); + + $result = DependencyResolver::resolve(['a']); + + // d must appear exactly once and before everything + $dCount = count(array_keys($result, 'd', true)); + $this->assertSame(1, $dCount); + + $posD = array_search('d', $result, true); + $posB = array_search('b', $result, true); + $posC = array_search('c', $result, true); + $posA = array_search('a', $result, true); + + $this->assertLessThan($posB, $posD, 'd should come before b'); + $this->assertLessThan($posC, $posD, 'd should come before c'); + $this->assertLessThan($posA, $posB, 'b should come before a'); + $this->assertLessThan($posA, $posC, 'c should come before a'); + } + + // ────────────────────────────────────────────── + // Suggests (optional dependencies) + // ────────────────────────────────────────────── + + public function testResolveSuggestsAreExcludedByDefault(): void + { + // a depends on b, suggests c + $this->loadConfig([ + 'a' => ['type' => 'library', 'depends' => ['b'], 'suggests' => ['c']], + 'b' => ['type' => 'library'], + 'c' => ['type' => 'library'], + ]); + + $result = DependencyResolver::resolve(['a']); + + // c should NOT be in the resolved list (it's only suggested, not depended) + $this->assertNotContains('c', $result); + $this->assertSame(['b', 'a'], $result); + } + + public function testResolveSuggestsIncludedWhenFlagSet(): void + { + $this->loadConfig([ + 'a' => ['type' => 'library', 'depends' => ['b'], 'suggests' => ['c']], + 'b' => ['type' => 'library'], + 'c' => ['type' => 'library', 'depends' => ['b']], + ]); + + $result = DependencyResolver::resolve(['a'], include_suggests: true); + + // c IS a suggest of a and should be included when flag is set + $this->assertContains('c', $result); + $posB = array_search('b', $result, true); + $posC = array_search('c', $result, true); + $posA = array_search('a', $result, true); + $this->assertLessThan($posA, $posB, 'b should come before a'); + $this->assertLessThan($posA, $posC, 'c should come before a'); + } + + // ────────────────────────────────────────────── + // Virtual-target promotion + // ────────────────────────────────────────────── + + public function testResolveVirtualTargetPromotesDepsToParent(): void + { + // php-cli (virtual-target) depends on [php, ext-ctype] + // When php-cli is in the input, ext-ctype should be promoted to php's deps + $this->loadConfig([ + 'php-cli' => ['type' => 'virtual-target', 'depends' => ['php', 'ext-ctype']], + 'php' => ['type' => 'target', 'depends' => ['libxml2']], + 'ext-ctype' => ['type' => 'php-extension', 'depends' => []], + 'libxml2' => ['type' => 'library'], + ]); + + $result = DependencyResolver::resolve(['php-cli']); + + $posPhp = array_search('php', $result, true); + $posCtype = array_search('ext-ctype', $result, true); + $posLibxml2 = array_search('libxml2', $result, true); + + $this->assertIsInt($posPhp); + $this->assertIsInt($posCtype); + $this->assertIsInt($posLibxml2); + + // ext-ctype was promoted to php's deps, so it must come before php + $this->assertLessThan($posPhp, $posCtype, 'ext-ctype should come before php (promoted dep)'); + // libxml2 is a native dep of php, so it must also come before php + $this->assertLessThan($posPhp, $posLibxml2, 'libxml2 should come before php'); + } + + public function testResolveVirtualTargetNotInInputDoesNotPromote(): void + { + // php-cli is a virtual-target but NOT in the input request, + // so its deps should NOT be injected into php + $this->loadConfig([ + 'php-cli' => ['type' => 'virtual-target', 'depends' => ['php', 'ext-ctype']], + 'php' => ['type' => 'target', 'depends' => ['libxml2']], + 'ext-ctype' => ['type' => 'php-extension'], + 'libxml2' => ['type' => 'library'], + ]); + + // Only php is requested, not php-cli + $result = DependencyResolver::resolve(['php']); + + // ext-ctype should NOT be in the result since php-cli was not requested + $this->assertNotContains('ext-ctype', $result); + $this->assertSame(['libxml2', 'php'], $result); + } + + // ────────────────────────────────────────────── + // Dependency overrides + // ────────────────────────────────────────────── + + public function testResolveDependencyOverridesAddDeps(): void + { + $this->loadConfig([ + 'a' => ['type' => 'library'], + 'b' => ['type' => 'library'], + 'c' => ['type' => 'library'], + ]); + + // Override: a now depends on b and c + $result = DependencyResolver::resolve(['a'], dependency_overrides: [ + 'a' => ['b', 'c'], + ]); + + $posA = array_search('a', $result, true); + $posB = array_search('b', $result, true); + $posC = array_search('c', $result, true); + + $this->assertLessThan($posA, $posB, 'b should come before a (override)'); + $this->assertLessThan($posA, $posC, 'c should come before a (override)'); + } + + public function testResolveDependencyOverridesMergeWithExisting(): void + { + $this->loadConfig([ + 'a' => ['type' => 'library', 'depends' => ['b']], + 'b' => ['type' => 'library'], + 'c' => ['type' => 'library'], + ]); + + // a natively depends on b, override adds c + $result = DependencyResolver::resolve(['a'], dependency_overrides: [ + 'a' => ['c'], + ]); + + $this->assertContains('b', $result); + $this->assertContains('c', $result); + $posA = array_search('a', $result, true); + $posB = array_search('b', $result, true); + $posC = array_search('c', $result, true); + $this->assertLessThan($posA, $posB, 'b should come before a'); + $this->assertLessThan($posA, $posC, 'c should come before a'); + } + + // ────────────────────────────────────────────── + // Error handling + // ────────────────────────────────────────────── + + public function testResolveUnknownPackageThrowsException(): void + { + $this->loadConfig([ + 'zlib' => ['type' => 'library'], + ]); + + $this->expectException(WrongUsageException::class); + $this->expectExceptionMessage('does not exist in config'); + + DependencyResolver::resolve(['nonexistent']); + } + + public function testResolveUnregisteredDependencyThrowsException(): void + { + // a depends on b, but b is not in the config + $this->loadConfig([ + 'a' => ['type' => 'library', 'depends' => ['b']], + ]); + + $this->expectException(WrongUsageException::class); + $this->expectExceptionMessage('not exist'); + + DependencyResolver::resolve(['a']); + } + + // ────────────────────────────────────────────── + // Reverse dependency map ($why parameter) + // ────────────────────────────────────────────── + + public function testReverseDependencyMap(): void + { + // a -> b -> c + $this->loadConfig([ + 'a' => ['type' => 'target', 'depends' => ['b']], + 'b' => ['type' => 'library', 'depends' => ['c']], + 'c' => ['type' => 'library'], + ]); + + $why = []; + DependencyResolver::resolve(['a'], why: $why); + + $this->assertArrayHasKey('c', $why, 'c is depended upon'); + $this->assertContains('b', $why['c'], 'b depends on c'); + $this->assertArrayHasKey('b', $why, 'b is depended upon'); + $this->assertContains('a', $why['b'], 'a depends on b'); + } + + public function testReverseDependencyMapOnlyIncludesResolvedPackages(): void + { + // a -> b -> c, but only requesting a + // d is not in the resolved set + $this->loadConfig([ + 'a' => ['type' => 'library', 'depends' => ['b']], + 'b' => ['type' => 'library', 'depends' => ['c']], + 'c' => ['type' => 'library'], + 'd' => ['type' => 'library', 'depends' => ['c']], // not in input + ]); + + $why = []; + DependencyResolver::resolve(['a'], why: $why); + + // d should NOT appear in the reverse map since it's not in the resolved set + $this->assertArrayNotHasKey('d', $why); + } + + // ────────────────────────────────────────────── + // getSubDependencies + // ────────────────────────────────────────────── + + public function testGetSubDependenciesLinearChain(): void + { + // a -> b -> c -> d + $this->loadConfig([ + 'a' => ['type' => 'target', 'depends' => ['b']], + 'b' => ['type' => 'library', 'depends' => ['c']], + 'c' => ['type' => 'library', 'depends' => ['d']], + 'd' => ['type' => 'library'], + ]); + + $subDeps = DependencyResolver::getSubDependencies('a', ['a', 'b', 'c', 'd']); + + // Should return [d, c, b] in dependency order (a not included) + $this->assertNotContains('a', $subDeps); + $this->assertSame(['d', 'c', 'b'], $subDeps); + } + + public function testGetSubDependenciesPackageNotInResolvedSet(): void + { + $this->loadConfig([ + 'a' => ['type' => 'library', 'depends' => ['b']], + 'b' => ['type' => 'library'], + ]); + + $subDeps = DependencyResolver::getSubDependencies('nonexistent', ['a', 'b']); + + $this->assertSame([], $subDeps); + } + + public function testGetSubDependenciesWithSuggests(): void + { + $this->loadConfig([ + 'a' => ['type' => 'target', 'depends' => ['b'], 'suggests' => ['c']], + 'b' => ['type' => 'library'], + 'c' => ['type' => 'library'], + ]); + + // Without include_suggests: only b is a sub-dep + $without = DependencyResolver::getSubDependencies('a', ['a', 'b', 'c'], include_suggests: false); + $this->assertSame(['b'], $without); + + // With include_suggests: both b and c are sub-deps + $with = DependencyResolver::getSubDependencies('a', ['a', 'b', 'c'], include_suggests: true); + $this->assertContains('b', $with); + $this->assertContains('c', $with); + } + + public function testGetSubDependenciesOnlyIncludesResolvedDeps(): void + { + // a depends on b and c, but c is not in the resolved set + $this->loadConfig([ + 'a' => ['type' => 'target', 'depends' => ['b', 'c']], + 'b' => ['type' => 'library'], + 'c' => ['type' => 'library'], + ]); + + // c is NOT in the resolved set + $subDeps = DependencyResolver::getSubDependencies('a', ['a', 'b']); + + $this->assertContains('b', $subDeps); + $this->assertNotContains('c', $subDeps, 'c is not in the resolved set, should be excluded'); + } + + // ────────────────────────────────────────────── + // Edge cases & defensive + // ────────────────────────────────────────────── + + public function testResolveEmptyInput(): void + { + $this->loadConfig([ + 'zlib' => ['type' => 'library'], + ]); + + $result = DependencyResolver::resolve([]); + + $this->assertSame([], $result); + } + + public function testResolveWithStringAndPackageInstanceMixed(): void + { + $this->loadConfig([ + 'a' => ['type' => 'library'], + 'b' => ['type' => 'library'], + ]); + + // Pass one as string, one as a mock Package + $mockPackage = $this->createMockPackage('a'); + + $result = DependencyResolver::resolve([$mockPackage, 'b']); + + $this->assertContains('a', $result); + $this->assertContains('b', $result); + } + + public function testResolveDuplicateInputPackages(): void + { + // Requesting the same package twice should not duplicate it in output + $this->loadConfig([ + 'zlib' => ['type' => 'library'], + ]); + + $result = DependencyResolver::resolve(['zlib', 'zlib']); + + $this->assertSame(['zlib'], $result); + } + + /** + * Documents the current behavior for circular dependencies. + * The algorithm does not detect cycles; it silently resolves them + * using the visited-set to break infinite recursion. This test + * locks in the current behavior so any intentional change is caught. + */ + public function testCircularDependencyDoesNotLoopInfinitely(): void + { + // a -> b -> a (circular) + $this->loadConfig([ + 'a' => ['type' => 'library', 'depends' => ['b']], + 'b' => ['type' => 'library', 'depends' => ['a']], + ]); + + // Must not hang — should complete and return both packages + $result = DependencyResolver::resolve(['a']); + + $this->assertCount(2, $result); + $this->assertContains('a', $result); + $this->assertContains('b', $result); + } + + // ────────────────────────────────────────────── + // Helpers + // ────────────────────────────────────────────── + + /** + * Load package configurations directly into PackageConfig. + * Uses reflection to inject fixture data without needing YAML files on disk. + * + * @param array $configs + */ + private function loadConfig(array $configs): void + { + $reflection = new \ReflectionClass(PackageConfig::class); + $property = $reflection->getProperty('package_configs'); + + $existing = $property->getValue(); + if (!is_array($existing)) { + $existing = []; + } + + foreach ($configs as $name => $config) { + $existing[$name] = $config; + } + + $property->setValue(null, $existing); + } + + /** + * Reset PackageConfig to empty state. + */ + private function resetPackageConfig(): void + { + $reflection = new \ReflectionClass(PackageConfig::class); + $property = $reflection->getProperty('package_configs'); + $property->setValue(null, []); + } + + /** + * Create a minimal mock Package object that returns a given name. + */ + private function createMockPackage(string $name): object + { + return new class($name) { + public function __construct(private string $name) {} + + public function getName(): string + { + return $this->name; + } + }; + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 14397d038..f45c9f4ac 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -2,8 +2,10 @@ declare(strict_types=1); use Psr\Log\LogLevel; +use StaticPHP\Registry\Registry; require_once __DIR__ . '/../src/bootstrap.php'; -\StaticPHP\Registry\Registry::resolve(); logger()->setLevel(LogLevel::ERROR); + +Registry::resolve();