From e2a8cf4221af59a7c48986ed3f147bc087e29fb4 Mon Sep 17 00:00:00 2001 From: Ben Ramsey Date: Sat, 8 May 2021 21:48:10 -0500 Subject: [PATCH] Add tooling for integration tests with initial tests --- .github/workflows/integration.yml | 5 +- composer.json | 6 +- phpunit.ci.xml | 3 + phpunit.xml | 3 + tests/constants.php | 1 + tests/files/template-acceptance/.gitignore | 2 + tests/files/template-acceptance/README.md | 3 + .../template-acceptance/captainhook.json | 39 +++ tests/files/template-acceptance/composer.json | 13 + tests/integration/HookInstallTest.php | 23 ++ tests/integration/HookPluginTest.php | 49 ++++ tests/integration/IntegrationTestCase.php | 225 ++++++++++++++++++ .../Plugin/PostCheckoutEnvCheck.php | 36 +++ tests/integration/Plugin/SimplePlugin.php | 35 +++ .../PreserveWorkingTreePluginTest.php | 75 ++++++ tests/integration/ProcessResult.php | 37 +++ 16 files changed, 552 insertions(+), 3 deletions(-) create mode 100644 tests/files/template-acceptance/.gitignore create mode 100644 tests/files/template-acceptance/README.md create mode 100644 tests/files/template-acceptance/captainhook.json create mode 100644 tests/files/template-acceptance/composer.json create mode 100644 tests/integration/HookInstallTest.php create mode 100644 tests/integration/HookPluginTest.php create mode 100644 tests/integration/IntegrationTestCase.php create mode 100644 tests/integration/Plugin/PostCheckoutEnvCheck.php create mode 100644 tests/integration/Plugin/SimplePlugin.php create mode 100644 tests/integration/PreserveWorkingTreePluginTest.php create mode 100644 tests/integration/ProcessResult.php diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 6dfd0d48..60984494 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -45,7 +45,10 @@ jobs: run: GITHUB_AUTH_TOKEN=${{ secrets.GITHUB_TOKEN }} tools/phive --no-progress --home ./.phive install --force-accept-unsigned --trust-gpg-keys 4AA394086372C20A,31C7E470E2138192,8E730BA25823D8B5,CF1A108D0E7AE720 - name: Execute unit tests - run: tools/phpunit --configuration=phpunit.ci.xml + run: tools/phpunit --configuration=phpunit.ci.xml --testsuite CaptainHook + + - name: Execute integration tests + run: tools/phpunit --configuration=phpunit.ci.xml --testsuite Integration - name: Check coding style run: tools/phpcs --standard=psr12 src tests diff --git a/composer.json b/composer.json index 3658c9de..e92e3946 100644 --- a/composer.json +++ b/composer.json @@ -27,7 +27,8 @@ }, "autoload-dev": { "psr-4": { - "CaptainHook\\App\\": "tests/CaptainHook/" + "CaptainHook\\App\\": "tests/CaptainHook/", + "CaptainHook\\App\\Integration\\": "tests/integration/" } }, "require": { @@ -67,7 +68,8 @@ "post-install-cmd": "tools/phive install --force-accept-unsigned", "tools": "tools/phive install --force-accept-unsigned", "compile": "tools/box compile", - "test": "tools/phpunit", + "test": "tools/phpunit --testsuite CaptainHook", + "test:integration": "tools/phpunit --testsuite Integration --no-coverage", "analyse": "tools/phpstan analyse", "style": "tools/phpcs --standard=psr12 src tests" } diff --git a/phpunit.ci.xml b/phpunit.ci.xml index 16aaf070..cae88b94 100644 --- a/phpunit.ci.xml +++ b/phpunit.ci.xml @@ -4,5 +4,8 @@ tests/CaptainHook + + tests/integration + diff --git a/phpunit.xml b/phpunit.xml index e270d38b..080a1a5a 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -13,6 +13,9 @@ tests/CaptainHook + + tests/integration + diff --git a/tests/constants.php b/tests/constants.php index efa4cc13..09c938dc 100644 --- a/tests/constants.php +++ b/tests/constants.php @@ -10,3 +10,4 @@ */ define('CH_PATH_FILES', realpath(__DIR__ . '/files')); +define('CH_PATH_ROOT', realpath(__DIR__ . '/..')); diff --git a/tests/files/template-acceptance/.gitignore b/tests/files/template-acceptance/.gitignore new file mode 100644 index 00000000..c8153b57 --- /dev/null +++ b/tests/files/template-acceptance/.gitignore @@ -0,0 +1,2 @@ +/composer.lock +/vendor/ diff --git a/tests/files/template-acceptance/README.md b/tests/files/template-acceptance/README.md new file mode 100644 index 00000000..b494f0ea --- /dev/null +++ b/tests/files/template-acceptance/README.md @@ -0,0 +1,3 @@ +# CaptainHook Acceptance Testing + +This is a sample project directory to use with acceptance tests. diff --git a/tests/files/template-acceptance/captainhook.json b/tests/files/template-acceptance/captainhook.json new file mode 100644 index 00000000..8c7a1844 --- /dev/null +++ b/tests/files/template-acceptance/captainhook.json @@ -0,0 +1,39 @@ +{ + "config": {}, + "commit-msg": { + "enabled": false, + "actions": [] + }, + "pre-push": { + "enabled": false, + "actions": [] + }, + "pre-commit": { + "enabled": false, + "actions": [] + }, + "prepare-commit-msg": { + "enabled": false, + "actions": [] + }, + "post-commit": { + "enabled": false, + "actions": [] + }, + "post-merge": { + "enabled": false, + "actions": [] + }, + "post-checkout": { + "enabled": false, + "actions": [] + }, + "post-rewrite": { + "enabled": false, + "actions": [] + }, + "post-change": { + "enabled": false, + "actions": [] + } +} diff --git a/tests/files/template-acceptance/composer.json b/tests/files/template-acceptance/composer.json new file mode 100644 index 00000000..32593e24 --- /dev/null +++ b/tests/files/template-acceptance/composer.json @@ -0,0 +1,13 @@ +{ + "name": "captainhook/repo-test", + "description": "A sample project to use with CaptainHook acceptance tests.", + "type": "project", + "minimum-stability": "dev", + "prefer-stable": true, + "require-dev": { + "captainhook/captainhook": "*" + }, + "scripts": { + "post-autoload-dump": "captainhook install --no-interaction --no-ansi --force" + } +} diff --git a/tests/integration/HookInstallTest.php b/tests/integration/HookInstallTest.php new file mode 100644 index 00000000..27cd25a2 --- /dev/null +++ b/tests/integration/HookInstallTest.php @@ -0,0 +1,23 @@ +setUpRepository(false); + + $result = $this->runInShell(['composer', 'update', '--no-ansi', '--no-interaction'], $repoPath); + + $this->assertStringContainsString("'commit-msg' hook installed successfully", $result->getStdout()); + $this->assertStringContainsString("'pre-push' hook installed successfully", $result->getStdout()); + $this->assertStringContainsString("'pre-commit' hook installed successfully", $result->getStdout()); + $this->assertStringContainsString("'prepare-commit-msg' hook installed successfully", $result->getStdout()); + $this->assertStringContainsString("'post-commit' hook installed successfully", $result->getStdout()); + $this->assertStringContainsString("'post-merge' hook installed successfully", $result->getStdout()); + $this->assertStringContainsString("'post-checkout' hook installed successfully", $result->getStdout()); + $this->assertStringContainsString("'post-rewrite' hook installed successfully", $result->getStdout()); + $this->assertStringContainsString("'post-change' hook installed successfully", $result->getStdout()); + } +} diff --git a/tests/integration/HookPluginTest.php b/tests/integration/HookPluginTest.php new file mode 100644 index 00000000..2358f679 --- /dev/null +++ b/tests/integration/HookPluginTest.php @@ -0,0 +1,49 @@ +setUpRepository(); + + $this->setConfig($repoPath, 'plugins', [ + [ + 'plugin' => '\\CaptainHook\\App\\Integration\\Plugin\\SimplePlugin', + 'options' => [ + 'stuff' => 'cool things', + ], + ], + ]); + + $this->enableHook($repoPath, 'pre-commit', [ + [ + 'action' => 'echo "action1"', + ], + [ + 'action' => 'echo "action2"', + ], + ]); + + $this->enableHook($repoPath, 'post-commit'); + + $this->filesystem()->touch($repoPath . '/foo.txt'); + $this->mustRunInShell(['git', 'add', 'foo.txt'], $repoPath); + + $result = $this->runInShell(['git', 'commit', '-m', 'Add foo.txt'], $repoPath); + + $this->assertStringContainsString('Do cool things before pre-commit runs', $result->getStderr()); + $this->assertStringContainsString('Do cool things before action echo "action1" runs', $result->getStderr()); + $this->assertStringContainsString('Do cool things after action echo "action1" runs', $result->getStderr()); + $this->assertStringContainsString('Do cool things before action echo "action2" runs', $result->getStderr()); + $this->assertStringContainsString('Do cool things after action echo "action2" runs', $result->getStderr()); + $this->assertStringContainsString('Do cool things after pre-commit runs', $result->getStderr()); + + $this->assertStringContainsString('Do cool things before post-commit runs', $result->getStderr()); + $this->assertStringContainsString('Do cool things after post-commit runs', $result->getStderr()); + + $this->assertStringNotContainsString('commit-msg', $result->getStderr()); + $this->assertStringNotContainsString('prepare-commit-msg', $result->getStderr()); + } +} diff --git a/tests/integration/IntegrationTestCase.php b/tests/integration/IntegrationTestCase.php new file mode 100644 index 00000000..a40c9d08 --- /dev/null +++ b/tests/integration/IntegrationTestCase.php @@ -0,0 +1,225 @@ +initializeEmptyGitRepository(); + $this->filesystem()->mirror(self::REPO_TEMPLATE, $repoPath); + $this->addLocalCaptainHookToComposer($repoPath, CH_PATH_ROOT); + + $this->mustRunInShell(['git', 'add', '.'], $repoPath); + $this->mustRunInShell(['git', 'commit', '-m', 'My initial commit'], $repoPath); + + if ($runComposerUpdate === true) { + $this->mustRunInShell(['composer', 'update', '--no-ansi', '--no-interaction'], $repoPath); + } + + return $repoPath; + } + + /** + * Creates and initializes a Git repository in the system's + * temporary directory. + * + * @return string The path to the Git repository. + */ + protected function initializeEmptyGitRepository(): string + { + try { + $repoPath = sys_get_temp_dir() + . '/CaptainHook/tests/repo-' + . time() . '-' . bin2hex(random_bytes(4)); + } catch (Exception $exception) { + TestCase::fail($exception->getMessage()); + } + + $gitConfigFile = $repoPath . '/.git/config'; + + $this->filesystem()->mkdir($repoPath); + + $this->mustRunInShell(['git', 'init', '--initial-branch=main', $repoPath]); + $this->mustRunInShell(['git', 'config', '--file', $gitConfigFile, 'user.name', 'Acceptance Tester']); + $this->mustRunInShell(['git', 'config', '--file', $gitConfigFile, 'user.email', 'test@example.com']); + + return $repoPath; + } + + /** + * Runs a shell command + * + * @param array $command The command to run and its arguments listed as separate entries + * @param string|null $cwd The working directory or null to use the working dir of the current PHP process + * @param array|null $env The environment variables or null to use the same environment as the current PHP process + * @param mixed $input The input as stream resource, scalar or \Traversable, or null for no input + * @param bool $throwOnFailure Throw exception if the process fails + * @return ProcessResult + */ + protected function runInShell( + array $command, + ?string $cwd = null, + ?array $env = null, + $input = null, + bool $throwOnFailure = false + ): ProcessResult { + $process = new Process($command, $cwd, $env, $input); + + if ($throwOnFailure === true) { + $process->mustRun(); + } else { + $process->run(); + } + + return new ProcessResult($process->getExitCode(), $process->getOutput(), $process->getErrorOutput()); + } + + /** + * Runs a shell command, throwing an exception if it fails. + * + * @param array $command The command to run and its arguments listed as separate entries + * @param string|null $cwd The working directory or null to use the working dir of the current PHP process + * @param array|null $env The environment variables or null to use the same environment as the current PHP process + * @param mixed $input The input as stream resource, scalar or \Traversable, or null for no input + * @return ProcessResult + */ + protected function mustRunInShell( + array $command, + ?string $cwd = null, + ?array $env = null, + $input = null + ): ProcessResult { + return $this->runInShell($command, $cwd, $env, $input, true); + } + + /** + * Adds an entry to the "repositories" property in composer.json, pointing + * to the local CaptainHook source code being tested. + * + * @param string $repositoryPath + * @param string $localCaptainHookPath + * @return void + */ + protected function addLocalCaptainHookToComposer( + string $repositoryPath, + string $localCaptainHookPath + ): void { + $composerFile = $repositoryPath . '/composer.json'; + $composerContents = $this->getJson($composerFile); + + if (!isset($composerContents['repositories'])) { + $composerContents['repositories'] = []; + } + + $composerContents['repositories'][] = [ + 'type' => 'path', + 'url' => $localCaptainHookPath, + 'options' => [ + 'symlink' => true, + ], + ]; + + $this->writeJson($composerFile, $composerContents); + } + + /** + * Enables a hook in captainhook.json and configures actions for it. + * + * @param string $repositoryPath + * @param string $hookName + * @param array $actions + * @return void + */ + protected function enableHook( + string $repositoryPath, + string $hookName, + array $actions = [] + ): void { + $captainHookFile = $repositoryPath . '/captainhook.json'; + $captainHookContents = $this->getJson($captainHookFile); + + $captainHookContents[$hookName] = [ + 'enabled' => true, + 'actions' => $actions, + ]; + + $this->writeJson($captainHookFile, $captainHookContents); + } + + /** + * Sets a config value in captainhook.json + * + * @param string $repositoryPath + * @param string $configName + * @param mixed $value + * @return void + */ + protected function setConfig(string $repositoryPath, string $configName, $value): void + { + $captainHookFile = $repositoryPath . '/captainhook.json'; + $captainHookContents = $this->getJson($captainHookFile); + + $captainHookContents['config'][$configName] = $value; + + $this->writeJson($captainHookFile, $captainHookContents); + } + + /** + * Returns the parsed contents of a JSON file. + * + * @param string $filename + * @return array + */ + private function getJson(string $filename): array + { + TestCase::assertFileExists($filename); + + $json = json_decode(file_get_contents($filename), true); + + if ($json === null) { + TestCase::fail(json_last_error_msg()); + } + + return $json; + } + + /** + * Encodes $contents as JSON and writes it to $filename. + * + * @param string $filename + * @param array $contents + * @return void + */ + private function writeJson(string $filename, array $contents): void + { + $this->filesystem()->dumpFile( + $filename, + json_encode( + $contents, + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE + ) + ); + } +} diff --git a/tests/integration/Plugin/PostCheckoutEnvCheck.php b/tests/integration/Plugin/PostCheckoutEnvCheck.php new file mode 100644 index 00000000..39e0b18a --- /dev/null +++ b/tests/integration/Plugin/PostCheckoutEnvCheck.php @@ -0,0 +1,36 @@ +plugin->getOptions()->get('stuff'); + $this->io->write("Do {$stuff} before {$hook->getName()} runs"); + } + + public function beforeAction(RunnerHook $hook, Config\Action $action): void + { + $stuff = $this->plugin->getOptions()->get('stuff'); + $this->io->write("Do {$stuff} before action {$action->getAction()} runs"); + } + + public function afterAction(RunnerHook $hook, Config\Action $action): void + { + $stuff = $this->plugin->getOptions()->get('stuff'); + $this->io->write("Do {$stuff} after action {$action->getAction()} runs"); + } + + public function afterHook(RunnerHook $hook): void + { + $stuff = $this->plugin->getOptions()->get('stuff'); + $this->io->write("Do {$stuff} after {$hook->getName()} runs"); + } +} diff --git a/tests/integration/PreserveWorkingTreePluginTest.php b/tests/integration/PreserveWorkingTreePluginTest.php new file mode 100644 index 00000000..b561ba72 --- /dev/null +++ b/tests/integration/PreserveWorkingTreePluginTest.php @@ -0,0 +1,75 @@ +setUpRepository(); + + $this->setConfig($repoPath, 'plugins', [ + ['plugin' => '\\CaptainHook\\App\\Plugin\\Hook\\PreserveWorkingTree'], + ['plugin' => '\\CaptainHook\\App\\Integration\\Plugin\\PostCheckoutEnvCheck'], + ]); + + $this->enableHook($repoPath, 'pre-commit', [ + ['action' => 'echo "this is an action"'], + ['action' => 'git status --porcelain=v1'], + ]); + + $this->enableHook($repoPath, 'post-checkout'); + + // Commit our changes to captainhook.json. + $this->mustRunInShell(['git', 'commit', '-m', 'Update captainhook.json', 'captainhook.json'], $repoPath); + + // Create a file and stage it. + $this->filesystem()->touch($repoPath . '/foo.txt'); + $this->mustRunInShell(['git', 'add', 'foo.txt'], $repoPath); + + // Make changes to the working tree that aren't staged. + $this->filesystem()->appendToFile( + $repoPath . '/README.md', + "\nWorking tree changes that aren't staged.\n" + ); + + // Look at `git status` to see the changes. + $statusResult = $this->runInShell(['git', 'status', '--porcelain=v1'], $repoPath); + $this->assertStringContainsString(' M README.md', $statusResult->getStdout()); + $this->assertStringContainsString('A foo.txt', $statusResult->getStdout()); + + // Ensure the skip post-checkout environment variable is not set before committing. + $envResult = $this->runInShell(['env'], $repoPath); + $this->assertStringNotContainsString(PreserveWorkingTree::SKIP_POST_CHECKOUT_VAR, $envResult->getStdout()); + + // Commit the file that's staged in the index. + $commitResult = $this->runInShell(['git', 'commit', '-m', 'Add foo.txt'], $repoPath); + + // Output from actions appears in STDERR, so let's check it instead of STDOUT. + // One of our actions is `git status`, so we want to assert that we do + // not see the working tree changes listed, since they should have been + // cached and cleared from the working tree. + $this->assertStringContainsString('this is an action', $commitResult->getStderr()); + $this->assertStringNotContainsString(' M README.md', $commitResult->getStderr()); + + // Since we have post-checkout enabled, and our pre-commit hook executes + // `git checkout`, we want to test our post-commit hook plugin creates a + // file with the environment variables dumped to it and that the skip + // post-checkout env var is one of them. + $this->assertStringContainsString( + PreserveWorkingTree::SKIP_POST_CHECKOUT_VAR, + file_get_contents($repoPath . '/env.txt') + ); + + // Look at `git status` again for the things we expect to see (or not). + $statusResult = $this->runInShell(['git', 'status', '--porcelain=v1'], $repoPath); + $this->assertStringContainsString(' M README.md', $statusResult->getStdout()); + $this->assertStringNotContainsString('A foo.txt', $statusResult->getStdout()); + + // Ensure the skip post-checkout environment variable is not set after committing. + $envResult = $this->runInShell(['env'], $repoPath); + $this->assertStringNotContainsString(PreserveWorkingTree::SKIP_POST_CHECKOUT_VAR, $envResult->getStdout()); + } +} diff --git a/tests/integration/ProcessResult.php b/tests/integration/ProcessResult.php new file mode 100644 index 00000000..ab4da639 --- /dev/null +++ b/tests/integration/ProcessResult.php @@ -0,0 +1,37 @@ +exitCode = $exitCode; + $this->stdout = $stdout; + $this->stderr = $stderr; + } + + public function getExitCode(): int + { + return $this->exitCode; + } + + public function getStdout(): string + { + return $this->stdout; + } + + public function getStderr(): string + { + return $this->stderr; + } +}