tmpDir = sys_get_temp_dir() . '/corecore-page-builder-test-' . uniqid(); mkdir($this->tmpDir, 0777, true); } protected function tearDown(): void { $this->removePath($this->tmpDir); } // ── Atomic build / swap ───────────────────────────────────────── public function testBuildCreatesRuntimeDirWithCoreAndModuleSymlinks(): void { $coreDir = $this->createCorePages(['admin', 'login']); $moduleA = $this->createModuleWithPages('mod-a', ['mod-a-page']); $runtimeDir = $this->tmpDir . '/runtime/pages'; $builder = new ModuleRuntimePageBuilder(); $result = $builder->build($runtimeDir, $coreDir, ['mod-a' => $moduleA]); self::assertSame(2, $result['core_entries']); self::assertSame(1, $result['module_entries']); self::assertDirectoryExists($runtimeDir); self::assertTrue(is_link($runtimeDir . '/admin')); self::assertTrue(is_link($runtimeDir . '/login')); self::assertTrue(is_link($runtimeDir . '/mod-a-page')); } public function testBuildWithNoModulesCreatesSymlinkToCore(): void { $coreDir = $this->createCorePages(['admin']); $runtimeDir = $this->tmpDir . '/runtime/pages'; mkdir(dirname($runtimeDir), 0777, true); $builder = new ModuleRuntimePageBuilder(); $result = $builder->build($runtimeDir, $coreDir, []); self::assertSame(0, $result['core_entries']); self::assertSame(0, $result['module_entries']); self::assertTrue(is_link($runtimeDir)); } public function testBuildFailureDoesNotLeavePartialRuntimeState(): void { $coreDir = $this->createCorePages(['shared-page']); // Module has a page that collides with core — will cause RuntimeException $moduleA = $this->createModuleWithPages('mod-a', ['shared-page']); $runtimeDir = $this->tmpDir . '/runtime/pages'; // Pre-populate a valid runtime dir to ensure it survives the failed build mkdir($runtimeDir, 0777, true); file_put_contents($runtimeDir . '/sentinel.txt', 'existing'); $builder = new ModuleRuntimePageBuilder(); try { $builder->build($runtimeDir, $coreDir, ['mod-a' => $moduleA]); self::fail('Expected RuntimeException for page path collision'); } catch (RuntimeException $e) { self::assertStringContainsString('collision', $e->getMessage()); } // The existing runtime dir must still be intact — atomic build // should not have corrupted it self::assertDirectoryExists($runtimeDir); self::assertFileExists($runtimeDir . '/sentinel.txt'); self::assertSame('existing', file_get_contents($runtimeDir . '/sentinel.txt')); } public function testBuildReplacesExistingRuntimeDir(): void { $coreDir = $this->createCorePages(['admin']); $moduleA = $this->createModuleWithPages('mod-a', ['mod-page']); $runtimeDir = $this->tmpDir . '/runtime/pages'; // First build $builder = new ModuleRuntimePageBuilder(); $builder->build($runtimeDir, $coreDir, ['mod-a' => $moduleA]); self::assertTrue(is_link($runtimeDir . '/mod-page')); // Second build with different module $moduleB = $this->createModuleWithPages('mod-b', ['mod-b-page']); $result = $builder->build($runtimeDir, $coreDir, ['mod-b' => $moduleB]); self::assertSame(1, $result['core_entries']); self::assertSame(1, $result['module_entries']); self::assertTrue(is_link($runtimeDir . '/mod-b-page')); // Old module page should be gone self::assertFalse(is_link($runtimeDir . '/mod-page')); } public function testNoStagingLeftoverOnSuccess(): void { $coreDir = $this->createCorePages(['admin']); $moduleA = $this->createModuleWithPages('mod-a', ['mod-page']); $runtimeDir = $this->tmpDir . '/runtime/pages'; $builder = new ModuleRuntimePageBuilder(); $builder->build($runtimeDir, $coreDir, ['mod-a' => $moduleA]); // Staging dir should be cleaned up $parentDir = dirname($runtimeDir); $entries = scandir($parentDir); self::assertNotFalse($entries); $stagingEntries = array_filter( $entries, static fn (string $e): bool => str_starts_with($e, '.pages-staging-'), ); self::assertEmpty($stagingEntries, 'Staging directory should be removed after successful build'); } // ── Fingerprint ───────────────────────────────────────────────── public function testFingerprintChangesWhenManifestContentChanges(): void { $moduleDir = $this->tmpDir . '/modules/mod-a'; mkdir($moduleDir, 0777, true); file_put_contents($moduleDir . '/module.php', ' "mod-a"];'); $manifest = ModuleManifest::fromArray(['id' => 'mod-a'], $moduleDir); $fp1 = ModuleRuntimePageBuilder::expectedFingerprint(['mod-a' => $manifest]); // Change manifest content file_put_contents($moduleDir . '/module.php', ' "mod-a", "version" => "2.0.0"];'); $fp2 = ModuleRuntimePageBuilder::expectedFingerprint(['mod-a' => $manifest]); self::assertNotSame($fp1, $fp2, 'Fingerprint must change when manifest file content changes'); } public function testFingerprintChangesWhenPageEntriesChange(): void { $moduleDir = $this->tmpDir . '/modules/mod-a'; mkdir($moduleDir . '/pages', 0777, true); file_put_contents($moduleDir . '/module.php', ' "mod-a"];'); file_put_contents($moduleDir . '/pages/page-a.php', ' 'mod-a'], $moduleDir); $fp1 = ModuleRuntimePageBuilder::expectedFingerprint(['mod-a' => $manifest]); // Add a new page entry file_put_contents($moduleDir . '/pages/page-b.php', ' $manifest]); self::assertNotSame($fp1, $fp2, 'Fingerprint must change when page entries change'); } public function testFingerprintStableForSameInputs(): void { $moduleDir = $this->tmpDir . '/modules/mod-a'; mkdir($moduleDir . '/pages', 0777, true); file_put_contents($moduleDir . '/module.php', ' "mod-a"];'); file_put_contents($moduleDir . '/pages/page-a.php', ' 'mod-a'], $moduleDir); $fp1 = ModuleRuntimePageBuilder::expectedFingerprint(['mod-a' => $manifest]); $fp2 = ModuleRuntimePageBuilder::expectedFingerprint(['mod-a' => $manifest]); self::assertSame($fp1, $fp2, 'Fingerprint must be deterministic for same inputs'); } public function testFingerprintEmptyForNoModules(): void { $fp = ModuleRuntimePageBuilder::expectedFingerprint([]); self::assertSame('', $fp); } public function testWriteAndReadFingerprintRoundtrip(): void { $runtimeDir = $this->tmpDir . '/runtime/pages'; mkdir(dirname($runtimeDir), 0777, true); $moduleDir = $this->tmpDir . '/modules/mod-a'; mkdir($moduleDir, 0777, true); file_put_contents($moduleDir . '/module.php', ' "mod-a"];'); $manifest = ModuleManifest::fromArray(['id' => 'mod-a'], $moduleDir); $modules = ['mod-a' => $manifest]; ModuleRuntimePageBuilder::writeFingerprint($runtimeDir, $modules); $expected = ModuleRuntimePageBuilder::expectedFingerprint($modules); $actual = ModuleRuntimePageBuilder::readFingerprint($runtimeDir); self::assertSame($expected, $actual); } public function testReadFingerprintReturnsEmptyWhenFileDoesNotExist(): void { $runtimeDir = $this->tmpDir . '/runtime/pages'; self::assertSame('', ModuleRuntimePageBuilder::readFingerprint($runtimeDir)); } // ── Helpers ────────────────────────────────────────────────────── private function createCorePages(array $entries): string { $coreDir = $this->tmpDir . '/pages'; mkdir($coreDir, 0777, true); foreach ($entries as $entry) { mkdir($coreDir . '/' . $entry, 0777, true); } return $coreDir; } private function createModuleWithPages(string $id, array $pageEntries): ModuleManifest { $moduleDir = $this->tmpDir . '/modules/' . $id; mkdir($moduleDir . '/pages', 0777, true); file_put_contents($moduleDir . '/module.php', ' $id], true) . ';'); foreach ($pageEntries as $entry) { mkdir($moduleDir . '/pages/' . $entry, 0777, true); } return ModuleManifest::fromArray(['id' => $id], $moduleDir); } private function removePath(string $path): void { if (!file_exists($path) && !is_link($path)) { return; } if (is_link($path) || is_file($path)) { @unlink($path); return; } if (!is_dir($path)) { return; } $entries = scandir($path); if ($entries !== false) { foreach ($entries as $entry) { if ($entry === '.' || $entry === '..') { continue; } $this->removePath($path . '/' . $entry); } } @rmdir($path); } }