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;
+ }
+}