diff --git a/.vortex/installer/src/Command/DestinationAwareTrait.php b/.vortex/installer/src/Command/DestinationAwareTrait.php index 99ff140a9..7061099e2 100644 --- a/.vortex/installer/src/Command/DestinationAwareTrait.php +++ b/.vortex/installer/src/Command/DestinationAwareTrait.php @@ -4,6 +4,7 @@ namespace DrevOps\VortexInstaller\Command; +use DrevOps\VortexInstaller\Utils\File; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -42,13 +43,13 @@ protected function getDestination(InputInterface $input): string { throw new \InvalidArgumentException('Destination must be a string.'); } - if (!is_dir($destination)) { + if (!File::isDir($destination)) { throw new \InvalidArgumentException( sprintf('Destination directory does not exist: %s', $destination) ); } - return realpath($destination) ?: $destination; + return File::realpath($destination); } } diff --git a/.vortex/installer/src/Command/InstallCommand.php b/.vortex/installer/src/Command/InstallCommand.php index 1ed72b447..afd765ea1 100644 --- a/.vortex/installer/src/Command/InstallCommand.php +++ b/.vortex/installer/src/Command/InstallCommand.php @@ -333,7 +333,7 @@ protected function handleValidate(InputInterface $input, OutputInterface $output return Command::FAILURE; } - $prompts_json = is_file($prompts_option) ? (string) file_get_contents($prompts_option) : $prompts_option; + $prompts_json = OptionsResolver::readJsonOption($prompts_option, '--prompts'); $decoded = json_decode($prompts_json); if (!$decoded instanceof \stdClass) { @@ -437,7 +437,7 @@ public function cleanup(): void { } $phar_path = \Phar::running(FALSE); - if (!empty($phar_path) && file_exists($phar_path)) { + if (!empty($phar_path) && File::exists($phar_path)) { File::remove($phar_path); } } diff --git a/.vortex/installer/src/Downloader/Archiver.php b/.vortex/installer/src/Downloader/Archiver.php index 1d18e3692..ea5667d22 100644 --- a/.vortex/installer/src/Downloader/Archiver.php +++ b/.vortex/installer/src/Downloader/Archiver.php @@ -53,11 +53,11 @@ public function detectFormat(string $archive_path): ?string { * {@inheritdoc} */ public function validate(string $archive_path): void { - if (!file_exists($archive_path)) { + if (!File::exists($archive_path)) { throw new \RuntimeException(sprintf('Archive file does not exist: "%s".', $archive_path)); } - if (filesize($archive_path) === 0) { + if (File::size($archive_path) === 0) { throw new \RuntimeException('Archive is empty.'); } @@ -125,7 +125,7 @@ protected function extractTar(string $archive_path, string $destination, bool $s throw new \RuntimeException(sprintf('Unable to extract tar archive to "%s": %s.', $destination, $e->getMessage()), $e->getCode(), $e); } finally { - if ($strip_first_level && is_dir($temp_dir)) { + if ($strip_first_level && File::isDir($temp_dir)) { File::remove($temp_dir); } } @@ -167,7 +167,7 @@ protected function extractZip(string $archive_path, string $destination, bool $s throw new \RuntimeException(sprintf('Unable to extract ZIP archive to "%s": %s.', $destination, $e->getMessage()), $e->getCode(), $e); } finally { - if ($strip_first_level && is_dir($temp_dir)) { + if ($strip_first_level && File::isDir($temp_dir)) { File::remove($temp_dir); } } diff --git a/.vortex/installer/src/Downloader/RepositoryDownloader.php b/.vortex/installer/src/Downloader/RepositoryDownloader.php index 2a1e20333..8d8dcafc8 100644 --- a/.vortex/installer/src/Downloader/RepositoryDownloader.php +++ b/.vortex/installer/src/Downloader/RepositoryDownloader.php @@ -56,7 +56,7 @@ public function download(Artifact $artifact, ?string $destination = NULL, ?strin $version = $this->downloadFromLocal($artifact, $destination); } - if (!is_readable($destination . '/composer.json')) { + if (!File::isReadable($destination . '/composer.json')) { throw new \RuntimeException('The downloaded repository does not contain a composer.json file.'); } @@ -130,9 +130,15 @@ protected function downloadFromRemote(Artifact $artifact, ?string $destination, $url = sprintf(self::ARCHIVE_URL_TEMPLATE, $repo_url, $ref); $archive_path = $this->downloadArchive($url); - $this->archiver->validate($archive_path); - $this->archiver->extract($archive_path, $destination, TRUE); - File::remove($archive_path); + + try { + $this->archiver->validate($archive_path); + $this->archiver->extract($archive_path, $destination, TRUE); + } + finally { + // The archive is created inside a temporary directory of its own. + File::remove(dirname($archive_path)); + } return $version; } @@ -159,9 +165,15 @@ protected function downloadFromLocal(Artifact $artifact, ?string $destination): } $archive_path = $this->archiveFromLocal($artifact->getRepo(), $ref); - $this->archiver->validate($archive_path); - $this->archiver->extract($archive_path, $destination, FALSE); - File::remove($archive_path); + + try { + $this->archiver->validate($archive_path); + $this->archiver->extract($archive_path, $destination, FALSE); + } + finally { + // The archive is created inside a temporary directory of its own. + File::remove(dirname($archive_path)); + } return $version; } @@ -177,7 +189,6 @@ protected function discoverLatestReleaseRemote(string $repo_url, ?string $releas $release_url = sprintf('https://api.github.com/repos/%s/releases', $path); $headers = self::requestHeaders($release_url, ['Accept' => 'application/vnd.github.v3+json']); - $github_token = Env::get('GITHUB_TOKEN'); try { $response = $this->httpClient->request('GET', $release_url, ['headers' => $headers]); @@ -188,7 +199,7 @@ protected function discoverLatestReleaseRemote(string $repo_url, ?string $releas } if ($release_contents === '' || $release_contents === '0') { - $message = sprintf('Unable to download release information from "%s"%s.', $release_url, $github_token ? ' (GitHub token was used)' : ''); + $message = sprintf('Unable to download release information from "%s"%s.', $release_url, isset($headers['Authorization']) ? ' (GitHub token was used)' : ''); throw new \RuntimeException($message); } @@ -219,18 +230,14 @@ protected function discoverLatestReleaseRemote(string $repo_url, ?string $releas * If download fails. */ protected function downloadArchive(string $url): string { - $temp_file = tempnam(sys_get_temp_dir(), 'vortex_archive_'); - if ($temp_file === FALSE) { - throw new \RuntimeException('Unable to create temporary file for archive download.'); - } + $temp_dir = File::tmpdir(prefix: 'vortex_archive_'); + $temp_file = $temp_dir . DIRECTORY_SEPARATOR . 'archive.tar.gz'; try { $this->fileDownloader->download($url, $temp_file, self::requestHeaders($url)); } catch (\RuntimeException $e) { - if (file_exists($temp_file)) { - File::remove($temp_file); - } + File::remove($temp_dir); throw new \RuntimeException(sprintf('Unable to download archive from "%s": %s.', $url, $e->getMessage()), $e->getCode(), $e); } @@ -256,19 +263,18 @@ protected function archiveFromLocal(string $repo, string $ref): string { $this->git = new Git($repo); } - $temp_file = sys_get_temp_dir() . '/vortex_local_archive_' . uniqid() . '.tar'; + $temp_dir = File::tmpdir(prefix: 'vortex_local_archive_'); + $temp_file = $temp_dir . DIRECTORY_SEPARATOR . 'archive.tar'; try { $this->git->run('archive', '--format=tar', $ref, '-o', $temp_file); - if (!file_exists($temp_file) || filesize($temp_file) === 0) { + if (!File::exists($temp_file) || File::size($temp_file) === 0) { throw new \RuntimeException('Archive creation produced empty file.'); } } catch (\Exception $e) { - if (file_exists($temp_file)) { - File::remove($temp_file); - } + File::remove($temp_dir); throw new \RuntimeException(sprintf('Unable to create archive from local repository "%s": %s.', $repo, $e->getMessage()), $e->getCode(), $e); } @@ -342,11 +348,11 @@ protected function validateRemoteRefExists(string $repo_url, string $ref): void * If the repository does not exist or is not a valid git repository. */ protected function validateLocalRepositoryExists(string $repo): void { - if (!is_dir($repo)) { + if (!File::isDir($repo)) { throw new \RuntimeException(sprintf('Local repository path does not exist: "%s".', $repo)); } - if (!is_dir($repo . '/.git')) { + if (!File::isDir($repo . '/.git')) { throw new \RuntimeException(sprintf('Path is not a git repository: "%s".', $repo)); } } @@ -363,7 +369,7 @@ protected function validateLocalRepositoryExists(string $repo): void { * If the reference does not exist. */ protected function validateLocalRefExists(string $repo, string $ref): void { - $repo_path = (string) realpath($repo); + $repo_path = File::realpath($repo); if (!$this->git instanceof Git || $this->git->getRepositoryPath() !== $repo_path) { $this->git = new Git($repo); diff --git a/.vortex/installer/src/Logger/FileLogger.php b/.vortex/installer/src/Logger/FileLogger.php index bf44a5ff9..5b2039952 100644 --- a/.vortex/installer/src/Logger/FileLogger.php +++ b/.vortex/installer/src/Logger/FileLogger.php @@ -50,7 +50,7 @@ public function open(string $command, array $args = []): bool { $this->path = $this->getDir() . '/' . self::LOG_DIR . '/' . $name . '-' . date('Y-m-d-His') . '.log'; $log_dir = dirname($this->path); - if (!is_dir($log_dir)) { + if (!File::isDir($log_dir)) { File::mkdir($log_dir); } diff --git a/.vortex/installer/src/Prompts/Handlers/AssignAuthorPr.php b/.vortex/installer/src/Prompts/Handlers/AssignAuthorPr.php index 3f1e4be7e..4a0622efd 100644 --- a/.vortex/installer/src/Prompts/Handlers/AssignAuthorPr.php +++ b/.vortex/installer/src/Prompts/Handlers/AssignAuthorPr.php @@ -37,7 +37,7 @@ public function discover(): null|string|bool|array { return NULL; } - return file_exists($this->destinationDir . '/.github/workflows/assign-author.yml'); + return File::exists($this->destinationDir . '/.github/workflows/assign-author.yml'); } /** diff --git a/.vortex/installer/src/Prompts/Handlers/CiProvider.php b/.vortex/installer/src/Prompts/Handlers/CiProvider.php index fe3ed1204..15ead9f93 100644 --- a/.vortex/installer/src/Prompts/Handlers/CiProvider.php +++ b/.vortex/installer/src/Prompts/Handlers/CiProvider.php @@ -60,11 +60,11 @@ public function discover(): null|string|bool|array { return NULL; } - if (is_readable($this->destinationDir . '/.github/workflows/build-test-deploy.yml')) { + if (File::exists($this->destinationDir . '/.github/workflows/build-test-deploy.yml')) { return self::GITHUB_ACTIONS; } - if (is_readable($this->destinationDir . '/.circleci/config.yml')) { + if (File::exists($this->destinationDir . '/.circleci/config.yml')) { return self::CIRCLECI; } diff --git a/.vortex/installer/src/Prompts/Handlers/CodeCoverageProvider.php b/.vortex/installer/src/Prompts/Handlers/CodeCoverageProvider.php index d17b6bd11..41c515ff1 100644 --- a/.vortex/installer/src/Prompts/Handlers/CodeCoverageProvider.php +++ b/.vortex/installer/src/Prompts/Handlers/CodeCoverageProvider.php @@ -53,13 +53,13 @@ public function discover(): null|string|bool|array { $gha_files = glob($this->destinationDir . '/.github/workflows/*.{yml,yaml}', GLOB_BRACE) ?: []; foreach ($gha_files as $gha_file) { - if (is_readable($gha_file) && File::contains($gha_file, 'codecov/codecov-action')) { + if (File::contains($gha_file, 'codecov/codecov-action')) { return self::CODECOV; } } $circle = $this->destinationDir . '/.circleci/config.yml'; - if (is_readable($circle) && File::contains($circle, 'codecov -Z -s')) { + if (File::contains($circle, 'codecov -Z -s')) { return self::CODECOV; } diff --git a/.vortex/installer/src/Prompts/Handlers/CodeProvider.php b/.vortex/installer/src/Prompts/Handlers/CodeProvider.php index e54df7729..88edd32ab 100644 --- a/.vortex/installer/src/Prompts/Handlers/CodeProvider.php +++ b/.vortex/installer/src/Prompts/Handlers/CodeProvider.php @@ -54,11 +54,11 @@ public function default(array $responses): null|string|bool|array { * {@inheritdoc} */ public function discover(): null|string|bool|array { - if (file_exists($this->destinationDir . '/.github')) { + if (File::exists($this->destinationDir . '/.github')) { return self::GITHUB; } - return $this->isInstalled() && file_exists($this->destinationDir . '/.git') ? self::OTHER : NULL; + return $this->isInstalled() && File::exists($this->destinationDir . '/.git') ? self::OTHER : NULL; } /** @@ -71,8 +71,8 @@ public function process(): void { if ($v === self::GITHUB) { File::remove($t . '/.github/PULL_REQUEST_TEMPLATE.md'); - if (file_exists($t . '/.github/PULL_REQUEST_TEMPLATE.dist.md')) { - rename($t . '/.github/PULL_REQUEST_TEMPLATE.dist.md', $t . '/.github/PULL_REQUEST_TEMPLATE.md'); + if (File::exists($t . '/.github/PULL_REQUEST_TEMPLATE.dist.md')) { + File::rename($t . '/.github/PULL_REQUEST_TEMPLATE.dist.md', $t . '/.github/PULL_REQUEST_TEMPLATE.md'); } } else { diff --git a/.vortex/installer/src/Prompts/Handlers/CustomModules.php b/.vortex/installer/src/Prompts/Handlers/CustomModules.php index 11440ced3..542ad8cb3 100644 --- a/.vortex/installer/src/Prompts/Handlers/CustomModules.php +++ b/.vortex/installer/src/Prompts/Handlers/CustomModules.php @@ -92,15 +92,15 @@ public function discover(): null|string|bool|array { $module_dir = $this->destinationDir . '/' . $this->webroot . '/modules/custom'; - if (is_dir($module_dir . '/' . $prefix . '_base')) { + if (File::isDir($module_dir . '/' . $prefix . '_base')) { $modules[] = self::BASE; } - if (is_dir($module_dir . '/' . $prefix . '_demo')) { + if (File::isDir($module_dir . '/' . $prefix . '_demo')) { $modules[] = self::DEMO; } - if (is_dir($module_dir . '/' . $prefix . '_search')) { + if (File::isDir($module_dir . '/' . $prefix . '_search')) { $modules[] = self::SEARCH; } @@ -220,7 +220,7 @@ protected function discoverModulePrefix(): ?string { protected static function removeDemoBehatFeatures(string $dir): void { $features_dir = $dir . '/tests/behat/features'; - if (!is_dir($features_dir)) { + if (!File::isDir($features_dir)) { return; } diff --git a/.vortex/installer/src/Prompts/Handlers/DependencyUpdatesProvider.php b/.vortex/installer/src/Prompts/Handlers/DependencyUpdatesProvider.php index b29b8ecbe..cc94eb3bb 100644 --- a/.vortex/installer/src/Prompts/Handlers/DependencyUpdatesProvider.php +++ b/.vortex/installer/src/Prompts/Handlers/DependencyUpdatesProvider.php @@ -54,11 +54,11 @@ public function discover(): null|string|bool|array { return NULL; } - if (!is_readable($this->destinationDir . '/renovate.json')) { + if (!File::exists($this->destinationDir . '/renovate.json')) { return self::NONE; } - if (file_exists($this->destinationDir . '/.github/workflows/update-dependencies.yml')) { + if (File::exists($this->destinationDir . '/.github/workflows/update-dependencies.yml')) { return self::RENOVATEBOT_CI; } diff --git a/.vortex/installer/src/Prompts/Handlers/Dotenv.php b/.vortex/installer/src/Prompts/Handlers/Dotenv.php index 4fad17b53..fe03bd31b 100644 --- a/.vortex/installer/src/Prompts/Handlers/Dotenv.php +++ b/.vortex/installer/src/Prompts/Handlers/Dotenv.php @@ -5,6 +5,7 @@ namespace DrevOps\VortexInstaller\Prompts\Handlers; use DrevOps\VortexInstaller\Utils\Env; +use DrevOps\VortexInstaller\Utils\File; class Dotenv extends AbstractHandler { @@ -22,7 +23,7 @@ public function discover(): null|string|bool|array { public function process(): void { $t = $this->tmpDir; - if (is_readable($this->destinationDir . '/.env')) { + if (File::exists($this->destinationDir . '/.env')) { $variables = Env::parseDotenv($this->destinationDir . '/.env'); foreach ($variables as $name => $value) { Env::writeValueDotenv($name, $value, $t . '/.env'); diff --git a/.vortex/installer/src/Prompts/Handlers/Gitleaks.php b/.vortex/installer/src/Prompts/Handlers/Gitleaks.php index 483749eaf..ba9c0d8c4 100644 --- a/.vortex/installer/src/Prompts/Handlers/Gitleaks.php +++ b/.vortex/installer/src/Prompts/Handlers/Gitleaks.php @@ -44,7 +44,7 @@ public function discover(): null|string|bool|array { return NULL; } - return file_exists($this->destinationDir . '/.gitleaks.toml'); + return File::exists($this->destinationDir . '/.gitleaks.toml'); } /** diff --git a/.vortex/installer/src/Prompts/Handlers/HostingProjectName.php b/.vortex/installer/src/Prompts/Handlers/HostingProjectName.php index e5efc2b09..36d8e626a 100644 --- a/.vortex/installer/src/Prompts/Handlers/HostingProjectName.php +++ b/.vortex/installer/src/Prompts/Handlers/HostingProjectName.php @@ -81,10 +81,10 @@ public function discover(): null|string|bool|array { // instead of a hardcoded project name. Kept for backward compatibility // with older installations. $acquia_settings_file = $this->destinationDir . sprintf('/%s/sites/default/includes/providers/settings.acquia.php', $this->webroot); - if (file_exists($acquia_settings_file)) { - $content = file_get_contents($acquia_settings_file); + if (File::isReadable($acquia_settings_file)) { + $content = File::read($acquia_settings_file); // Require '/var/www/site-php/your_site/your_site-settings.inc';. - if ($content !== FALSE && preg_match('/require\s+[\'"]\/var\/www\/site-php\/([a-z0-9_]+)\/[a-z0-9_]+-settings\.inc[\'"]\s*;/', $content, $matches) && !empty($matches[1])) { + if (preg_match('/require\s+[\'"]\/var\/www\/site-php\/([a-z0-9_]+)\/[a-z0-9_]+-settings\.inc[\'"]\s*;/', $content, $matches) && !empty($matches[1])) { return $matches[1]; } } @@ -95,9 +95,9 @@ public function discover(): null|string|bool|array { } $lagoon_site_file = $this->destinationDir . '/drush/sites/lagoon.site.yml'; - if (file_exists($lagoon_site_file)) { - $content = file_get_contents($lagoon_site_file); - if ($content !== FALSE && preg_match('/user:\s*([a-z0-9_]+)-/', $content, $matches) && (!empty($matches[1]) && $matches[1] !== 'your_site')) { + if (File::isReadable($lagoon_site_file)) { + $content = File::read($lagoon_site_file); + if (preg_match('/user:\s*([a-z0-9_]+)-/', $content, $matches) && (!empty($matches[1]) && $matches[1] !== 'your_site')) { return $matches[1]; } } diff --git a/.vortex/installer/src/Prompts/Handlers/HostingProvider.php b/.vortex/installer/src/Prompts/Handlers/HostingProvider.php index 928258f2a..099c126d8 100644 --- a/.vortex/installer/src/Prompts/Handlers/HostingProvider.php +++ b/.vortex/installer/src/Prompts/Handlers/HostingProvider.php @@ -62,11 +62,11 @@ public function default(array $responses): null|string|bool|array { * {@inheritdoc} */ public function discover(): null|string|bool|array { - if (is_readable($this->destinationDir . '/hooks') || Env::getFromDotenv('VORTEX_FETCH_DB_SOURCE', $this->destinationDir) === DatabaseFetchSource::ACQUIA) { + if (File::exists($this->destinationDir . '/hooks') || Env::getFromDotenv('VORTEX_FETCH_DB_SOURCE', $this->destinationDir) === DatabaseFetchSource::ACQUIA) { return self::ACQUIA; } - if (is_readable($this->destinationDir . '/.lagoon.yml')) { + if (File::exists($this->destinationDir . '/.lagoon.yml')) { return self::LAGOON; } diff --git a/.vortex/installer/src/Prompts/Handlers/Internal.php b/.vortex/installer/src/Prompts/Handlers/Internal.php index c95b5d266..15d99bf70 100644 --- a/.vortex/installer/src/Prompts/Handlers/Internal.php +++ b/.vortex/installer/src/Prompts/Handlers/Internal.php @@ -77,8 +77,8 @@ public function process(): void { return $content; }); - if (file_exists($t . '/README.dist.md')) { - rename($t . '/README.dist.md', $t . '/README.md'); + if (File::exists($t . '/README.dist.md')) { + File::rename($t . '/README.dist.md', $t . '/README.md'); } // Remove Vortex internal files. @@ -162,7 +162,7 @@ protected function processDemoMode(array $responses, string $dir): void { $is_demo = FALSE; } elseif ($responses[ProvisionType::id()] === ProvisionType::DATABASE) { - $db_file_exists = file_exists(Env::get('VORTEX_DB_DIR', './.data') . '/' . Env::get('VORTEX_DB_FILE', 'db.sql')); + $db_file_exists = File::exists(Env::get('VORTEX_DB_DIR', './.data') . '/' . Env::get('VORTEX_DB_FILE', 'db.sql')); $has_comment = File::contains($this->destinationDir . '/.env', 'Override project-specific values for demonstration purposes'); // Demo mode applies only to the URL and container registry download diff --git a/.vortex/installer/src/Prompts/Handlers/LabelMergeConflictsPr.php b/.vortex/installer/src/Prompts/Handlers/LabelMergeConflictsPr.php index 8320f38bb..ea1f0c8cb 100644 --- a/.vortex/installer/src/Prompts/Handlers/LabelMergeConflictsPr.php +++ b/.vortex/installer/src/Prompts/Handlers/LabelMergeConflictsPr.php @@ -37,7 +37,7 @@ public function discover(): null|string|bool|array { return NULL; } - return file_exists($this->destinationDir . '/.github/workflows/label-merge-conflict.yml'); + return File::exists($this->destinationDir . '/.github/workflows/label-merge-conflict.yml'); } /** diff --git a/.vortex/installer/src/Prompts/Handlers/Modules.php b/.vortex/installer/src/Prompts/Handlers/Modules.php index 3cf545b4c..1b6ec7e85 100644 --- a/.vortex/installer/src/Prompts/Handlers/Modules.php +++ b/.vortex/installer/src/Prompts/Handlers/Modules.php @@ -185,7 +185,7 @@ public static function getAvailableModules(): array { * Array of module machine names (without drupal/ prefix), or NULL on error. */ protected function getModulesFromComposerFile(string $composer_file): ?array { - if (!file_exists($composer_file)) { + if (!File::isReadable($composer_file)) { return NULL; } diff --git a/.vortex/installer/src/Prompts/Handlers/PreserveDocsProject.php b/.vortex/installer/src/Prompts/Handlers/PreserveDocsProject.php index de83c391a..25f518a5e 100644 --- a/.vortex/installer/src/Prompts/Handlers/PreserveDocsProject.php +++ b/.vortex/installer/src/Prompts/Handlers/PreserveDocsProject.php @@ -37,7 +37,7 @@ public function discover(): null|string|bool|array { return NULL; } - return file_exists($this->destinationDir . '/docs/README.md'); + return File::exists($this->destinationDir . '/docs/README.md'); } /** diff --git a/.vortex/installer/src/Prompts/Handlers/Theme.php b/.vortex/installer/src/Prompts/Handlers/Theme.php index 106bee8ae..a091f087e 100644 --- a/.vortex/installer/src/Prompts/Handlers/Theme.php +++ b/.vortex/installer/src/Prompts/Handlers/Theme.php @@ -127,7 +127,7 @@ public function process(): void { if (in_array($v, [self::OLIVERO, self::CLARO, self::STARK])) { $file_tmpl = self::findThemeFile($t, $w); - if (!empty($file_tmpl) && is_readable($file_tmpl)) { + if (!empty($file_tmpl) && File::exists($file_tmpl)) { File::remove(dirname($file_tmpl)); File::rmdirIfEmpty(dirname($file_tmpl)); @@ -149,7 +149,7 @@ public function process(): void { if ($this->isInstalled() && (empty($file_dst) || !self::isVortexTheme(dirname($file_dst)))) { $file_tmpl = self::findThemeFile($t, $w); - if (!empty($file_tmpl) && is_readable($file_tmpl)) { + if (!empty($file_tmpl) && File::exists($file_tmpl)) { File::remove(dirname($file_tmpl)); } } @@ -211,8 +211,8 @@ protected static function findThemeFile(string $dir, string $webroot, ?string $t } protected static function isVortexTheme(string $dir): bool { - $c1 = file_exists($dir . '/scss/_variables.scss'); - $c2 = file_exists($dir . '/package.json'); + $c1 = File::exists($dir . '/scss/_variables.scss'); + $c2 = File::exists($dir . '/package.json'); $c3 = File::contains($dir . '/package.json', 'build-dev'); return $c1 && $c2 && $c3; diff --git a/.vortex/installer/src/Prompts/Handlers/VisualRegression.php b/.vortex/installer/src/Prompts/Handlers/VisualRegression.php index 5c14bfffd..bbf74ed8a 100644 --- a/.vortex/installer/src/Prompts/Handlers/VisualRegression.php +++ b/.vortex/installer/src/Prompts/Handlers/VisualRegression.php @@ -60,7 +60,7 @@ public function discover(): null|string|bool|array { return NULL; } - return file_exists($this->destinationDir . '/.github/workflows/test-vr.yml'); + return File::exists($this->destinationDir . '/.github/workflows/test-vr.yml'); } /** diff --git a/.vortex/installer/src/Prompts/Handlers/Webroot.php b/.vortex/installer/src/Prompts/Handlers/Webroot.php index 389e3b776..2007c721c 100644 --- a/.vortex/installer/src/Prompts/Handlers/Webroot.php +++ b/.vortex/installer/src/Prompts/Handlers/Webroot.php @@ -150,7 +150,7 @@ public function process(): void { File::replaceContentAsync(fn(string $content): string => preg_replace('/=' . preg_quote($webroot, '/') . '\b/', '=' . $v, $content) ?? $content); - rename($t . '/' . $webroot, $t . '/' . $v); + File::rename($t . '/' . $webroot, $t . '/' . $v); } } diff --git a/.vortex/installer/src/Utils/Env.php b/.vortex/installer/src/Utils/Env.php index 04ef345ae..2f6bdff56 100644 --- a/.vortex/installer/src/Utils/Env.php +++ b/.vortex/installer/src/Utils/Env.php @@ -33,7 +33,7 @@ public static function getFromDotenv(string $name, string $dir): ?string { } $file = $dir . '/.env'; - if (!is_readable($file)) { + if (!File::isReadable($file)) { return NULL; } @@ -82,16 +82,11 @@ public static function putFromDotenv(string $filename = '.env', bool $override_e * Array of parsed values, key is the variable name. */ public static function parseDotenv(string $filename = '.env'): array { - if (!is_file($filename) || !is_readable($filename)) { + if (!File::isReadable($filename)) { return []; } - $contents = file_get_contents($filename); - if ($contents === FALSE) { - // @codeCoverageIgnoreStart - return []; - // @codeCoverageIgnoreEnd - } + $contents = File::read($filename); // Replace all # not inside quotes. $contents = preg_replace('/#(?=(?:(?:[^"]*"){2})*[^"]*$)/', ';', $contents); @@ -140,16 +135,11 @@ public static function parseDotenv(string $filename = '.env'): array { * Array of parsed values after modification. */ public static function writeValueDotenv(string $name, ?string $value = NULL, string $filename = '.env', bool $enabled = TRUE): array { - if (!is_readable($filename)) { + if (!File::isReadable($filename)) { throw new \RuntimeException(sprintf('File "%s" is not readable.', $filename)); } - $contents = file_get_contents($filename); - if ($contents === FALSE) { - // @codeCoverageIgnoreStart - throw new \RuntimeException(sprintf('Unable to read file "%s".', $filename)); - // @codeCoverageIgnoreEnd - } + $contents = File::read($filename); // Pattern to match the variable name and its value, including multiline // quoted values. Matches both normal and commented-out variables. @@ -185,11 +175,7 @@ public static function writeValueDotenv(string $name, ?string $value = NULL, str } } - if (file_put_contents($filename, $contents) === FALSE) { - // @codeCoverageIgnoreStart - throw new \RuntimeException(sprintf('Unable to write to file "%s".', $filename)); - // @codeCoverageIgnoreEnd - } + File::dump($filename, $contents); return self::parseDotenv($filename); } diff --git a/.vortex/installer/src/Utils/File.php b/.vortex/installer/src/Utils/File.php index 37dfd7d1b..ca84481c3 100644 --- a/.vortex/installer/src/Utils/File.php +++ b/.vortex/installer/src/Utils/File.php @@ -5,7 +5,9 @@ namespace DrevOps\VortexInstaller\Utils; use AlexSkrypnyk\File\ContentFile\ContentFile; +use AlexSkrypnyk\File\Exception\FileException; use AlexSkrypnyk\File\File as UpstreamFile; +use Symfony\Component\Filesystem\Filesystem; class File extends UpstreamFile { @@ -30,6 +32,75 @@ public static function isInternal(string $path): bool { return in_array($path, self::internalPaths()); } + /** + * Check if path is a regular file with readable contents. + * + * Distinct from exists(), which is also TRUE for a directory: this answers + * whether the path can be passed to read(). + */ + public static function isReadable(string $path): bool { + return is_file($path) && is_readable($path); + } + + /** + * Check if an existing path can be written to. + * + * A dump() replaces a file by renaming a temporary one over it, which + * succeeds on a read-only file whose directory is writable, so a caller that + * must respect the file's own permissions checks them here first. + */ + public static function isWritable(string $path): bool { + return is_writable($path); + } + + /** + * Check if path is a directory. + * + * Distinct from exists(), which is also TRUE for a file. + */ + public static function isDir(string $path): bool { + return is_dir($path); + } + + /** + * Get the size of a file in bytes. + * + * @throws \AlexSkrypnyk\File\Exception\FileException + * When the size cannot be read. + */ + public static function size(string $path): int { + $size = @filesize($path); + + if ($size === FALSE) { + throw new FileException(sprintf('Unable to read the size of "%s".', $path)); + } + + return $size; + } + + /** + * Move a file or directory. + * + * An existing target is replaced by default, as rename() does; pass FALSE to + * fail instead. + * + * @throws \Symfony\Component\Filesystem\Exception\IOException + * When the move fails. + */ + public static function rename(string $origin, string $target, bool $overwrite = TRUE): void { + (new Filesystem())->rename($origin, $target, $overwrite); + } + + /** + * Change the mode of a file or directory. + * + * @throws \Symfony\Component\Filesystem\Exception\IOException + * When the mode cannot be changed. + */ + public static function chmod(string $path, int $mode, bool $recursive = FALSE): void { + (new Filesystem())->chmod($path, $mode, 0000, $recursive); + } + /** * Get list of internal paths. */ diff --git a/.vortex/installer/src/Utils/FileManager.php b/.vortex/installer/src/Utils/FileManager.php index 7fea04713..e881b6ae1 100644 --- a/.vortex/installer/src/Utils/FileManager.php +++ b/.vortex/installer/src/Utils/FileManager.php @@ -145,12 +145,12 @@ public function prepareDestination(): array { $messages = []; $destination = $this->config->getDestination(); - if (!is_dir($destination)) { + if (!File::isDir($destination)) { $destination = File::mkdir($destination); $messages[] = sprintf('Created directory "%s".', $destination); } - if (!is_readable($destination . '/.git')) { + if (!File::exists($destination . '/.git')) { $messages[] = sprintf('Initializing a new Git repository in directory "%s".', $destination); // The destination arrives from a CLI option, the environment or a config @@ -160,7 +160,7 @@ public function prepareDestination(): array { $command = sprintf('git -c advice.defaultBranchName=false --work-tree=%s --git-dir=%s init > /dev/null', escapeshellarg($destination), escapeshellarg($destination . '/.git')); passthru($command, $exit_code); - if ($exit_code !== 0 || !File::exists($destination . '/.git')) { + if ($exit_code !== 0) { throw new \RuntimeException(sprintf('Unable to initialize Git repository in directory "%s".', $destination)); } } @@ -199,7 +199,7 @@ public function copyFiles(): void { } foreach ($ignored_files as $ignored_file) { - if (is_readable($ignored_file)) { + if (File::exists($ignored_file)) { File::remove($ignored_file); } } @@ -210,11 +210,11 @@ public function copyFiles(): void { $this->recordReplacedChanges($src); - if (is_dir($src) && !File::dirIsEmpty($src)) { + if (File::isDir($src) && !File::dirIsEmpty($src)) { File::copy($src, $destination); } - if (!file_exists($destination . '/.env.local') && file_exists($destination . '/.env.local.example')) { + if (!File::exists($destination . '/.env.local') && File::exists($destination . '/.env.local.example')) { File::copy($destination . '/.env.local.example', $destination . '/.env.local'); } @@ -255,7 +255,7 @@ protected function removeExcludedPaths(array $paths, array $expected): void { $target = $destination . '/' . $path; - if (!is_file($target)) { + if (!File::isReadable($target)) { continue; } @@ -302,7 +302,7 @@ protected function recordReplacedChanges(string $src): void { foreach ($this->relativePaths($src) as $path) { $project = $destination . '/' . $path; - if (!is_file($project)) { + if (!File::isReadable($project)) { continue; } @@ -319,7 +319,7 @@ protected function recordReplacedChanges(string $src): void { // diff the project's copy against. $previous = $this->previousDir . '/' . $path; - $registry->add($path, is_file($previous) ? File::read($previous) : NULL, File::read($project), File::read($next)); + $registry->add($path, File::isReadable($previous) ? File::read($previous) : NULL, File::read($project), File::read($next)); } $this->registryFile = $registry->write((string) $this->previousRef, (string) $this->config->get(Config::VERSION), date('Y-m-d H:i:s')); @@ -360,7 +360,7 @@ protected function hashDirectory(string $directory): array { foreach ($this->relativePaths($directory) as $path) { $file = $directory . '/' . $path; - if (is_file($file)) { + if (File::isReadable($file)) { $hashes[$path] = (string) hash_file(self::HASH_ALGO, $file); } } @@ -378,7 +378,7 @@ protected function hashDirectory(string $directory): array { * Relative file paths. */ protected function relativePaths(string $directory): array { - if (!is_dir($directory)) { + if (!File::isDir($directory)) { return []; } @@ -411,7 +411,7 @@ public function removeObsoletePaths(): void { foreach ($obsolete as $relative) { $path = $destination . '/' . $relative; - if (file_exists($path)) { + if (File::exists($path)) { File::remove($path); } } @@ -445,12 +445,12 @@ public function prepareDemo(Downloader $downloader): array|string { $data_dir = $this->config->getDestination() . '/' . Env::get('VORTEX_DB_DIR', './.data'); $db_file = Env::get('VORTEX_DB_FILE', 'db.sql'); - if (file_exists($data_dir . '/' . $db_file)) { + if (File::exists($data_dir . '/' . $db_file)) { return 'Database dump file already exists. Skipping demo database fetch.'; } $messages = []; - if (!file_exists($data_dir)) { + if (!File::exists($data_dir)) { $data_dir = File::mkdir($data_dir); $messages[] = sprintf('Created data directory "%s".', $data_dir); } diff --git a/.vortex/installer/src/Utils/Git.php b/.vortex/installer/src/Utils/Git.php index 5c7732253..223233a00 100644 --- a/.vortex/installer/src/Utils/Git.php +++ b/.vortex/installer/src/Utils/Git.php @@ -90,7 +90,7 @@ public static function extractOwnerRepo(string $uri): ?string { * @todo Refactor to use GitPhp. */ public static function getTrackedFiles(string $dir): array { - if (!is_dir($dir . '/.git')) { + if (!File::isDir($dir . '/.git')) { throw new \RuntimeException('The directory is not a Git repository.'); } diff --git a/.vortex/installer/src/Utils/JsonManipulator.php b/.vortex/installer/src/Utils/JsonManipulator.php index 78bfab75f..caed53353 100644 --- a/.vortex/installer/src/Utils/JsonManipulator.php +++ b/.vortex/installer/src/Utils/JsonManipulator.php @@ -14,20 +14,11 @@ public function __construct(protected string $contents) { } public static function fromFile(string $composer_json): ?self { - if (!is_readable($composer_json) || !is_file($composer_json)) { + if (!File::isReadable($composer_json)) { return NULL; } - $contents = file_get_contents($composer_json); - if ($contents === FALSE) { - // @codeCoverageIgnoreStart - throw new \RuntimeException(sprintf( - 'Unable to read composer.json from %s: %s', - $composer_json, - error_get_last()['message'] ?? 'unknown error' - )); - // @codeCoverageIgnoreEnd - } + $contents = File::read($composer_json); try { $instance = new self($contents); @@ -62,9 +53,7 @@ public static function updateFile(string $file, callable $callback): void { $callback($instance); $contents = $instance->getContents(); - if (file_put_contents($file, $contents) !== strlen($contents)) { - throw new \RuntimeException(sprintf('Unable to write a JSON file at "%s".', $file)); - } + File::dump($file, $contents); } /** diff --git a/.vortex/installer/src/Utils/NpmLock.php b/.vortex/installer/src/Utils/NpmLock.php index 15bfd6ac8..7e29de64e 100644 --- a/.vortex/installer/src/Utils/NpmLock.php +++ b/.vortex/installer/src/Utils/NpmLock.php @@ -41,7 +41,7 @@ class NpmLock { public static function sync(string $manifest_file): void { $lock_file = dirname($manifest_file) . DIRECTORY_SEPARATOR . self::FILE; - if (!is_file($lock_file)) { + if (!File::isReadable($lock_file)) { return; } @@ -160,13 +160,7 @@ protected static function resolve(\stdClass $packages, string $from, string $nam * Decode a JSON file into objects. */ protected static function read(string $file): \stdClass { - $contents = file_get_contents($file); - - if ($contents === FALSE) { - // @codeCoverageIgnoreStart - throw new \RuntimeException(sprintf('Unable to read a JSON file at "%s".', $file)); - // @codeCoverageIgnoreEnd - } + $contents = File::read($file); try { $decoded = json_decode($contents, FALSE, 512, JSON_THROW_ON_ERROR); @@ -201,9 +195,11 @@ protected static function write(string $file, \stdClass $data): void { $json .= "\n"; - if (!is_writable($file) || file_put_contents($file, $json) !== strlen($json)) { + if (!File::isWritable($file)) { throw new \RuntimeException(sprintf('Unable to write a JSON file at "%s".', $file)); } + + File::dump($file, $json); } } diff --git a/.vortex/installer/src/Utils/OptionsResolver.php b/.vortex/installer/src/Utils/OptionsResolver.php index f82393c58..ae3e2d0c0 100644 --- a/.vortex/installer/src/Utils/OptionsResolver.php +++ b/.vortex/installer/src/Utils/OptionsResolver.php @@ -35,6 +35,32 @@ public static function checkRequirements(ExecutableFinder $finder): void { } } + /** + * Read a JSON option given either as a file path or as a literal value. + * + * @param string $value + * The option value. + * @param string $option + * The option name, for the error message. + * + * @return string + * The JSON string. + * + * @throws \RuntimeException + * When the value names an existing path whose contents cannot be read. + */ + public static function readJsonOption(string $value, string $option): string { + if (!File::exists($value)) { + return $value; + } + + if (!File::isReadable($value)) { + throw new \RuntimeException(sprintf('Unable to read %s file: "%s".', $option, $value)); + } + + return File::read($value); + } + /** * Instantiate configuration from CLI options and environment variables. * @@ -53,8 +79,7 @@ public static function checkRequirements(ExecutableFinder $finder): void { public static function resolve(array $options): array { $config_json = '{}'; if (isset($options['config']) && is_scalar($options['config'])) { - $config_candidate = (string) $options['config']; - $config_json = is_file($config_candidate) ? (string) file_get_contents($config_candidate) : $config_candidate; + $config_json = self::readJsonOption((string) $options['config'], '--config'); } $config = Config::fromString($config_json); @@ -102,7 +127,7 @@ public static function resolve(array $options): array { throw new \RuntimeException(sprintf('Invalid repository URI: %s.', $e->getMessage()), $e->getCode(), $e); } - $config->set(Config::IS_VORTEX_PROJECT, File::contains($config->getDestination() . '/README.md', '/badge\/Vortex-/')); + $config->set(Config::IS_VORTEX_PROJECT, File::contains($config->getDestination() . '/README.md', Version::BADGE_REGEX)); // Flag to proceed with installation. If FALSE, the installation only // prints the resolved values and does not proceed. @@ -117,16 +142,7 @@ public static function resolve(array $options): array { $config->set(Config::IS_DEMO_DB_FETCH_SKIP, (bool) Env::get(Config::IS_DEMO_DB_FETCH_SKIP, FALSE)); if (isset($options['prompts']) && is_scalar($options['prompts'])) { - $prompts_candidate = (string) $options['prompts']; - if (is_file($prompts_candidate)) { - if (!is_readable($prompts_candidate)) { - throw new \RuntimeException(sprintf('Unable to read --prompts file: "%s".', $prompts_candidate)); - } - $prompts_json = (string) file_get_contents($prompts_candidate); - } - else { - $prompts_json = $prompts_candidate; - } + $prompts_json = self::readJsonOption((string) $options['prompts'], '--prompts'); $prompts = json_decode($prompts_json, TRUE); if (!is_array($prompts)) { diff --git a/.vortex/installer/src/Utils/Tui.php b/.vortex/installer/src/Utils/Tui.php index afbfaf29d..76fc5adf4 100644 --- a/.vortex/installer/src/Utils/Tui.php +++ b/.vortex/installer/src/Utils/Tui.php @@ -156,7 +156,7 @@ public static function caretUp(): string { public static function caretEol(string $text): string { $lines = explode(PHP_EOL, $text); - $longest = max(array_map(strlen(...), $lines)); + $longest = max(array_map(Strings::strlenPlain(...), $lines)); return "\033[" . $longest . "C"; } diff --git a/.vortex/installer/src/Utils/UpdateRegistry.php b/.vortex/installer/src/Utils/UpdateRegistry.php index 517b387b5..a3623c51f 100644 --- a/.vortex/installer/src/Utils/UpdateRegistry.php +++ b/.vortex/installer/src/Utils/UpdateRegistry.php @@ -98,7 +98,14 @@ public function write(string $from, string $to, string $time): ?string { } $file = $this->destination . '/' . self::FILE; - $existing = is_file($file) ? File::read($file) : self::HEADING . PHP_EOL; + + // A registry that exists but cannot be read would otherwise be replaced by + // the heading, discarding every entry recorded before this run. + if (File::exists($file) && !File::isReadable($file)) { + throw new \RuntimeException(sprintf('Unable to read the update registry "%s".', $file)); + } + + $existing = File::exists($file) ? File::read($file) : self::HEADING . PHP_EOL; File::dump($file, $existing . $content); diff --git a/.vortex/installer/src/Utils/Version.php b/.vortex/installer/src/Utils/Version.php index 0e17bbfdf..e2916cf08 100644 --- a/.vortex/installer/src/Utils/Version.php +++ b/.vortex/installer/src/Utils/Version.php @@ -15,6 +15,14 @@ */ class Version { + /** + * Pattern matching the Vortex badge in a project README. + * + * The capture group holds the git reference the badge was stamped with, so + * a README that matches is one a reference can be read from. + */ + const BADGE_REGEX = '#badge/Vortex-(.+?)-65ACBC\.svg#'; + /** * Extract the major version number from a version string. * @@ -85,13 +93,13 @@ public static function majorFromConstraint(?string $constraint): ?int { public static function detectProjectRef(string $dir): ?string { $readme = $dir . '/README.md'; - if (!is_file($readme)) { + if (!File::isReadable($readme)) { return NULL; } - $contents = (string) file_get_contents($readme); + $contents = File::read($readme); - if (!preg_match('#badge/Vortex-(.+?)-65ACBC\.svg#', $contents, $matches)) { + if (!preg_match(self::BADGE_REGEX, $contents, $matches)) { return NULL; } @@ -117,11 +125,11 @@ public static function detectProjectRef(string $dir): ?string { public static function detectProjectMajor(string $dir): ?int { $composer_json = $dir . '/composer.json'; - if (!is_file($composer_json)) { + if (!File::isReadable($composer_json)) { return NULL; } - $data = json_decode((string) file_get_contents($composer_json), TRUE); + $data = json_decode(File::read($composer_json), TRUE); if (!is_array($data)) { return NULL; } diff --git a/.vortex/installer/src/Utils/Yaml.php b/.vortex/installer/src/Utils/Yaml.php index 3d69acf12..ad3262209 100644 --- a/.vortex/installer/src/Utils/Yaml.php +++ b/.vortex/installer/src/Utils/Yaml.php @@ -10,7 +10,7 @@ class Yaml extends SymfonyYaml { public static function validateFile(string $path): void { - if (!file_exists($path) || !is_readable($path)) { + if (!File::isReadable($path)) { throw new \RuntimeException(sprintf('File does not exist or is not readable: "%s".', $path)); } diff --git a/.vortex/installer/tests/Functional/Command/BuildCommandTest.php b/.vortex/installer/tests/Functional/Command/BuildCommandTest.php index 739408af6..8e587a276 100644 --- a/.vortex/installer/tests/Functional/Command/BuildCommandTest.php +++ b/.vortex/installer/tests/Functional/Command/BuildCommandTest.php @@ -4,7 +4,7 @@ namespace DrevOps\VortexInstaller\Tests\Functional\Command; -use AlexSkrypnyk\File\File; +use DrevOps\VortexInstaller\Utils\File; use DrevOps\VortexInstaller\Command\BuildCommand; use DrevOps\VortexInstaller\Command\CheckRequirementsCommand; use DrevOps\VortexInstaller\Logger\FileLoggerInterface; diff --git a/.vortex/installer/tests/Functional/Command/CheckRequirementsCommandTest.php b/.vortex/installer/tests/Functional/Command/CheckRequirementsCommandTest.php index 6685ffc62..3c4be9267 100644 --- a/.vortex/installer/tests/Functional/Command/CheckRequirementsCommandTest.php +++ b/.vortex/installer/tests/Functional/Command/CheckRequirementsCommandTest.php @@ -4,7 +4,7 @@ namespace DrevOps\VortexInstaller\Tests\Functional\Command; -use AlexSkrypnyk\File\File; +use DrevOps\VortexInstaller\Utils\File; use DrevOps\VortexInstaller\Command\CheckRequirementsCommand; use DrevOps\VortexInstaller\Runner\ProcessRunner; use DrevOps\VortexInstaller\Runner\RunnerInterface; diff --git a/.vortex/installer/tests/Functional/Command/InstallCommandTest.php b/.vortex/installer/tests/Functional/Command/InstallCommandTest.php index 39b968f43..abbbc39c1 100644 --- a/.vortex/installer/tests/Functional/Command/InstallCommandTest.php +++ b/.vortex/installer/tests/Functional/Command/InstallCommandTest.php @@ -523,8 +523,8 @@ public function testInstallCommandMajorGate(?string $version, string $composer_j // Pre-populate the destination so it looks like an existing Vortex project: // the README badge flags it as a Vortex project and the composer.json // 'drevops/vortex-tooling' constraint carries the project's major. - $this->assertNotFalse(file_put_contents(self::$sut . '/README.md', '[![Vortex](https://img.shields.io/badge/Vortex-1.40.0-65ACBC.svg)](https://github.com/drevops/vortex)')); - $this->assertNotFalse(file_put_contents(self::$sut . '/composer.json', $composer_json)); + File::dump(self::$sut . '/README.md', '[![Vortex](https://img.shields.io/badge/Vortex-1.40.0-65ACBC.svg)](https://github.com/drevops/vortex)'); + File::dump(self::$sut . '/composer.json', $composer_json); static::applicationInitFromCommand($install_command); diff --git a/.vortex/installer/tests/Functional/PharTest.php b/.vortex/installer/tests/Functional/PharTest.php index 1264b5f50..2b0ce6a91 100644 --- a/.vortex/installer/tests/Functional/PharTest.php +++ b/.vortex/installer/tests/Functional/PharTest.php @@ -84,7 +84,7 @@ public function testPharOptionHelp(): void { protected static function buildPhar(string $destination): void { fwrite(STDERR, 'Building installer PHAR file...'); - if (!file_exists('vendor')) { + if (!File::exists('vendor')) { $exit_code = 0; passthru('composer install --no-dev --optimize-autoloader >/dev/null 2>&1 ', $exit_code); if ($exit_code !== 0) { diff --git a/.vortex/installer/tests/Functional/Prompts/Handlers/AbstractHandlerProcessTestCase.php b/.vortex/installer/tests/Functional/Prompts/Handlers/AbstractHandlerProcessTestCase.php index ddc419153..059132549 100644 --- a/.vortex/installer/tests/Functional/Prompts/Handlers/AbstractHandlerProcessTestCase.php +++ b/.vortex/installer/tests/Functional/Prompts/Handlers/AbstractHandlerProcessTestCase.php @@ -89,7 +89,7 @@ public function testHandlerProcess( abstract public static function dataProviderHandlerProcess(): \Iterator; protected function assertCommon(): void { - if (file_exists(static::$root . '/scripts/vortex.yml')) { + if (File::exists(static::$root . '/scripts/vortex.yml')) { $this->assertFileEquals(static::$root . '/tests/behat/fixtures/image.jpg', static::$sut . '/tests/behat/fixtures/image.jpg', 'Binary files were not modified.'); } diff --git a/.vortex/installer/tests/Functional/Prompts/Handlers/ToolsHandlerProcessTest.php b/.vortex/installer/tests/Functional/Prompts/Handlers/ToolsHandlerProcessTest.php index e9351cfb9..07ab299d6 100644 --- a/.vortex/installer/tests/Functional/Prompts/Handlers/ToolsHandlerProcessTest.php +++ b/.vortex/installer/tests/Functional/Prompts/Handlers/ToolsHandlerProcessTest.php @@ -7,6 +7,7 @@ use DrevOps\VortexInstaller\Prompts\Handlers\CiProvider; use DrevOps\VortexInstaller\Prompts\Handlers\Theme; use DrevOps\VortexInstaller\Prompts\Handlers\Tools; +use DrevOps\VortexInstaller\Utils\File; use PHPUnit\Framework\Attributes\CoversClass; #[CoversClass(Tools::class)] @@ -718,7 +719,7 @@ protected static function assertNpmLockLacksPackages(string $manifest_file, arra protected static function readJson(string $file): array { self::assertFileExists($file); - return (array) json_decode((string) file_get_contents($file), TRUE, 512, JSON_THROW_ON_ERROR); + return (array) json_decode(File::read($file), TRUE, 512, JSON_THROW_ON_ERROR); } } diff --git a/.vortex/installer/tests/Unit/Downloader/ArchiverTest.php b/.vortex/installer/tests/Unit/Downloader/ArchiverTest.php index cdd0e5f50..e0969d030 100644 --- a/.vortex/installer/tests/Unit/Downloader/ArchiverTest.php +++ b/.vortex/installer/tests/Unit/Downloader/ArchiverTest.php @@ -4,7 +4,7 @@ namespace DrevOps\VortexInstaller\Tests\Unit\Downloader; -use AlexSkrypnyk\File\File; +use DrevOps\VortexInstaller\Utils\File; use DrevOps\VortexInstaller\Downloader\Archiver; use DrevOps\VortexInstaller\Tests\Unit\UnitTestCase; use PHPUnit\Framework\Attributes\CoversClass; @@ -124,7 +124,7 @@ public function testExtract(string $creator, bool $strip, string $expected_path) $this->archiver->extract($archive_path, $destination, $strip); $this->assertFileExists($destination . $expected_path); - $this->assertEquals('Test content', file_get_contents($destination . $expected_path)); + $this->assertEquals('Test content', File::read($destination . $expected_path)); if ($strip) { $this->assertFileDoesNotExist($destination . '/test_archive'); @@ -224,7 +224,7 @@ protected function createTestTarGz(): string { $phar->buildFromDirectory($temp_dir); $phar->compress(\Phar::GZ); - rename($temp_dir . '/test.tar.gz', $archive_path); + File::rename($temp_dir . '/test.tar.gz', $archive_path); return $archive_path; } diff --git a/.vortex/installer/tests/Unit/Downloader/RepositoryDownloaderTest.php b/.vortex/installer/tests/Unit/Downloader/RepositoryDownloaderTest.php index 9aae68f9e..28e4cbdcf 100644 --- a/.vortex/installer/tests/Unit/Downloader/RepositoryDownloaderTest.php +++ b/.vortex/installer/tests/Unit/Downloader/RepositoryDownloaderTest.php @@ -4,7 +4,7 @@ namespace DrevOps\VortexInstaller\Tests\Unit\Downloader; -use AlexSkrypnyk\File\File; +use DrevOps\VortexInstaller\Utils\File; use DrevOps\VortexInstaller\Downloader\ArchiverInterface; use DrevOps\VortexInstaller\Downloader\Artifact; use DrevOps\VortexInstaller\Downloader\Downloader; @@ -469,129 +469,168 @@ public function testDownloadArchiveWithGithubToken(): void { $this->assertEquals('develop', $version); } - public function testValidateRemoteRepositoryExistsWithNotFoundError(): void { - $mock_http_client = $this->createMock(ClientInterface::class); - $mock_response = $this->createMock(ResponseInterface::class); - $mock_response->method('getStatusCode')->willReturn(404); - $mock_http_client->method('request')->willReturn($mock_response); - $destination = self::$tmp . '/destination_' . uniqid(); - File::mkdir($destination); - $downloader = new RepositoryDownloader($mock_http_client); - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Repository not found or not accessible: "https://github.com/user/nonexistent" (HTTP 404)'); - $downloader->download(Artifact::create('https://github.com/user/nonexistent', '1.0.0'), $destination); - } + /** + * @param \Closure(self):array{0:?\GuzzleHttp\ClientInterface,1:string} $setup + * Builds the HTTP client (NULL for a local repository) and the repository + * to download from. + * @param string $ref + * Reference to download. + * @param string $expected_message + * Substring the thrown message must contain. + */ + #[DataProvider('dataProviderValidateFailure')] + public function testValidateFailure(\Closure $setup, string $ref, string $expected_message): void { + [$http_client, $repo] = $setup($this); - public function testValidateRemoteRefExistsWithNotFoundError(): void { - $mock_http_client = $this->createMock(ClientInterface::class); - $repo_response = $this->createMock(ResponseInterface::class); - $repo_response->method('getStatusCode')->willReturn(200); - $ref_response = $this->createMock(ResponseInterface::class); - $ref_response->method('getStatusCode')->willReturn(404); - $mock_http_client->method('request')->willReturnCallback(function ($method, $url) use ($repo_response, $ref_response): ResponseInterface { - if (str_contains($url, '/archive/')) { - return $ref_response; - } - return $repo_response; - }); $destination = self::$tmp . '/destination_' . uniqid(); File::mkdir($destination); - $downloader = new RepositoryDownloader($mock_http_client); - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Reference "nonexistent-tag" not found in repository "https://github.com/user/repo"'); - $downloader->download(Artifact::create('https://github.com/user/repo', 'nonexistent-tag'), $destination); - } - public function testValidateLocalRepositoryExistsWithNonexistentPath(): void { - $nonexistent_path = self::$tmp . '/nonexistent_repo_' . uniqid(); - $destination = self::$tmp . '/destination_' . uniqid(); - File::mkdir($destination); - $downloader = new RepositoryDownloader(); + $downloader = new RepositoryDownloader($http_client); + $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage(sprintf('Local repository path does not exist: "%s"', $nonexistent_path)); - $downloader->download(Artifact::create($nonexistent_path, 'main'), $destination); + $this->expectExceptionMessage($expected_message); + + $downloader->download(Artifact::create($repo, $ref), $destination); } - public function testValidateLocalRepositoryExistsWithNonGitDirectory(): void { - $non_git_path = self::$tmp . '/non_git_dir_' . uniqid(); - File::mkdir($non_git_path); - $destination = self::$tmp . '/destination_' . uniqid(); - File::mkdir($destination); - $downloader = new RepositoryDownloader(); - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage(sprintf('Path is not a git repository: "%s"', $non_git_path)); - $downloader->download(Artifact::create($non_git_path, 'main'), $destination); + public static function dataProviderValidateFailure(): \Iterator { + yield 'remote repository not found' => [ + static fn(self $test): array => [$test->stubStatusClient(404), 'https://github.com/user/nonexistent'], + '1.0.0', + 'Repository not found or not accessible: "https://github.com/user/nonexistent" (HTTP 404)', + ]; + + yield 'remote ref not found' => [ + static fn(self $test): array => [$test->stubRefClient(404), 'https://github.com/user/repo'], + 'nonexistent-tag', + 'Reference "nonexistent-tag" not found in repository "https://github.com/user/repo"', + ]; + + yield 'remote repository unreachable' => [ + static fn(self $test): array => [$test->stubThrowingClient('Connection timeout'), 'https://github.com/user/repo'], + '1.0.0', + 'Unable to access repository "https://github.com/user/repo": Connection timeout.', + ]; + + yield 'remote ref unverifiable' => [ + static fn(self $test): array => [$test->stubRefClient(NULL, 'Network error'), 'https://github.com/user/repo'], + 'test-tag', + 'Unable to verify reference "test-tag" in repository "https://github.com/user/repo": Network error.', + ]; + + yield 'local path missing' => [ + static function (self $test): array { + $path = self::$tmp . '/nonexistent_repo_' . uniqid(); + + return [NULL, $path]; + }, + 'main', + 'Local repository path does not exist: ', + ]; + + yield 'local path not a git repository' => [ + static function (self $test): array { + $path = self::$tmp . '/non_git_dir_' . uniqid(); + File::mkdir($path); + + return [NULL, $path]; + }, + 'main', + 'Path is not a git repository: ', + ]; } - public function testValidateRemoteRepositoryExistsWithRequestException(): void { - $mock_http_client = $this->createMock(ClientInterface::class); - $mock_http_client->method('request')->willThrowException(new RequestException('Connection timeout', $this->createMock(RequestInterface::class))); - $destination = self::$tmp . '/destination_' . uniqid(); - File::mkdir($destination); - $downloader = new RepositoryDownloader($mock_http_client); - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Unable to access repository "https://github.com/user/repo": Connection timeout.'); - $downloader->download(Artifact::create('https://github.com/user/repo', '1.0.0'), $destination); + /** + * Stub a client answering every request with a single status code. + */ + protected function stubStatusClient(int $status_code): ClientInterface { + $client = $this->createMock(ClientInterface::class); + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn($status_code); + $client->method('request')->willReturn($response); + + return $client; } - public function testValidateRemoteRefExistsWithRequestException(): void { - $mock_http_client = $this->createMock(ClientInterface::class); + /** + * Stub a client where the repository resolves and the archive request fails. + * + * @param int|null $status_code + * Status code for the archive request, or NULL to throw instead. + * @param string|null $exception_message + * Message for the thrown request exception when no status code is given. + */ + protected function stubRefClient(?int $status_code, ?string $exception_message = NULL): ClientInterface { + $client = $this->createMock(ClientInterface::class); $repo_response = $this->createMock(ResponseInterface::class); $repo_response->method('getStatusCode')->willReturn(200); - $mock_http_client->method('request')->willReturnCallback(function ($method, $url) use ($repo_response): ResponseInterface { - if (str_contains($url, '/archive/')) { - throw new RequestException('Network error', $this->createMock(RequestInterface::class)); + + $ref_response = NULL; + if ($status_code !== NULL) { + $ref_response = $this->createMock(ResponseInterface::class); + $ref_response->method('getStatusCode')->willReturn($status_code); + } + + $request = $this->createMock(RequestInterface::class); + $client->method('request')->willReturnCallback(function ($method, $url) use ($repo_response, $ref_response, $exception_message, $request): ResponseInterface { + if (!str_contains($url, '/archive/')) { + return $repo_response; } - return $repo_response; + + if (!$ref_response instanceof ResponseInterface) { + throw new RequestException((string) $exception_message, $request); + } + + return $ref_response; }); - $destination = self::$tmp . '/destination_' . uniqid(); - File::mkdir($destination); - $downloader = new RepositoryDownloader($mock_http_client); - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Unable to verify reference "test-tag" in repository "https://github.com/user/repo": Network error.'); - $downloader->download(Artifact::create('https://github.com/user/repo', 'test-tag'), $destination); + + return $client; } - public function testValidateRemoteArtifactWithStableRef(): void { - $mock_http_client = $this->createMock(ClientInterface::class); - $mock_response = $this->createMock(ResponseInterface::class); - $mock_response->method('getStatusCode')->willReturn(200); - $mock_http_client->method('request')->willReturn($mock_response); - $downloader = new RepositoryDownloader($mock_http_client); - $artifact = Artifact::create('https://github.com/user/repo', 'stable'); - $downloader->validate($artifact); - $this->expectNotToPerformAssertions(); + /** + * Stub a client that fails every request with a transport error. + */ + protected function stubThrowingClient(string $message): ClientInterface { + $client = $this->createMock(ClientInterface::class); + $client->method('request')->willThrowException(new RequestException($message, $this->createMock(RequestInterface::class))); + + return $client; } - public function testValidateRemoteArtifactWithCustomRef(): void { + #[DataProvider('dataProviderValidateRemoteArtifact')] + public function testValidateRemoteArtifact(string $ref): void { $mock_http_client = $this->createMock(ClientInterface::class); $mock_response = $this->createMock(ResponseInterface::class); $mock_response->method('getStatusCode')->willReturn(200); $mock_http_client->method('request')->willReturn($mock_response); $downloader = new RepositoryDownloader($mock_http_client); - $artifact = Artifact::create('https://github.com/user/repo', 'v1.0.0'); - $downloader->validate($artifact); + + $downloader->validate(Artifact::create('https://github.com/user/repo', $ref)); + $this->expectNotToPerformAssertions(); } - public function testValidateLocalArtifactWithHeadRef(): void { - $temp_repo_dir = $this->createGitRepo(); - $downloader = new RepositoryDownloader(); - $artifact = Artifact::create($temp_repo_dir, 'HEAD'); - $downloader->validate($artifact); - $this->expectNotToPerformAssertions(); - $this->removeGitRepo($temp_repo_dir); + public static function dataProviderValidateRemoteArtifact(): \Iterator { + yield 'stable ref' => ['stable']; + yield 'custom ref' => ['v1.0.0']; } - public function testValidateLocalArtifactWithCustomRef(): void { + #[DataProvider('dataProviderValidateLocalArtifact')] + public function testValidateLocalArtifact(string $ref): void { $temp_repo_dir = $this->createGitRepo(); $downloader = new RepositoryDownloader(); - $artifact = Artifact::create($temp_repo_dir, 'main'); - $downloader->validate($artifact); + + $downloader->validate(Artifact::create($temp_repo_dir, $ref)); + $this->expectNotToPerformAssertions(); $this->removeGitRepo($temp_repo_dir); } + public static function dataProviderValidateLocalArtifact(): \Iterator { + yield 'HEAD ref' => ['HEAD']; + yield 'custom ref' => ['main']; + } + protected function createMockHttpClient(int $status_code = 200, string $body_content = 'mock content'): ClientInterface { $mock_client = $this->createMock(ClientInterface::class); $mock_response = $this->createMock(ResponseInterface::class); @@ -620,6 +659,10 @@ protected function createGitRepo(bool $with_composer_json = TRUE): string { $runner->run('git add .', output: new NullOutput()); $runner->run('git', args: ['commit', '-m', 'Initial commit'], output: new NullOutput()); + // 'git init' names the first branch after the machine's + // 'init.defaultBranch', so the tests that resolve 'main' pin it here. + $runner->run('git', args: ['branch', '-M', 'main'], output: new NullOutput()); + if ($with_composer_json) { File::dump($temp_repo_dir . '/composer.json', '{}'); $runner->run('git add composer.json', output: new NullOutput()); diff --git a/.vortex/installer/tests/Unit/Logger/FileLoggerTest.php b/.vortex/installer/tests/Unit/Logger/FileLoggerTest.php index fe8466653..7948cd305 100644 --- a/.vortex/installer/tests/Unit/Logger/FileLoggerTest.php +++ b/.vortex/installer/tests/Unit/Logger/FileLoggerTest.php @@ -4,7 +4,7 @@ namespace DrevOps\VortexInstaller\Tests\Unit\Logger; -use AlexSkrypnyk\File\File; +use DrevOps\VortexInstaller\Utils\File; use DrevOps\VortexInstaller\Logger\FileLogger; use DrevOps\VortexInstaller\Tests\Unit\UnitTestCase; use PHPUnit\Framework\Attributes\CoversClass; @@ -178,7 +178,7 @@ public function testWrite(string $content, bool $is_open, int $expected_writes): $logger->close(); $path = $logger->getPath(); - $written_content = file_get_contents((string) $path); + $written_content = File::read((string) $path); $expected_content = str_repeat($content, $expected_writes); $this->assertEquals($expected_content, $written_content, 'Written content should match expected content'); @@ -238,7 +238,7 @@ public function testClose(): void { // empty. $logger->write('should not be written'); - $content = file_get_contents($path); + $content = File::read($path); $this->assertEquals('', $content, 'No content should be written after close()'); $logger->close(); diff --git a/.vortex/installer/tests/Unit/Prompts/Handlers/AbstractHandlerDiscoveryTestCase.php b/.vortex/installer/tests/Unit/Prompts/Handlers/AbstractHandlerDiscoveryTestCase.php index 29503a0d4..93e0116ab 100644 --- a/.vortex/installer/tests/Unit/Prompts/Handlers/AbstractHandlerDiscoveryTestCase.php +++ b/.vortex/installer/tests/Unit/Prompts/Handlers/AbstractHandlerDiscoveryTestCase.php @@ -253,7 +253,7 @@ public static function defaultTuiAnswers(): array { protected function stubComposerJsonValue(string $name, mixed $value): string { $composer_json = static::$sut . DIRECTORY_SEPARATOR . 'composer.json'; - file_put_contents($composer_json, json_encode([$name => $value], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + File::dump($composer_json, (string) json_encode([$name => $value], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); return $composer_json; } @@ -263,9 +263,8 @@ protected function stubComposerJsonDependencies(array $dependencies, bool $is_de $section = $is_dev ? 'require-dev' : 'require'; $data = []; - if (file_exists($composer_json)) { - $contents = file_get_contents($composer_json); - $existing = $contents !== FALSE ? json_decode($contents, TRUE) : NULL; + if (File::exists($composer_json)) { + $existing = json_decode(File::read($composer_json), TRUE); if ($existing) { $data = $existing; } @@ -275,7 +274,7 @@ protected function stubComposerJsonDependencies(array $dependencies, bool $is_de $data[$section] = array_merge($data[$section], $dependencies); - file_put_contents($composer_json, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + File::dump($composer_json, (string) json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); return $composer_json; } @@ -283,7 +282,12 @@ protected function stubComposerJsonDependencies(array $dependencies, bool $is_de protected function stubDotenvValue(string $name, mixed $value, string $filename = '.env'): string { $dotenv = static::$sut . DIRECTORY_SEPARATOR . $filename; - file_put_contents($dotenv, sprintf('%s=%s', $name, $value) . PHP_EOL, FILE_APPEND); + // append() requires the file to exist, and each stub may be the first. + if (!File::exists($dotenv)) { + File::dump($dotenv); + } + + File::append($dotenv, sprintf('%s=%s', $name, $value) . PHP_EOL); return $dotenv; } @@ -292,7 +296,12 @@ protected function stubVortexProject(Config $config): void { // Add a README.md file with a Vortex badge. $readme = static::$sut . DIRECTORY_SEPARATOR . 'README.md'; $repo_url = str_replace('.git', '', RepositoryDownloader::DEFAULT_REPO); - file_put_contents($readme, sprintf('[![Vortex](https://img.shields.io/badge/Vortex-1.2.3-65ACBC.svg)](%s/tree/1.2.3)', $repo_url) . PHP_EOL, FILE_APPEND); + + if (!File::exists($readme)) { + File::dump($readme); + } + + File::append($readme, sprintf('[![Vortex](https://img.shields.io/badge/Vortex-1.2.3-65ACBC.svg)](%s/tree/1.2.3)', $repo_url) . PHP_EOL); $config->set(Config::IS_VORTEX_PROJECT, TRUE); } diff --git a/.vortex/installer/tests/Unit/Prompts/Handlers/ToolsHandlerDiscoveryTest.php b/.vortex/installer/tests/Unit/Prompts/Handlers/ToolsHandlerDiscoveryTest.php index c7026d711..6a8cb7329 100644 --- a/.vortex/installer/tests/Unit/Prompts/Handlers/ToolsHandlerDiscoveryTest.php +++ b/.vortex/installer/tests/Unit/Prompts/Handlers/ToolsHandlerDiscoveryTest.php @@ -38,7 +38,7 @@ function (AbstractHandlerDiscoveryTestCase $test, Config $config): void { 'vincentlanglet/twig-cs-fixer' => '*', ]; $test->stubComposerJsonDependencies($dependencies, TRUE); - file_put_contents(static::$sut . '/package.json', json_encode(['devDependencies' => ['eslint' => '*', 'jest' => '*', 'stylelint' => '*']], JSON_PRETTY_PRINT)); + File::dump(static::$sut . '/package.json', (string) json_encode(['devDependencies' => ['eslint' => '*', 'jest' => '*', 'stylelint' => '*']], JSON_PRETTY_PRINT)); File::dump(static::$sut . '/.dclintrc'); File::dump(static::$sut . '/.circleci/config.yml', 'docker run --rm -i hadolint/hadolint'); }, @@ -182,7 +182,7 @@ function (AbstractHandlerDiscoveryTestCase $test, Config $config): void { [Tools::id() => [Tools::JEST]] + $expected_installed, function (AbstractHandlerDiscoveryTestCase $test, Config $config): void { $test->stubVortexProject($config); - file_put_contents(static::$sut . '/package.json', json_encode(['devDependencies' => ['jest' => '*']], JSON_PRETTY_PRINT)); + File::dump(static::$sut . '/package.json', (string) json_encode(['devDependencies' => ['jest' => '*']], JSON_PRETTY_PRINT)); }, ]; yield 'tools - discovery - jest, alt' => [ @@ -198,7 +198,7 @@ function (AbstractHandlerDiscoveryTestCase $test, Config $config): void { [Tools::id() => [Tools::ESLINT]] + $expected_installed, function (AbstractHandlerDiscoveryTestCase $test, Config $config): void { $test->stubVortexProject($config); - file_put_contents(static::$sut . '/package.json', json_encode(['devDependencies' => ['eslint' => '*']], JSON_PRETTY_PRINT)); + File::dump(static::$sut . '/package.json', (string) json_encode(['devDependencies' => ['eslint' => '*']], JSON_PRETTY_PRINT)); }, ]; yield 'tools - discovery - eslint, alt' => [ @@ -214,7 +214,7 @@ function (AbstractHandlerDiscoveryTestCase $test, Config $config): void { [Tools::id() => [Tools::STYLELINT]] + $expected_installed, function (AbstractHandlerDiscoveryTestCase $test, Config $config): void { $test->stubVortexProject($config); - file_put_contents(static::$sut . '/package.json', json_encode(['devDependencies' => ['stylelint' => '*']], JSON_PRETTY_PRINT)); + File::dump(static::$sut . '/package.json', (string) json_encode(['devDependencies' => ['stylelint' => '*']], JSON_PRETTY_PRINT)); }, ]; yield 'tools - discovery - stylelint, alt' => [ diff --git a/.vortex/installer/tests/Unit/Prompts/InstallerPresenterTest.php b/.vortex/installer/tests/Unit/Prompts/InstallerPresenterTest.php index dfb8b285d..d0a0bc0d4 100644 --- a/.vortex/installer/tests/Unit/Prompts/InstallerPresenterTest.php +++ b/.vortex/installer/tests/Unit/Prompts/InstallerPresenterTest.php @@ -32,14 +32,14 @@ protected function setUp(): void { } public function testConstructor(): void { - $config = new Config('/tmp/root', '/tmp/dst', '/tmp/tmp'); + $config = $this->createConfig(); $presenter = new InstallerPresenter($config); $this->assertInstanceOf(InstallerPresenter::class, $presenter); } public function testSetPromptManager(): void { - $config = new Config('/tmp/root', '/tmp/dst', '/tmp/tmp'); + $config = $this->createConfig(); $presenter = new InstallerPresenter($config); $mock_pm = $this->createMock(PromptManager::class); @@ -51,7 +51,7 @@ public function testSetPromptManager(): void { #[DataProvider('dataProviderHeaderWithStableArtifact')] public function testHeaderWithStableArtifact(bool $is_vortex_project, bool $no_interaction): void { - $config = new Config('/tmp/root', '/tmp/dst', '/tmp/tmp'); + $config = $this->createConfig(); $config->set(Config::IS_VORTEX_PROJECT, $is_vortex_project); $config->setNoInteraction($no_interaction); $presenter = new InstallerPresenter($config); @@ -73,7 +73,7 @@ public static function dataProviderHeaderWithStableArtifact(): \Iterator { } public function testHeaderWithDevelopmentArtifact(): void { - $config = new Config('/tmp/root', '/tmp/dst', '/tmp/tmp'); + $config = $this->createConfig(); $presenter = new InstallerPresenter($config); $artifact = Artifact::fromUri(RepositoryDownloader::DEFAULT_REPO . '#' . RepositoryDownloader::REF_HEAD); @@ -84,7 +84,7 @@ public function testHeaderWithDevelopmentArtifact(): void { } public function testHeaderWithCustomArtifact(): void { - $config = new Config('/tmp/root', '/tmp/dst', '/tmp/tmp'); + $config = $this->createConfig(); $presenter = new InstallerPresenter($config); $artifact = Artifact::fromUri('https://github.com/drevops/vortex.git#abc123'); @@ -96,7 +96,7 @@ public function testHeaderWithCustomArtifact(): void { } public function testHeaderVersionPlaceholderReplacement(): void { - $config = new Config('/tmp/root', '/tmp/dst', '/tmp/tmp'); + $config = $this->createConfig(); $presenter = new InstallerPresenter($config); $artifact = Artifact::fromUri(NULL); @@ -108,7 +108,7 @@ public function testHeaderVersionPlaceholderReplacement(): void { } public function testHeaderInteractiveShowsControls(): void { - $config = new Config('/tmp/root', '/tmp/dst', '/tmp/tmp'); + $config = $this->createConfig(); $config->setNoInteraction(FALSE); $presenter = new InstallerPresenter($config); @@ -121,7 +121,7 @@ public function testHeaderInteractiveShowsControls(): void { } public function testHeaderNonInteractiveHidesControls(): void { - $config = new Config('/tmp/root', '/tmp/dst', '/tmp/tmp'); + $config = $this->createConfig(); $config->setNoInteraction(TRUE); $presenter = new InstallerPresenter($config); @@ -135,7 +135,7 @@ public function testHeaderNonInteractiveHidesControls(): void { } public function testHeaderExistingVortexProject(): void { - $config = new Config('/tmp/root', '/tmp/dst', '/tmp/tmp'); + $config = $this->createConfig(); $config->set(Config::IS_VORTEX_PROJECT, TRUE); $presenter = new InstallerPresenter($config); @@ -147,7 +147,7 @@ public function testHeaderExistingVortexProject(): void { } public function testFooterNewProject(): void { - $config = new Config('/tmp/root', '/tmp/dst', '/tmp/tmp'); + $config = $this->createConfig(); $config->set(Config::IS_VORTEX_PROJECT, FALSE); $presenter = new InstallerPresenter($config); @@ -160,7 +160,7 @@ public function testFooterNewProject(): void { } public function testFooterExistingProject(): void { - $config = new Config('/tmp/root', '/tmp/dst', '/tmp/tmp'); + $config = $this->createConfig(); $config->set(Config::IS_VORTEX_PROJECT, TRUE); $presenter = new InstallerPresenter($config); @@ -173,7 +173,7 @@ public function testFooterExistingProject(): void { } public function testFooterBuildSucceeded(): void { - $config = new Config('/tmp/root', '/tmp/dst', '/tmp/tmp'); + $config = $this->createConfig(); $presenter = new InstallerPresenter($config); $mock_pm = $this->createMock(PromptManager::class); @@ -191,7 +191,7 @@ public function testFooterBuildSucceeded(): void { } public function testFooterBuildSucceededWithHandlerOutput(): void { - $config = new Config('/tmp/root', '/tmp/dst', '/tmp/tmp'); + $config = $this->createConfig(); $presenter = new InstallerPresenter($config); $mock_pm = $this->createMock(PromptManager::class); @@ -208,7 +208,7 @@ public function testFooterBuildSucceededWithHandlerOutput(): void { #[DataProvider('dataProviderFooterBuildSkipped')] public function testFooterBuildSkipped(string $starter, bool $expect_profile_command, bool $expect_export_db): void { - $config = new Config('/tmp/root', '/tmp/dst', '/tmp/tmp'); + $config = $this->createConfig(); $presenter = new InstallerPresenter($config); $mock_pm = $this->createMock(PromptManager::class); @@ -258,7 +258,7 @@ public static function dataProviderFooterBuildSkipped(): \Iterator { } public function testFooterBuildSkippedDefaultsToDemo(): void { - $config = new Config('/tmp/root', '/tmp/dst', '/tmp/tmp'); + $config = $this->createConfig(); $presenter = new InstallerPresenter($config); $mock_pm = $this->createMock(PromptManager::class); @@ -274,7 +274,7 @@ public function testFooterBuildSkippedDefaultsToDemo(): void { } public function testFooterBuildFailed(): void { - $config = new Config('/tmp/root', '/tmp/dst', '/tmp/tmp'); + $config = $this->createConfig(); $presenter = new InstallerPresenter($config); $mock_pm = $this->createMock(PromptManager::class); @@ -295,7 +295,7 @@ public function testFooterBuildFailed(): void { } public function testFooterBuildFailedWithHandlerOutput(): void { - $config = new Config('/tmp/root', '/tmp/dst', '/tmp/tmp'); + $config = $this->createConfig(); $presenter = new InstallerPresenter($config); $mock_pm = $this->createMock(PromptManager::class); @@ -321,4 +321,11 @@ public static function dataProviderBuildResultConstants(): \Iterator { yield 'failed' => [InstallerPresenter::BUILD_RESULT_FAILED, 'failed']; } + /** + * Create a config with paths the presenter never reads from disk. + */ + protected function createConfig(): Config { + return new Config(static::$tmp . '/root', static::$tmp . '/dst', static::$tmp . '/tmp'); + } + } diff --git a/.vortex/installer/tests/Unit/Runner/ProcessRunnerTest.php b/.vortex/installer/tests/Unit/Runner/ProcessRunnerTest.php index 2bdd5f2c2..7bddde35e 100644 --- a/.vortex/installer/tests/Unit/Runner/ProcessRunnerTest.php +++ b/.vortex/installer/tests/Unit/Runner/ProcessRunnerTest.php @@ -4,7 +4,7 @@ namespace DrevOps\VortexInstaller\Tests\Unit\Runner; -use AlexSkrypnyk\File\File; +use DrevOps\VortexInstaller\Utils\File; use DrevOps\VortexInstaller\Runner\ProcessRunner; use DrevOps\VortexInstaller\Tests\Unit\UnitTestCase; use DrevOps\VortexInstaller\Utils\Tui; @@ -298,7 +298,7 @@ public function testResolveCommandWithRelativePath(): void { $script_path = $test_dir . '/test_script.sh'; File::dump($script_path, "#!/bin/sh\necho 'test'\n"); - chmod($script_path, 0755); + File::chmod($script_path, 0755); $runner->setCwd(self::$tmp); diff --git a/.vortex/installer/tests/Unit/UnitTestCase.php b/.vortex/installer/tests/Unit/UnitTestCase.php index 63128ca51..0ed2ef6a2 100644 --- a/.vortex/installer/tests/Unit/UnitTestCase.php +++ b/.vortex/installer/tests/Unit/UnitTestCase.php @@ -10,6 +10,7 @@ use AlexSkrypnyk\PhpunitHelpers\Traits\SerializableClosureTrait; use AlexSkrypnyk\PhpunitHelpers\UnitTestCase as UpstreamUnitTestCase; use AlexSkrypnyk\Snapshot\Testing\SnapshotTrait; +use DrevOps\VortexInstaller\Utils\File; use DrevOps\VortexInstaller\Utils\Yaml; /** @@ -64,12 +65,7 @@ protected function assertYamlFileIsValid(string $filename): void { protected function assertJsonFileIsValid(string $filename): void { $this->assertFileExists($filename); - $content = file_get_contents($filename); - if ($content === FALSE) { - $this->fail(sprintf('Failed to read JSON file "%s".', $filename)); - } - - $this->assertJson($content, sprintf('JSON validation for file %s failed: %s', $filename, json_last_error_msg())); + $this->assertJson(File::read($filename), sprintf('JSON validation for file %s failed: %s', $filename, json_last_error_msg())); } } diff --git a/.vortex/installer/tests/Unit/Utils/EnvTest.php b/.vortex/installer/tests/Unit/Utils/EnvTest.php index eb17ffffe..b101f52b5 100644 --- a/.vortex/installer/tests/Unit/Utils/EnvTest.php +++ b/.vortex/installer/tests/Unit/Utils/EnvTest.php @@ -117,7 +117,7 @@ public static function dataProviderPutFromDotenv(): \Iterator { public function testWriteValueDotenv(): void { $fixture_dir = dirname(__DIR__) . '/Fixtures/env'; $actual_file = static::$sut . '/.env'; - copy($fixture_dir . '/_baseline/.env', $actual_file); + File::copy($fixture_dir . '/_baseline/.env', $actual_file); // Apply updates to every variable to transform it to the after state. Env::writeValueDotenv('SIMPLE_VAR', 'new_simple_value', $actual_file); @@ -235,12 +235,12 @@ public function testParseDotenvFileNotReadable(): void { public function testParseDotenvFileReadFailure(): void { $filename = $this->createFixtureEnvFile('VAR=value'); - chmod($filename, 0000); + File::chmod($filename, 0000); $result = Env::parseDotenv($filename); $this->assertEquals([], $result); - chmod($filename, 0644); + File::chmod($filename, 0644); File::remove($filename); } @@ -284,7 +284,7 @@ public function testGetFromDotenvReturnsParsedValue(): void { $dir = dirname($filename); $dotenv_file = $dir . '/.env'; - rename($filename, $dotenv_file); + File::rename($filename, $dotenv_file); static::envUnset('TEST_VAR'); @@ -303,7 +303,7 @@ public function testWriteValueDotenvFileNotReadable(): void { public function testWriteValueDotenvFileReadFailure(): void { $filename = $this->createFixtureEnvFile('VAR=value'); - chmod($filename, 0000); + File::chmod($filename, 0000); $this->expectException(\RuntimeException::class); $this->expectExceptionMessage(sprintf('File "%s" is not readable.', $filename)); @@ -312,7 +312,7 @@ public function testWriteValueDotenvFileReadFailure(): void { Env::writeValueDotenv('TEST_VAR', 'value', $filename); } finally { - chmod($filename, 0644); + File::chmod($filename, 0644); File::remove($filename); } } @@ -322,7 +322,7 @@ public function testWriteValueDotenvAddNewVariableToFileWithoutNewline(): void { Env::writeValueDotenv('NEW_VAR', 'new_value', $filename); - $content = file_get_contents($filename); + $content = File::read($filename); $expected = "EXISTING_VAR=value\nNEW_VAR=new_value\n"; $this->assertEquals($expected, $content); @@ -334,7 +334,7 @@ public function testWriteValueDotenvReplaceVariableToFileWithoutNewline(): void Env::writeValueDotenv('NEW_VAR', 'new value with spaces', $filename); - $content = file_get_contents($filename); + $content = File::read($filename); $expected = "EXISTING_VAR=old_value\nNEW_VAR=\"new value with spaces\"\n"; $this->assertEquals($expected, $content); @@ -346,7 +346,7 @@ public function testWriteValueDotenvAddEmptyVariable(): void { Env::writeValueDotenv('NEW_VAR', NULL, $filename); - $content = file_get_contents($filename); + $content = File::read($filename); $expected = "EXISTING_VAR=value\nNEW_VAR=\n"; $this->assertEquals($expected, $content); @@ -358,7 +358,7 @@ public function testWriteValueDotenvAddEmptyVariableToFileWithoutNewline(): void Env::writeValueDotenv('NEW_VAR', NULL, $filename); - $content = file_get_contents($filename); + $content = File::read($filename); $expected = "EXISTING_VAR=value\nNEW_VAR=\n"; $this->assertEquals($expected, $content); @@ -371,7 +371,7 @@ public function testWriteValueDotenvWithEnabled(string $initial_content, string Env::writeValueDotenv($name, $value, $filename, $enabled); - $content = file_get_contents($filename); + $content = File::read($filename); $this->assertEquals($expected_content, $content); File::remove($filename); @@ -472,10 +472,9 @@ public static function dataProviderWriteValueDotenvWithEnabled(): \Iterator { } public function testParseDotenvFileGetContentsFailure(): void { - // A directory in place of the file makes file_get_contents() fail. - $dirname = tempnam(sys_get_temp_dir(), '.env'); - File::remove($dirname); - mkdir($dirname); + // A directory in place of the file is not readable as one. + $dirname = static::$tmp . '/' . uniqid('.env'); + File::mkdir($dirname); $result = Env::parseDotenv($dirname); $this->assertEquals([], $result); @@ -484,15 +483,9 @@ public function testParseDotenvFileGetContentsFailure(): void { } protected function createFixtureEnvFile(string $content): string { - $filename = tempnam(sys_get_temp_dir(), '.env'); - - if ($filename === FALSE) { - throw new \RuntimeException('Failed to create temporary file.'); - } + $filename = static::$tmp . '/' . uniqid('.env'); - if (file_put_contents($filename, $content) === FALSE) { - throw new \RuntimeException('Failed to write to temporary file.'); - } + File::dump($filename, $content); return $filename; } diff --git a/.vortex/installer/tests/Unit/Utils/FileManagerTest.php b/.vortex/installer/tests/Unit/Utils/FileManagerTest.php index 43faef823..baf6b832f 100644 --- a/.vortex/installer/tests/Unit/Utils/FileManagerTest.php +++ b/.vortex/installer/tests/Unit/Utils/FileManagerTest.php @@ -13,6 +13,7 @@ use DrevOps\VortexInstaller\Utils\FileManager; use DrevOps\VortexInstaller\Utils\UpdateRegistry; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; #[CoversClass(FileManager::class)] class FileManagerTest extends UnitTestCase { @@ -25,128 +26,120 @@ protected function setUp(): void { static::envUnsetPrefix('VORTEX_DB'); } - public function testConstructor(): void { - $config = new Config('/tmp/root', self::$sut, '/tmp/tmp'); - $fm = new FileManager($config); - - $this->assertInstanceOf(FileManager::class, $fm); + /** + * Create a config for a destination, staging from a source when given. + */ + protected function createConfig(string $destination, ?string $src = NULL): Config { + return new Config(static::$tmp . '/root', $destination, $src ?? static::$tmp . '/staged'); } - public function testPrepareDestinationExistingDirWithGit(): void { - $destination = self::$sut; - mkdir($destination . '/.git', 0777, TRUE); - - $config = new Config('/tmp/root', $destination, '/tmp/tmp'); + public function testConstructor(): void { + $config = $this->createConfig(self::$sut); $fm = new FileManager($config); - $messages = $fm->prepareDestination(); - - $this->assertEmpty($messages); + $this->assertInstanceOf(FileManager::class, $fm); } - public function testPrepareDestinationExistingDirWithoutGit(): void { - $destination = self::$sut; - - $config = new Config('/tmp/root', $destination, '/tmp/tmp'); - $fm = new FileManager($config); - - $messages = $fm->prepareDestination(); - - $this->assertNotEmpty($messages); - $this->assertDirectoryExists($destination . '/.git'); - $this->assertStringContainsString('Initializing a new Git repository', $messages[0]); - } + /** + * @param string $subdir + * Path appended to the test directory to form the destination. + * @param bool $with_git + * Create a repository in the destination before preparing it. + * @param array $expected_messages + * Substrings every returned message set must contain. + */ + #[DataProvider('dataProviderPrepareDestination')] + public function testPrepareDestination(string $subdir, bool $with_git, array $expected_messages): void { + $destination = self::$sut . $subdir; - public function testPrepareDestinationCreatesNewDir(): void { - $destination = self::$sut . '/new_subdir'; + if ($with_git) { + File::mkdir($destination . '/.git'); + } - $config = new Config('/tmp/root', $destination, '/tmp/tmp'); - $fm = new FileManager($config); + $fm = new FileManager($this->createConfig($destination)); $messages = $fm->prepareDestination(); $this->assertDirectoryExists($destination); $this->assertDirectoryExists($destination . '/.git'); + $this->assertCount(count($expected_messages), $messages); - $has_created_msg = FALSE; - $has_git_msg = FALSE; - foreach ($messages as $message) { - if (str_contains($message, 'Created directory')) { - $has_created_msg = TRUE; - } - if (str_contains($message, 'Initializing a new Git repository')) { - $has_git_msg = TRUE; - } + foreach ($expected_messages as $index => $expected_message) { + $this->assertStringContainsString($expected_message, $messages[$index]); } - $this->assertTrue($has_created_msg); - $this->assertTrue($has_git_msg); + } + + public static function dataProviderPrepareDestination(): \Iterator { + yield 'existing directory with a repository' => ['', TRUE, []]; + yield 'existing directory without a repository' => ['', FALSE, ['Initializing a new Git repository']]; + yield 'directory created by the install' => ['/new_subdir', FALSE, ['Created directory', 'Initializing a new Git repository']]; } public function testCopyFilesCopiesToDestination(): void { $src = self::$sut . '/src_copy'; $destination = self::$sut . '/dst_copy'; - mkdir($src, 0777, TRUE); - mkdir($destination, 0777, TRUE); - file_put_contents($src . '/test.txt', 'content'); + File::mkdir($src); + File::mkdir($destination); + File::dump($src . '/test.txt', 'content'); - $config = new Config('/tmp/root', $destination, $src); + $config = $this->createConfig($destination, $src); $fm = new FileManager($config); $fm->copyFiles(); $this->assertFileExists($destination . '/test.txt'); - $this->assertEquals('content', file_get_contents($destination . '/test.txt')); + $this->assertEquals('content', File::read($destination . '/test.txt')); } public function testCopyFilesCreatesEnvLocal(): void { $src = self::$sut . '/src_envlocal'; $destination = self::$sut . '/dst_envlocal'; - mkdir($src, 0777, TRUE); - mkdir($destination, 0777, TRUE); - file_put_contents($src . '/test.txt', 'content'); + File::mkdir($src); + File::mkdir($destination); + File::dump($src . '/test.txt', 'content'); - $config = new Config('/tmp/root', $destination, $src); + $config = $this->createConfig($destination, $src); $fm = new FileManager($config); $fm->copyFiles(); // Create the .env.local.example after copy. - file_put_contents($destination . '/.env.local.example', 'EXAMPLE=1'); + File::dump($destination . '/.env.local.example', 'EXAMPLE=1'); // Re-run to trigger the .env.local creation. // Recreate src for the second run. - mkdir($src, 0777, TRUE); - file_put_contents($src . '/dummy.txt', 'dummy'); + File::mkdir($src); + File::dump($src . '/dummy.txt', 'dummy'); $fm->copyFiles(); $this->assertFileExists($destination . '/.env.local'); - $this->assertEquals('EXAMPLE=1', file_get_contents($destination . '/.env.local')); + $this->assertEquals('EXAMPLE=1', File::read($destination . '/.env.local')); } public function testCopyFilesSkipsEnvLocalIfExists(): void { $src = self::$sut . '/src_envexist'; $destination = self::$sut . '/dst_envexist'; - mkdir($src, 0777, TRUE); - mkdir($destination, 0777, TRUE); - file_put_contents($src . '/test.txt', 'content'); - file_put_contents($destination . '/.env.local', 'EXISTING=1'); - file_put_contents($destination . '/.env.local.example', 'EXAMPLE=1'); + File::mkdir($src); + File::mkdir($destination); + File::dump($src . '/test.txt', 'content'); + File::dump($destination . '/.env.local', 'EXISTING=1'); + File::dump($destination . '/.env.local.example', 'EXAMPLE=1'); - $config = new Config('/tmp/root', $destination, $src); + $config = $this->createConfig($destination, $src); $fm = new FileManager($config); $fm->copyFiles(); - $this->assertEquals('EXISTING=1', file_get_contents($destination . '/.env.local')); + $this->assertEquals('EXISTING=1', File::read($destination . '/.env.local')); } public function testCopyFilesHandlesEmptySrc(): void { $src = self::$sut . '/src_empty'; $destination = self::$sut . '/dst_empty'; - mkdir($src, 0777, TRUE); - mkdir($destination, 0777, TRUE); + File::mkdir($src); + File::mkdir($destination); - $config = new Config('/tmp/root', $destination, $src); + $config = $this->createConfig($destination, $src); $fm = new FileManager($config); $fm->copyFiles(); @@ -157,17 +150,17 @@ public function testCopyFilesHandlesEmptySrc(): void { public function testCopyFilesRemovesUnmodifiedExcludedPaths(): void { $src = self::$sut . '/src_excluded'; $destination = self::$sut . '/dst_excluded'; - file_put_contents(File::mkdir($src) . '/composer.json', '{}'); - file_put_contents($src . '/phpstan.neon', 'parameters: []'); - file_put_contents(File::mkdir($src . '/.circleci') . '/config.yml', 'version: 2.1'); + File::dump(File::mkdir($src) . '/composer.json', '{}'); + File::dump($src . '/phpstan.neon', 'parameters: []'); + File::dump(File::mkdir($src . '/.circleci') . '/config.yml', 'version: 2.1'); - $config = new Config('/tmp/root', $destination, $src); + $config = $this->createConfig($destination, $src); $config->set(Config::IS_VORTEX_PROJECT, TRUE, TRUE); $fm = new FileManager($config); // A previous install wrote both, unmodified since. - file_put_contents(File::mkdir($destination) . '/phpstan.neon', 'parameters: []'); - file_put_contents(File::mkdir($destination . '/.circleci') . '/config.yml', 'version: 2.1'); + File::dump(File::mkdir($destination) . '/phpstan.neon', 'parameters: []'); + File::dump(File::mkdir($destination . '/.circleci') . '/config.yml', 'version: 2.1'); $this->stubPreviousTemplate($fm, $destination, [ 'phpstan.neon' => 'parameters: []', '.circleci/config.yml' => 'version: 2.1', @@ -190,15 +183,15 @@ public function testCopyFilesRemovesUnmodifiedExcludedPaths(): void { public function testCopyFilesKeepsModifiedExcludedPaths(): void { $src = self::$sut . '/src_modified'; $destination = self::$sut . '/dst_modified'; - file_put_contents(File::mkdir($src) . '/composer.json', '{}'); - file_put_contents($src . '/phpstan.neon', 'parameters: []'); + File::dump(File::mkdir($src) . '/composer.json', '{}'); + File::dump($src . '/phpstan.neon', 'parameters: []'); - $config = new Config('/tmp/root', $destination, $src); + $config = $this->createConfig($destination, $src); $config->set(Config::IS_VORTEX_PROJECT, TRUE, TRUE); $fm = new FileManager($config); // The project edited the file after the previous install wrote it. - file_put_contents(File::mkdir($destination) . '/phpstan.neon', "parameters:\n level: 8"); + File::dump(File::mkdir($destination) . '/phpstan.neon', "parameters:\n level: 8"); $this->stubPreviousTemplate($fm, $destination, ['phpstan.neon' => 'parameters: []']); $fm->snapshotTemplate(); @@ -213,16 +206,16 @@ public function testCopyFilesKeepsModifiedExcludedPaths(): void { public function testCopyFilesKeepsExcludedPathsWithoutRecordedHash(): void { $src = self::$sut . '/src_unverifiable'; $destination = self::$sut . '/dst_unverifiable'; - file_put_contents(File::mkdir($src) . '/composer.json', '{}'); - file_put_contents($src . '/phpstan.neon', 'parameters: []'); + File::dump(File::mkdir($src) . '/composer.json', '{}'); + File::dump($src . '/phpstan.neon', 'parameters: []'); - $config = new Config('/tmp/root', $destination, $src); + $config = $this->createConfig($destination, $src); $config->set(Config::IS_VORTEX_PROJECT, TRUE, TRUE); $fm = new FileManager($config); $fm->snapshotTemplate(); // No previous version, so ownership cannot be established. - file_put_contents(File::mkdir($destination) . '/phpstan.neon', 'parameters: []'); + File::dump(File::mkdir($destination) . '/phpstan.neon', 'parameters: []'); File::remove($src . '/phpstan.neon'); $fm->copyFiles(); @@ -233,10 +226,10 @@ public function testCopyFilesKeepsExcludedPathsWithoutRecordedHash(): void { public function testCopyFilesWritesNoManifest(): void { $src = self::$sut . '/src_no_manifest'; $destination = self::$sut . '/dst_no_manifest'; - file_put_contents(File::mkdir($src) . '/composer.json', '{}'); - file_put_contents(File::mkdir($src . '/scripts') . '/provision.sh', 'echo 1'); + File::dump(File::mkdir($src) . '/composer.json', '{}'); + File::dump(File::mkdir($src . '/scripts') . '/provision.sh', 'echo 1'); - $config = new Config('/tmp/root', $destination, $src); + $config = $this->createConfig($destination, $src); $fm = new FileManager($config); $fm->snapshotTemplate(); @@ -248,15 +241,15 @@ public function testCopyFilesWritesNoManifest(): void { public function testCopyFilesRemovesExcludedPathsMatchedOnlyAfterRendering(): void { $src = self::$sut . '/src_rendered'; $destination = self::$sut . '/dst_rendered'; - file_put_contents(File::mkdir($src) . '/composer.json', '{}'); - file_put_contents($src . '/rector.php', 'paths: your_site'); + File::dump(File::mkdir($src) . '/composer.json', '{}'); + File::dump($src . '/rector.php', 'paths: your_site'); - $config = new Config('/tmp/root', $destination, $src); + $config = $this->createConfig($destination, $src); $config->set(Config::IS_VORTEX_PROJECT, TRUE, TRUE); $fm = new FileManager($config); // The project holds the rendered content, which the download never has. - file_put_contents(File::mkdir($destination) . '/rector.php', 'paths: star_wars'); + File::dump(File::mkdir($destination) . '/rector.php', 'paths: star_wars'); $this->stubPreviousTemplate($fm, $destination, ['rector.php' => 'paths: your_site'], function (string $dir): void { File::dump($dir . '/rector.php', 'paths: star_wars'); }); @@ -272,14 +265,14 @@ public function testCopyFilesRemovesExcludedPathsMatchedOnlyAfterRendering(): vo public function testCopyFilesRemovesExcludedPathsDeselectedByThisRun(): void { $src = self::$sut . '/src_deselected'; $destination = self::$sut . '/dst_deselected'; - file_put_contents(File::mkdir($src) . '/composer.json', '{}'); - file_put_contents($src . '/jest.config.js', 'module.exports = {};'); + File::dump(File::mkdir($src) . '/composer.json', '{}'); + File::dump($src . '/jest.config.js', 'module.exports = {};'); - $config = new Config('/tmp/root', $destination, $src); + $config = $this->createConfig($destination, $src); $config->set(Config::IS_VORTEX_PROJECT, TRUE, TRUE); $fm = new FileManager($config); - file_put_contents(File::mkdir($destination) . '/jest.config.js', 'module.exports = {};'); + File::dump(File::mkdir($destination) . '/jest.config.js', 'module.exports = {};'); // Discovery answers describe the project, which still has the tool, so // the render keeps the file even though this run deselects it. @@ -296,15 +289,15 @@ public function testCopyFilesRemovesExcludedPathsDeselectedByThisRun(): void { public function testCopyFilesRecordsReplacedProjectChanges(): void { $src = self::$sut . '/src_registry'; $destination = self::$sut . '/dst_registry'; - file_put_contents(File::mkdir($src) . '/phpstan.neon', "parameters:\n level: 9\n"); + File::dump(File::mkdir($src) . '/phpstan.neon', "parameters:\n level: 9\n"); - $config = new Config('/tmp/root', $destination, $src); + $config = $this->createConfig($destination, $src); $config->set(Config::IS_VORTEX_PROJECT, TRUE, TRUE); $config->set(Config::VERSION, '1.41.0', TRUE); $fm = new FileManager($config); // The project edited the file the previous version installed. - file_put_contents(File::mkdir($destination) . '/phpstan.neon', "parameters:\n level: 8\n"); + File::dump(File::mkdir($destination) . '/phpstan.neon', "parameters:\n level: 8\n"); $this->stubPreviousTemplate($fm, $destination, ['phpstan.neon' => "parameters:\n level: 5\n"]); $fm->snapshotTemplate(); @@ -324,13 +317,13 @@ public function testCopyFilesRecordsReplacedProjectChanges(): void { public function testCopyFilesRecordsNothingWithoutProjectChanges(): void { $src = self::$sut . '/src_no_registry'; $destination = self::$sut . '/dst_no_registry'; - file_put_contents(File::mkdir($src) . '/phpstan.neon', "parameters:\n level: 9\n"); + File::dump(File::mkdir($src) . '/phpstan.neon', "parameters:\n level: 9\n"); - $config = new Config('/tmp/root', $destination, $src); + $config = $this->createConfig($destination, $src); $config->set(Config::IS_VORTEX_PROJECT, TRUE, TRUE); $fm = new FileManager($config); - file_put_contents(File::mkdir($destination) . '/phpstan.neon', "parameters:\n level: 5\n"); + File::dump(File::mkdir($destination) . '/phpstan.neon', "parameters:\n level: 5\n"); $this->stubPreviousTemplate($fm, $destination, ['phpstan.neon' => "parameters:\n level: 5\n"]); $fm->snapshotTemplate(); @@ -343,13 +336,13 @@ public function testCopyFilesRecordsNothingWithoutProjectChanges(): void { public function testCopyFilesRemovesCommittedManifest(): void { $src = self::$sut . '/src_stale_manifest'; $destination = self::$sut . '/dst_stale_manifest'; - file_put_contents(File::mkdir($src) . '/composer.json', '{}'); + File::dump(File::mkdir($src) . '/composer.json', '{}'); - $config = new Config('/tmp/root', $destination, $src); + $config = $this->createConfig($destination, $src); $config->set(Config::IS_VORTEX_PROJECT, TRUE, TRUE); $fm = new FileManager($config); - file_put_contents(File::mkdir($destination) . '/.vortex-manifest.json', '{"composer.json":"abc"}'); + File::dump(File::mkdir($destination) . '/.vortex-manifest.json', '{"composer.json":"abc"}'); $fm->copyFiles(); @@ -359,12 +352,12 @@ public function testCopyFilesRemovesCommittedManifest(): void { public function testCopyFilesKeepsManifestInDestinationThatIsNotVortexProject(): void { $src = self::$sut . '/src_foreign_manifest'; $destination = self::$sut . '/dst_foreign_manifest'; - file_put_contents(File::mkdir($src) . '/composer.json', '{}'); + File::dump(File::mkdir($src) . '/composer.json', '{}'); - $config = new Config('/tmp/root', $destination, $src); + $config = $this->createConfig($destination, $src); $fm = new FileManager($config); - file_put_contents(File::mkdir($destination) . '/.vortex-manifest.json', '{"owned":"by the project"}'); + File::dump(File::mkdir($destination) . '/.vortex-manifest.json', '{"owned":"by the project"}'); $fm->copyFiles(); @@ -374,15 +367,15 @@ public function testCopyFilesKeepsManifestInDestinationThatIsNotVortexProject(): public function testCopyFilesKeepsPathsTheTemplateNeverShipped(): void { $src = self::$sut . '/src_unknown'; $destination = self::$sut . '/dst_unknown'; - file_put_contents(File::mkdir($src) . '/composer.json', '{}'); + File::dump(File::mkdir($src) . '/composer.json', '{}'); - $config = new Config('/tmp/root', $destination, $src); + $config = $this->createConfig($destination, $src); $config->set(Config::IS_VORTEX_PROJECT, TRUE, TRUE); $fm = new FileManager($config); $fm->snapshotTemplate(); - file_put_contents(File::mkdir($destination) . '/phpstan.neon', 'project owned'); - file_put_contents(File::mkdir($destination . '/web/modules/custom/mymodule') . '/mymodule.info.yml', 'name: My module'); + File::dump(File::mkdir($destination) . '/phpstan.neon', 'project owned'); + File::dump(File::mkdir($destination . '/web/modules/custom/mymodule') . '/mymodule.info.yml', 'name: My module'); $fm->copyFiles(); @@ -393,14 +386,14 @@ public function testCopyFilesKeepsPathsTheTemplateNeverShipped(): void { public function testCopyFilesKeepsExcludedPathsForNonVortexProject(): void { $src = self::$sut . '/src_fresh'; $destination = self::$sut . '/dst_fresh'; - file_put_contents(File::mkdir($src) . '/composer.json', '{}'); - file_put_contents($src . '/phpstan.neon', 'parameters: []'); + File::dump(File::mkdir($src) . '/composer.json', '{}'); + File::dump($src . '/phpstan.neon', 'parameters: []'); - $config = new Config('/tmp/root', $destination, $src); + $config = $this->createConfig($destination, $src); $fm = new FileManager($config); $fm->snapshotTemplate(); - file_put_contents(File::mkdir($destination) . '/phpstan.neon', 'project owned'); + File::dump(File::mkdir($destination) . '/phpstan.neon', 'project owned'); File::remove($src . '/phpstan.neon'); $fm->copyFiles(); @@ -411,15 +404,15 @@ public function testCopyFilesKeepsExcludedPathsForNonVortexProject(): void { public function testCopyFilesKeepsHarnessPaths(): void { $src = self::$sut . '/src_harness'; $destination = self::$sut . '/dst_harness'; - file_put_contents(File::mkdir($src) . '/composer.json', '{}'); - file_put_contents(File::mkdir($src . '/.vortex') . '/CLAUDE.md', 'harness'); + File::dump(File::mkdir($src) . '/composer.json', '{}'); + File::dump(File::mkdir($src . '/.vortex') . '/CLAUDE.md', 'harness'); - $config = new Config('/tmp/root', $destination, $src); + $config = $this->createConfig($destination, $src); $config->set(Config::IS_VORTEX_PROJECT, TRUE, TRUE); $fm = new FileManager($config); $fm->snapshotTemplate(); - file_put_contents(File::mkdir($destination . '/.vortex') . '/CLAUDE.md', 'project owned'); + File::dump(File::mkdir($destination . '/.vortex') . '/CLAUDE.md', 'project owned'); File::remove($src . '/.vortex'); $fm->copyFiles(); @@ -433,13 +426,13 @@ public function testCopyFilesRemovesObsoleteScriptsVortex(): void { // package ships them instead, so the copy removes the legacy directory. $src = self::$sut . '/src_obsolete'; $destination = self::$sut . '/dst_obsolete'; - mkdir($src, 0777, TRUE); - mkdir($destination . '/scripts/vortex', 0777, TRUE); - file_put_contents($src . '/test.txt', 'new'); - file_put_contents($destination . '/scripts/vortex/legacy.sh', 'legacy'); - file_put_contents($destination . '/scripts/keep.sh', 'custom'); + File::mkdir($src); + File::mkdir($destination . '/scripts/vortex'); + File::dump($src . '/test.txt', 'new'); + File::dump($destination . '/scripts/vortex/legacy.sh', 'legacy'); + File::dump($destination . '/scripts/keep.sh', 'custom'); - $config = new Config('/tmp/root', $destination, $src); + $config = $this->createConfig($destination, $src); $fm = new FileManager($config); $fm->copyFiles(); @@ -451,9 +444,9 @@ public function testCopyFilesRemovesObsoleteScriptsVortex(): void { public function testRemoveObsoletePathsSilentOnMissing(): void { $destination = self::$sut . '/dst_no_obsolete'; - mkdir($destination, 0777, TRUE); + File::mkdir($destination); - $config = new Config('/tmp/root', $destination, '/tmp/tmp'); + $config = $this->createConfig($destination); $fm = new FileManager($config); $fm->removeObsoletePaths(); @@ -461,110 +454,63 @@ public function testRemoveObsoletePathsSilentOnMissing(): void { $this->addToAssertionCount(1); } - public function testPrepareDemoNotDemoMode(): void { - $config = new Config('/tmp/root', self::$sut, '/tmp/tmp'); - $fm = new FileManager($config); - - $downloader = $this->createMock(Downloader::class); - $result = $fm->prepareDemo($downloader); - - $this->assertEquals('Not a demo mode.', $result); - } - - public function testPrepareDemoWithFetchSkip(): void { - $config = new Config('/tmp/root', self::$sut, '/tmp/tmp'); - $config->set(Config::IS_DEMO, TRUE); - $config->set(Config::IS_DEMO_DB_FETCH_SKIP, TRUE); - $fm = new FileManager($config); - - $downloader = $this->createMock(Downloader::class); - $result = $fm->prepareDemo($downloader); - - $this->assertIsString($result); - $this->assertStringContainsString('Skipping demo database fetch', $result); - } - - public function testPrepareDemoNoUrl(): void { + /** + * @param array $config_values + * Config keys to set before preparing, keyed by constant. + * @param string|null $dotenv + * Content for the project's '.env', or NULL to write none. + * @param bool $with_database_file + * Seed the data directory with an already-fetched database dump. + * @param string $expected_message + * Substring the returned messages must contain. + */ + #[DataProvider('dataProviderPrepareDemo')] + public function testPrepareDemo(array $config_values, ?string $dotenv, bool $with_database_file, string $expected_message): void { $destination = self::$sut; - file_put_contents($destination . '/.env', ''); - $config = new Config('/tmp/root', $destination, '/tmp/tmp'); - $config->set(Config::IS_DEMO, TRUE); - $fm = new FileManager($config); + if ($dotenv !== NULL) { + File::dump($destination . '/.env', $dotenv); + } - $downloader = $this->createMock(Downloader::class); - $result = $fm->prepareDemo($downloader); + if ($with_database_file) { + File::dump(File::mkdir($destination . '/.data') . '/db.sql', 'existing'); + } - $this->assertIsString($result); - $this->assertStringContainsString('No database fetch URL provided', $result); - } + $config = $this->createConfig($destination); + foreach ($config_values as $name => $value) { + $config->set($name, $value); + } - public function testPrepareDemoExistingDatabaseFile(): void { - $destination = self::$sut; - $data_dir = $destination . '/.data'; - mkdir($data_dir, 0777, TRUE); - file_put_contents($data_dir . '/db.sql', 'existing'); - file_put_contents($destination . '/.env', "VORTEX_FETCH_DB_URL=https://example.com/db.sql\nVORTEX_DB_DIR=./.data\nVORTEX_DB_FILE=db.sql\n"); + $result = (new FileManager($config))->prepareDemo($this->createMock(Downloader::class)); - $config = new Config('/tmp/root', $destination, '/tmp/tmp'); - $config->set(Config::IS_DEMO, TRUE); - $fm = new FileManager($config); + $messages = is_array($result) ? $result : [$result]; + $this->assertStringContainsString($expected_message, implode(PHP_EOL, array_map(strval(...), $messages))); + } - $downloader = $this->createMock(Downloader::class); - $result = $fm->prepareDemo($downloader); + public static function dataProviderPrepareDemo(): \Iterator { + $dotenv = "VORTEX_FETCH_DB_URL=https://example.com/db.sql\nVORTEX_DB_DIR=./.data\nVORTEX_DB_FILE=db.sql\n"; - $this->assertIsString($result); - $this->assertStringContainsString('already exists', $result); + yield 'not a demo' => [[], NULL, FALSE, 'Not a demo mode.']; + yield 'fetch skipped' => [[Config::IS_DEMO => TRUE, Config::IS_DEMO_DB_FETCH_SKIP => TRUE], NULL, FALSE, 'Skipping demo database fetch']; + yield 'no fetch url' => [[Config::IS_DEMO => TRUE], '', FALSE, 'No database fetch URL provided']; + yield 'database already fetched' => [[Config::IS_DEMO => TRUE], $dotenv, TRUE, 'already exists']; + yield 'data directory created' => [[Config::IS_DEMO => TRUE], $dotenv, FALSE, 'Created data directory']; + yield 'database fetched' => [[Config::IS_DEMO => TRUE], $dotenv, FALSE, 'Fetched demo database']; } - public function testPrepareDemoFetchesDatabase(): void { + public function testPrepareDemoDownloadsFromTheConfiguredUrl(): void { $destination = self::$sut; - file_put_contents($destination . '/.env', "VORTEX_FETCH_DB_URL=https://example.com/db.sql\nVORTEX_DB_DIR=./.data\nVORTEX_DB_FILE=db.sql\n"); + File::dump($destination . '/.env', "VORTEX_FETCH_DB_URL=https://example.com/db.sql\nVORTEX_DB_DIR=./.data\nVORTEX_DB_FILE=db.sql\n"); - $config = new Config('/tmp/root', $destination, '/tmp/tmp'); + $config = $this->createConfig($destination); $config->set(Config::IS_DEMO, TRUE); - $fm = new FileManager($config); $downloader = $this->createMock(Downloader::class); $downloader->expects($this->once()) ->method('download') ->with('https://example.com/db.sql', $this->stringContains('db.sql')); - $result = $fm->prepareDemo($downloader); - - $this->assertIsArray($result); - $this->assertNotEmpty($result); - - $has_download_msg = FALSE; - foreach ($result as $msg) { - if (str_contains((string) $msg, 'Fetched demo database')) { - $has_download_msg = TRUE; - } - } - $this->assertTrue($has_download_msg); - } - - public function testPrepareDemoCreatesDataDir(): void { - $destination = self::$sut; - file_put_contents($destination . '/.env', "VORTEX_FETCH_DB_URL=https://example.com/db.sql\nVORTEX_DB_DIR=./.data\nVORTEX_DB_FILE=db.sql\n"); - - $config = new Config('/tmp/root', $destination, '/tmp/tmp'); - $config->set(Config::IS_DEMO, TRUE); - $fm = new FileManager($config); - - $downloader = $this->createMock(Downloader::class); - $result = $fm->prepareDemo($downloader); - - $this->assertIsArray($result); - $this->assertDirectoryExists($destination . '/.data'); - - $has_created_msg = FALSE; - foreach ($result as $msg) { - if (str_contains((string) $msg, 'Created data directory')) { - $has_created_msg = TRUE; - } - } - $this->assertTrue($has_created_msg); + (new FileManager($config))->prepareDemo($downloader); } /** diff --git a/.vortex/installer/tests/Unit/Utils/GitTest.php b/.vortex/installer/tests/Unit/Utils/GitTest.php index d66c2e605..7a8b6181c 100644 --- a/.vortex/installer/tests/Unit/Utils/GitTest.php +++ b/.vortex/installer/tests/Unit/Utils/GitTest.php @@ -35,13 +35,13 @@ public static function dataProviderExtractOwnerRepo(): \Iterator { } public function testInit(): void { - $temp_dir = sys_get_temp_dir() . '/git_test_init_' . uniqid(); - mkdir($temp_dir); + $temp_dir = static::$tmp . '/git_test_init_' . uniqid(); + File::mkdir($temp_dir); $repo = Git::init($temp_dir); $this->assertInstanceOf(GitRepository::class, $repo); - $this->assertTrue(is_dir($temp_dir . '/.git')); + $this->assertTrue(File::isDir($temp_dir . '/.git')); $this->cleanupTempGitRepo($temp_dir); } @@ -89,8 +89,8 @@ public function testListRemotesWithRemotes(): void { } public function testGetTrackedFilesNonGitDirectory(): void { - $temp_dir = sys_get_temp_dir() . '/non_git_' . uniqid(); - mkdir($temp_dir); + $temp_dir = static::$tmp . '/non_git_' . uniqid(); + File::mkdir($temp_dir); $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('The directory is not a Git repository.'); @@ -152,8 +152,8 @@ public function testGetLastShortCommitId(): void { * Array with temp directory path and Git object. */ protected function createTempGitRepo(bool $with_remote = FALSE, bool $with_commits = FALSE): array { - $temp_dir = sys_get_temp_dir() . '/git_test_' . uniqid(); - mkdir($temp_dir); + $temp_dir = static::$tmp . '/git_test_' . uniqid(); + File::mkdir($temp_dir); Git::init($temp_dir); $repo = new Git($temp_dir); @@ -163,11 +163,11 @@ protected function createTempGitRepo(bool $with_remote = FALSE, bool $with_commi $repo->run('config', 'user.name', 'Test User'); $repo->run('config', 'user.email', 'test@example.com'); - file_put_contents($temp_dir . '/test.txt', 'test content'); + File::dump($temp_dir . '/test.txt', 'test content'); $repo->addAllChanges(); $repo->commit('Initial commit'); - file_put_contents($temp_dir . '/another.txt', 'another test'); + File::dump($temp_dir . '/another.txt', 'another test'); $repo->addAllChanges(); $repo->commit('Second commit'); } @@ -181,7 +181,7 @@ protected function createTempGitRepo(bool $with_remote = FALSE, bool $with_commi } protected function cleanupTempGitRepo(string $temp_dir): void { - if (is_dir($temp_dir)) { + if (File::isDir($temp_dir)) { File::remove($temp_dir); } } diff --git a/.vortex/installer/tests/Unit/Utils/JsonManipulatorTest.php b/.vortex/installer/tests/Unit/Utils/JsonManipulatorTest.php index 5f398fd1f..dc25c87da 100644 --- a/.vortex/installer/tests/Unit/Utils/JsonManipulatorTest.php +++ b/.vortex/installer/tests/Unit/Utils/JsonManipulatorTest.php @@ -65,21 +65,21 @@ public function testFromFileWithNonexistentFile(): void { public function testFromFileWithNonReadableFile(): void { $temp_file = $this->createTempJsonFile(self::SAMPLE_JSON); - chmod($temp_file, 0000); + File::chmod($temp_file, 0000); try { $manipulator = JsonManipulator::fromFile($temp_file); $this->assertNull($manipulator); } finally { - chmod($temp_file, 0644); + File::chmod($temp_file, 0644); File::remove($temp_file); } } public function testFromFileWithDirectory(): void { - $temp_dir = sys_get_temp_dir() . '/json_test_dir_' . uniqid(); - mkdir($temp_dir); + $temp_dir = static::$tmp . '/json_test_dir_' . uniqid(); + File::mkdir($temp_dir); try { $manipulator = JsonManipulator::fromFile($temp_dir); @@ -209,8 +209,8 @@ public function testGetPropertyArrayAccess(): void { } protected function createTempJsonFile(string $content): string { - $temp_file = tempnam(sys_get_temp_dir(), 'json_test_'); - file_put_contents($temp_file, $content); + $temp_file = static::$tmp . '/' . uniqid('json_test_'); + File::dump($temp_file, $content); return $temp_file; } diff --git a/.vortex/installer/tests/Unit/Utils/NpmLockTest.php b/.vortex/installer/tests/Unit/Utils/NpmLockTest.php index 58b3d9be1..2a9a392d2 100644 --- a/.vortex/installer/tests/Unit/Utils/NpmLockTest.php +++ b/.vortex/installer/tests/Unit/Utils/NpmLockTest.php @@ -167,7 +167,7 @@ public function testSyncWritesTheFileTheWayNpmWritesIt(): void { NpmLock::sync($manifest_file); - $contents = (string) file_get_contents(dirname($manifest_file) . '/package-lock.json'); + $contents = File::read(dirname($manifest_file) . '/package-lock.json'); $this->assertStringContainsString("\n \"lockfileVersion\": 3,", $contents); $this->assertStringContainsString("\n \"node_modules/keep\": {", $contents); @@ -182,7 +182,7 @@ public function testSyncPreservesEmptyObjects(): void { NpmLock::sync($manifest_file); - $this->assertStringContainsString('"bin": {}', (string) file_get_contents(dirname($manifest_file) . '/package-lock.json')); + $this->assertStringContainsString('"bin": {}', File::read(dirname($manifest_file) . '/package-lock.json')); } #[DataProvider('dataProviderSyncThrows')] @@ -217,7 +217,7 @@ public function testSyncThrowsWhenLockIsNotWritable(): void { ['packages' => ['' => ['dependencies' => ['keep' => '^1.0.0']]]] ); - chmod(dirname($manifest_file) . '/package-lock.json', 0444); + File::chmod(dirname($manifest_file) . '/package-lock.json', 0444); $this->expectException(\RuntimeException::class); $this->expectExceptionMessageMatches('/Unable to write a JSON file/'); @@ -242,7 +242,7 @@ protected function createPair(array $manifest, ?array $lock): string { } protected function readLock(string $manifest_file): array { - return (array) json_decode((string) file_get_contents(dirname($manifest_file) . '/package-lock.json'), TRUE, 512, JSON_THROW_ON_ERROR); + return (array) json_decode(File::read(dirname($manifest_file) . '/package-lock.json'), TRUE, 512, JSON_THROW_ON_ERROR); } } diff --git a/.vortex/installer/tests/Unit/Utils/OptionsResolverTest.php b/.vortex/installer/tests/Unit/Utils/OptionsResolverTest.php index 9e47c3256..7d0cdfb87 100644 --- a/.vortex/installer/tests/Unit/Utils/OptionsResolverTest.php +++ b/.vortex/installer/tests/Unit/Utils/OptionsResolverTest.php @@ -8,6 +8,7 @@ use DrevOps\VortexInstaller\Downloader\RepositoryDownloader; use DrevOps\VortexInstaller\Tests\Unit\UnitTestCase; use DrevOps\VortexInstaller\Utils\Config; +use DrevOps\VortexInstaller\Utils\File; use DrevOps\VortexInstaller\Utils\OptionsResolver; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; @@ -125,7 +126,7 @@ public function testResolveWithConfigJsonString(): void { public function testResolveWithConfigJsonFile(): void { $config_file = self::$sut . '/config.json'; - file_put_contents($config_file, '{"VORTEX_PROJECT_NAME":"file_project"}'); + File::dump($config_file, '{"VORTEX_PROJECT_NAME":"file_project"}'); $options = self::defaultOptions([ 'config' => $config_file, diff --git a/.vortex/installer/tests/Unit/Utils/UpdateRegistryTest.php b/.vortex/installer/tests/Unit/Utils/UpdateRegistryTest.php index b2d3761e0..8cbe95fa1 100644 --- a/.vortex/installer/tests/Unit/Utils/UpdateRegistryTest.php +++ b/.vortex/installer/tests/Unit/Utils/UpdateRegistryTest.php @@ -137,4 +137,18 @@ public function testWriteAppendsToExistingRegistry(): void { $this->assertFileContainsString($file, '### behat.yml'); } + public function testWriteRefusesToReplaceUnreadableRegistry(): void { + // A directory at the registry path exists but cannot be read as a file, + // which is the condition that would otherwise discard the entries. + File::mkdir(self::$sut . '/' . UpdateRegistry::FILE); + + $registry = new UpdateRegistry(self::$sut); + $registry->add('phpstan.neon', "level: 5\n", "level: 8\n", "level: 9\n"); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Unable to read the update registry'); + + $registry->write('1.40.0', '1.41.0', '2026-09-07 09:31:22'); + } + } diff --git a/.vortex/installer/tests/Unit/Utils/VersionTest.php b/.vortex/installer/tests/Unit/Utils/VersionTest.php index 4312faabc..8a3ca288c 100644 --- a/.vortex/installer/tests/Unit/Utils/VersionTest.php +++ b/.vortex/installer/tests/Unit/Utils/VersionTest.php @@ -4,7 +4,7 @@ namespace DrevOps\VortexInstaller\Tests\Unit\Utils; -use AlexSkrypnyk\File\File; +use DrevOps\VortexInstaller\Utils\File; use DrevOps\VortexInstaller\Tests\Unit\UnitTestCase; use DrevOps\VortexInstaller\Utils\Version; use PHPUnit\Framework\Attributes\CoversClass; diff --git a/.vortex/installer/tests/Unit/Utils/YamlTest.php b/.vortex/installer/tests/Unit/Utils/YamlTest.php index 2957f7c61..6cf11bd11 100644 --- a/.vortex/installer/tests/Unit/Utils/YamlTest.php +++ b/.vortex/installer/tests/Unit/Utils/YamlTest.php @@ -5,6 +5,7 @@ namespace DrevOps\VortexInstaller\Tests\Unit\Utils; use DrevOps\VortexInstaller\Tests\Unit\UnitTestCase; +use DrevOps\VortexInstaller\Utils\File; use DrevOps\VortexInstaller\Utils\Yaml; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; @@ -19,8 +20,8 @@ public function testValidateFile(string $yaml_content, string $expected_exceptio $this->expectExceptionMessage($expected_exception_message); } - $temp_file = tempnam(sys_get_temp_dir(), 'yaml_test_'); - file_put_contents($temp_file, $yaml_content); + $temp_file = static::$tmp . '/' . uniqid('yaml_test_'); + File::dump($temp_file, $yaml_content); Yaml::validateFile($temp_file); @@ -51,7 +52,7 @@ public function testValidateFileNonExistent(): void { $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('File does not exist or is not readable'); - $non_existent_file = sys_get_temp_dir() . '/non_existent_file.yml'; + $non_existent_file = static::$tmp . '/non_existent_file.yml'; Yaml::validateFile($non_existent_file); }