From e870f890bec1d1cc51065e52c5f38f8c8e3f889d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20Gon=C3=A7alves?= Date: Mon, 14 Sep 2026 10:38:02 +0100 Subject: [PATCH 1/2] Cover the untested failure paths of the CS Fixer commands The SonarCloud quality gate was failing on coverage: 94.7% overall against a 95% threshold, and 86.3% on new code against 90%. The two PHP-CS-Fixer commands held 36 of the 48 uncovered lines. Add four tests: - CsFixerCommand: running outside a Composer project, which exercises both the vendor-dir fallback and the missing-binary guard. - CsFixerGitHookCommand: the scope marker being removed once a project drops its own config, plus the two symlink failure paths. CommandTestCase now disables exception catching on Composer's Application. Without that, Application::getComposer() calls exit(1) rather than throwing when it cannot build a Composer instance, ending the whole test run instead of letting the command handle the failure. Also collapse the vendor tree setup, duplicated verbatim across four tests, into a single helper. Coverage: CsFixerCommand 81.8% to 96.1%, CsFixerGitHookCommand 83.7% to 94.1%, overall 94.1% to 97.2%. Co-Authored-By: Claude Opus 5 (1M context) --- tests/PHPCSFixer/Command/CommandTestCase.php | 1 + .../PHPCSFixer/Command/CsFixerCommandTest.php | 33 ++++++ .../Command/CsFixerGitHookCommandTest.php | 112 +++++++++++++----- 3 files changed, 115 insertions(+), 31 deletions(-) diff --git a/tests/PHPCSFixer/Command/CommandTestCase.php b/tests/PHPCSFixer/Command/CommandTestCase.php index 1056ed7..6c1a085 100644 --- a/tests/PHPCSFixer/Command/CommandTestCase.php +++ b/tests/PHPCSFixer/Command/CommandTestCase.php @@ -19,6 +19,7 @@ abstract protected function getCommandName(): string; protected function setUp(): void { $application = new Application(); + $application->setCatchExceptions(false); $application->addCommand($this->getCommand()); $this->tester = new CommandTester($application->find($this->getCommandName())); diff --git a/tests/PHPCSFixer/Command/CsFixerCommandTest.php b/tests/PHPCSFixer/Command/CsFixerCommandTest.php index f0373ef..e915d1c 100644 --- a/tests/PHPCSFixer/Command/CsFixerCommandTest.php +++ b/tests/PHPCSFixer/Command/CsFixerCommandTest.php @@ -14,7 +14,9 @@ final class CsFixerCommandTest extends CommandTestCase TEXT; + private ?string $tempDir = null; private ?string $tempFile = null; + private string $oldCwd; #[DataProvider('csFixerCommandDataProvider')] public function testCsFixerCommand(string $before, string $after): void @@ -110,6 +112,31 @@ public function testCsFixerCommandReturnsFailureWhenProcessFails(): void self::assertEquals(CsFixerCommand::FAILURE, $exitCode); } + public function testCsFixerCommandReturnsFailureWhenBinaryIsMissing(): void + { + // Outside a Composer project there is no vendor-dir to read, so the command falls back to + // resolving one from its own location, where no PHP CS Fixer binary is installed. + $this->tempDir = sprintf('%s/csfixer_no_project_%s', sys_get_temp_dir(), uniqid('', true)); + mkdir($this->tempDir, 0777, true); + chdir($this->tempDir); + + $this->tempFile = self::createTempFile(self::TEMPLATE); + + $exitCode = $this->tester->execute([ + 'files' => [$this->tempFile], + ]); + + self::assertEquals(CsFixerCommand::FAILURE, $exitCode); + self::assertStringContainsString('PHP CS Fixer binary not found', $this->tester->getDisplay()); + } + + protected function setUp(): void + { + parent::setUp(); + + $this->oldCwd = (string) getcwd(); + } + protected function getCommand(): CsFixerCommand { return new CsFixerCommand(); @@ -122,9 +149,15 @@ protected function getCommandName(): string protected function tearDown(): void { + chdir($this->oldCwd); + if ($this->tempFile !== null && is_file($this->tempFile)) { unlink($this->tempFile); } + + if ($this->tempDir !== null && is_dir($this->tempDir)) { + rmdir($this->tempDir); + } } private static function createTempFile(mixed $content): string diff --git a/tests/PHPCSFixer/Command/CsFixerGitHookCommandTest.php b/tests/PHPCSFixer/Command/CsFixerGitHookCommandTest.php index 7e4f544..2d50b15 100644 --- a/tests/PHPCSFixer/Command/CsFixerGitHookCommandTest.php +++ b/tests/PHPCSFixer/Command/CsFixerGitHookCommandTest.php @@ -29,18 +29,7 @@ public function testInstallsHookSuccessfully(): void chdir($this->repoDir); exec('git init 2>/dev/null'); - // Create the vendor tree where your current code expects it: - // - resolveVendorDir(dirname($rootGitPath)) - // - If rootGitPath is $this->repoDir, it will look under $this->baseDir/vendor - $vendorBase = sprintf('%s/vendor', $this->baseDir); - $codeToolsDir = sprintf('%s/kununu/code-tools', $vendorBase); - $binDir = sprintf('%s/bin', $vendorBase); - mkdir($codeToolsDir, 0777, true); - mkdir($binDir, 0777, true); - - // Files the command symlinks to: - file_put_contents(sprintf('%s/php-cs-fixer', $binDir), "#!/usr/bin/env php\ncreateVendorTree(); $exitCode = $this->tester->execute([]); @@ -103,12 +92,7 @@ public function testScopeMarkerIsWrittenOnlyForAProjectRootedConfig(): void chdir($this->repoDir); exec('git init 2>/dev/null'); - $vendorBase = sprintf('%s/vendor', $this->baseDir); - $binDir = sprintf('%s/bin', $vendorBase); - mkdir(sprintf('%s/kununu/code-tools', $vendorBase), 0777, true); - mkdir($binDir, 0777, true); - file_put_contents(sprintf('%s/php-cs-fixer', $binDir), "#!/usr/bin/env php\ncreateVendorTree(); $marker = sprintf('%s/.git/kununu/filter-by-config', $this->repoDir); @@ -129,12 +113,7 @@ public function testFailedReinstallKeepsTheExistingSymlinks(): void chdir($this->repoDir); exec('git init 2>/dev/null'); - $vendorBase = sprintf('%s/vendor', $this->baseDir); - $binDir = sprintf('%s/bin', $vendorBase); - mkdir(sprintf('%s/kununu/code-tools', $vendorBase), 0777, true); - mkdir($binDir, 0777, true); - file_put_contents(sprintf('%s/php-cs-fixer', $binDir), "#!/usr/bin/env php\ncreateVendorTree(); self::assertEquals(CsFixerGitHookCommand::SUCCESS, $this->tester->execute([])); @@ -158,13 +137,7 @@ public function testReinstallRemovesExistingHookAndSymlinks(): void chdir($this->repoDir); exec('git init 2>/dev/null'); - $vendorBase = sprintf('%s/vendor', $this->baseDir); - $codeToolsDir = sprintf('%s/kununu/code-tools', $vendorBase); - $binDir = sprintf('%s/bin', $vendorBase); - mkdir($codeToolsDir, 0777, true); - mkdir($binDir, 0777, true); - file_put_contents(sprintf('%s/php-cs-fixer', $binDir), "#!/usr/bin/env php\ncreateVendorTree(); $this->tester->execute([]); @@ -253,6 +226,66 @@ public function testFailsWhenVendorDirNotFound(): void self::assertStringContainsString('Could not find vendor directory', $this->tester->getDisplay()); } + public function testScopeMarkerIsRemovedWhenTheProjectConfigGoesAway(): void + { + chdir($this->repoDir); + exec('git init 2>/dev/null'); + $this->createVendorTree(); + + $config = sprintf('%s/php-cs-fixer.php', $this->repoDir); + $marker = sprintf('%s/.git/kununu/filter-by-config', $this->repoDir); + + file_put_contents($config, "tester->execute([])); + self::assertFileExists($marker); + + // The project dropped its own config, so the hook falls back to the packaged template and + // must stop narrowing staged files through a config that is no longer there. + unlink($config); + + self::assertEquals(CsFixerGitHookCommand::SUCCESS, $this->tester->execute([])); + self::assertFileDoesNotExist($marker); + } + + public function testFailsWhenSymlinkDirectoryCannotBeCreated(): void + { + chdir($this->repoDir); + exec('git init 2>/dev/null'); + $this->createVendorTree(); + + // The hooks directory already exists and stays writable, so installation gets past the hook + // itself and only then fails to create the directory holding the symlinks. + $gitPath = sprintf('%s/.git', $this->repoDir); + chmod($gitPath, 0555); + + $exitCode = $this->tester->execute([]); + + chmod($gitPath, 0755); + + self::assertEquals(CsFixerGitHookCommand::FAILURE, $exitCode); + self::assertStringContainsString('Could not create directory', $this->tester->getDisplay()); + } + + public function testFailsWhenSymlinkCannotBeCreated(): void + { + chdir($this->repoDir); + exec('git init 2>/dev/null'); + $this->createVendorTree(); + + // Here the directory is already there, so it is the symlink itself that cannot be written. + $kununuDir = sprintf('%s/.git/kununu', $this->repoDir); + mkdir($kununuDir, 0777, true); + chmod($kununuDir, 0555); + + $exitCode = $this->tester->execute([]); + + chmod($kununuDir, 0755); + + self::assertEquals(CsFixerGitHookCommand::FAILURE, $exitCode); + self::assertStringContainsString('Failed to create symlink', $this->tester->getDisplay()); + } + protected function setUp(): void { parent::setUp(); @@ -285,6 +318,23 @@ protected function getCommandName(): string return 'kununu:cs-fixer-git-hook'; } + // The vendor tree where the command looks for it: resolveVendorDir() walks up from the + // repository, so for $this->repoDir that is $this->baseDir/vendor. Returns the bin directory, + // which holds the binary the command symlinks to. + private function createVendorTree(): string + { + $vendorBase = sprintf('%s/vendor', $this->baseDir); + $binDir = sprintf('%s/bin', $vendorBase); + + mkdir(sprintf('%s/kununu/code-tools', $vendorBase), 0777, true); + mkdir($binDir, 0777, true); + + file_put_contents(sprintf('%s/php-cs-fixer', $binDir), "#!/usr/bin/env php\nmethod->invoke($this->getCommand(), $gitPath); From fb88f8b5ec9f30a161739af3c0814f6c37296efb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20Gon=C3=A7alves?= Date: Mon, 14 Sep 2026 10:38:51 +0100 Subject: [PATCH 2/2] Bump the SonarCloud scan action to v8.2.1 Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/continuous-integration.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 4c30538..93fb865 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -134,7 +134,7 @@ jobs: sed -i 's@'$GITHUB_WORKSPACE'@/github/workspace/@g' tests-junit.xml - name: SonarCloud Scan - uses: SonarSource/sonarqube-scan-action@v8.1.0 + uses: SonarSource/sonarqube-scan-action@v8.2.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}