From ea2f8af3096064b43b4c6db1b2b7d0f29d6c7edd Mon Sep 17 00:00:00 2001 From: Luca Tumedei Date: Wed, 25 Feb 2026 17:07:19 +0100 Subject: [PATCH 1/4] test: add comprehensive CLI command black-box tests Add 153 tests covering all slic CLI commands using black-box testing approach (control working directory, files, env vars, CLI args; assert on stdout/stderr output). Includes test infrastructure improvements: - Close stdin in slicExec to prevent interactive prompts from blocking - Add git mock binary to prevent real git clone calls from hanging - Set SLIC_INTERACTIVE=0 as defense-in-depth against readline prompts Fix missing echo in mysql help command (help text was built but never output). --- src/commands/mysql.php | 2 + tests/Cli/AirplaneModeTest.php | 55 +++++++++++ tests/Cli/BaseTestCase.php | 20 +++- tests/Cli/BuildPromptTest.php | 66 +++++++++++++ tests/Cli/BuildStackTest.php | 30 ++++++ tests/Cli/BuildSubdirTest.php | 70 ++++++++++++++ tests/Cli/CacheTest.php | 103 ++++++++++++++++++++ tests/Cli/CcTest.php | 29 ++++++ tests/Cli/CompletionTest.php | 82 ++++++++++++++++ tests/Cli/ComposerCacheTest.php | 69 ++++++++++++++ tests/Cli/ComposerTest.php | 91 ++++++++++++++++++ tests/Cli/ConfigTest.php | 83 ++++++++++++++++ tests/Cli/DcExecPsTest.php | 92 ++++++++++++++++++ tests/Cli/DebugTest.php | 69 ++++++++++++++ tests/Cli/GroupTest.php | 16 ++++ tests/Cli/HelpTest.php | 74 +++++++++++++++ tests/Cli/HereTest.php | 87 +++++++++++++++++ tests/Cli/HostIpTest.php | 37 ++++++++ tests/Cli/InfoTest.php | 72 ++++++++++++++ tests/Cli/InitTest.php | 56 +++++++++++ tests/Cli/InteractiveTest.php | 73 ++++++++++++++ tests/Cli/LogsTest.php | 33 +++++++ tests/Cli/MysqlTest.php | 38 ++++++++ tests/Cli/NpmTest.php | 38 ++++++++ tests/Cli/PhpcsPhpcbfTest.php | 58 ++++++++++++ tests/Cli/PlaywrightTest.php | 37 ++++++++ tests/Cli/ResetTest.php | 96 +++++++++++++++++++ tests/Cli/RunTest.php | 36 +++++++ tests/Cli/ShellTest.php | 58 ++++++++++++ tests/Cli/StackTest.php | 110 +++++++++++++++++++++ tests/Cli/StartStopTest.php | 126 +++++++++++++++++++++++++ tests/Cli/TargetTest.php | 28 ++++++ tests/Cli/UpdateDumpTest.php | 48 ++++++++++ tests/Cli/UpdateTest.php | 38 ++++++++ tests/Cli/UseTest.php | 110 +++++++++++++++++++++ tests/Cli/UsingTest.php | 52 ++++++++++ tests/Cli/WorktreeTest.php | 38 ++++++++ tests/Cli/WpCliTest.php | 95 +++++++++++++++++++ tests/Cli/XdebugTest.php | 120 +++++++++++++++++++++++ tests/_support/bin/docker-mock-host-ip | 9 ++ tests/_support/bin/git-mock-dir/git | 4 + 41 files changed, 2446 insertions(+), 2 deletions(-) create mode 100644 tests/Cli/AirplaneModeTest.php create mode 100644 tests/Cli/BuildPromptTest.php create mode 100644 tests/Cli/BuildStackTest.php create mode 100644 tests/Cli/BuildSubdirTest.php create mode 100644 tests/Cli/CacheTest.php create mode 100644 tests/Cli/CcTest.php create mode 100644 tests/Cli/CompletionTest.php create mode 100644 tests/Cli/ComposerCacheTest.php create mode 100644 tests/Cli/ComposerTest.php create mode 100644 tests/Cli/ConfigTest.php create mode 100644 tests/Cli/DcExecPsTest.php create mode 100644 tests/Cli/DebugTest.php create mode 100644 tests/Cli/GroupTest.php create mode 100644 tests/Cli/HelpTest.php create mode 100644 tests/Cli/HereTest.php create mode 100644 tests/Cli/HostIpTest.php create mode 100644 tests/Cli/InfoTest.php create mode 100644 tests/Cli/InitTest.php create mode 100644 tests/Cli/InteractiveTest.php create mode 100644 tests/Cli/LogsTest.php create mode 100644 tests/Cli/MysqlTest.php create mode 100644 tests/Cli/NpmTest.php create mode 100644 tests/Cli/PhpcsPhpcbfTest.php create mode 100644 tests/Cli/PlaywrightTest.php create mode 100644 tests/Cli/ResetTest.php create mode 100644 tests/Cli/RunTest.php create mode 100644 tests/Cli/ShellTest.php create mode 100644 tests/Cli/StackTest.php create mode 100644 tests/Cli/StartStopTest.php create mode 100644 tests/Cli/TargetTest.php create mode 100644 tests/Cli/UpdateDumpTest.php create mode 100644 tests/Cli/UpdateTest.php create mode 100644 tests/Cli/UseTest.php create mode 100644 tests/Cli/UsingTest.php create mode 100644 tests/Cli/WorktreeTest.php create mode 100644 tests/Cli/WpCliTest.php create mode 100644 tests/Cli/XdebugTest.php create mode 100755 tests/_support/bin/docker-mock-host-ip create mode 100755 tests/_support/bin/git-mock-dir/git diff --git a/src/commands/mysql.php b/src/commands/mysql.php index 2e76f1a..b724537 100644 --- a/src/commands/mysql.php +++ b/src/commands/mysql.php @@ -21,6 +21,8 @@ {$cli_name} {$subcommand} HELP; + echo colorize( $help ); + return; } diff --git a/tests/Cli/AirplaneModeTest.php b/tests/Cli/AirplaneModeTest.php new file mode 100644 index 0000000..020080c --- /dev/null +++ b/tests/Cli/AirplaneModeTest.php @@ -0,0 +1,55 @@ +setUpPluginsDir(); + + $output = $this->slicExec( 'airplane-mode on', $this->dockerMockEnv() ); + + $this->assertStringContainsString( + 'Airplane mode plugin installed', + $output, + 'Turning airplane mode on should confirm the plugin was installed.' + ); + $this->assertStringNotContainsString( + 'Error', + $output, + 'Turning airplane mode on should not produce errors.' + ); + } + + public function test_airplane_mode_off(): void { + $this->setUpPluginsDir(); + + $output = $this->slicExec( 'airplane-mode off', $this->dockerMockEnv() ); + + $this->assertStringContainsString( + 'Airplane mode plugin removed', + $output, + 'Turning airplane mode off should confirm the plugin was removed.' + ); + $this->assertStringNotContainsString( + 'Error', + $output, + 'Turning airplane mode off should not produce errors.' + ); + } + + public function test_airplane_mode_help_shows_usage(): void { + $output = $this->slicExec( 'airplane-mode help' ); + + $this->assertStringContainsString( + 'USAGE', + $output, + 'Running airplane-mode help should display usage information.' + ); + $this->assertStringContainsString( + 'SUMMARY', + $output, + 'Running airplane-mode help should display a summary.' + ); + } +} diff --git a/tests/Cli/BaseTestCase.php b/tests/Cli/BaseTestCase.php index 1d0b145..2ecfe39 100644 --- a/tests/Cli/BaseTestCase.php +++ b/tests/Cli/BaseTestCase.php @@ -16,10 +16,12 @@ abstract class BaseTestCase extends TestCase { private array $createdStackIds = []; private static string $dockerMockBin = ''; + private static string $gitMockDir = ''; public static function setUpBeforeClass(): void { parent::setUpBeforeClass(); self::$dockerMockBin = dirname( __DIR__ ) . '/_support/bin/docker-mock'; + self::$gitMockDir = dirname( __DIR__ ) . '/_support/bin/git-mock-dir'; } public function setUp(): void { @@ -52,6 +54,7 @@ public function tearDown(): void { */ protected function slicExec( string $command, array $env = [] ): string { $env['NO_COLOR'] = '1'; + $env['SLIC_INTERACTIVE'] = '0'; $envString = ''; foreach ( $env as $key => $value ) { @@ -60,8 +63,8 @@ protected function slicExec( string $command, array $env = [] ): string { $commandString = $envString . 'php ' . escapeshellarg( dirname( __DIR__, 2 ) . '/slic.php' ) . ' ' . $command; - // Redirect stderr to stdout to capture all output. - return (string) shell_exec( $commandString . ' 2>&1' ); + // Close stdin to prevent interactive prompts from blocking, and redirect stderr to stdout. + return (string) shell_exec( $commandString . ' &1' ); } /** @@ -76,6 +79,19 @@ protected function dockerMockEnv(): array { ]; } + /** + * Returns env vars that replace git with a mock that always fails. + * + * Prevents real git clone calls from hanging on SSH authentication prompts. + * + * @return array + */ + protected function gitMockEnv(): array { + return [ + 'PATH' => self::$gitMockDir . ':' . getenv( 'PATH' ), + ]; + } + /** * Creates a temporary plugins directory with a plugin, chdirs into it, and runs `slic here`. * diff --git a/tests/Cli/BuildPromptTest.php b/tests/Cli/BuildPromptTest.php new file mode 100644 index 0000000..6a5bf27 --- /dev/null +++ b/tests/Cli/BuildPromptTest.php @@ -0,0 +1,66 @@ +setUpPluginsDir(); + + $output = $this->slicExec( 'build-prompt on' ); + + $this->assertStringContainsString( + 'Build Prompt status: on', + $output, + 'Turning build-prompt on should confirm activation.' + ); + } + + public function test_build_prompt_off(): void { + $this->setUpPluginsDir(); + + $output = $this->slicExec( 'build-prompt off' ); + + $this->assertStringContainsString( + 'Build Prompt status: off', + $output, + 'Turning build-prompt off should confirm deactivation.' + ); + } + + public function test_build_prompt_status(): void { + $this->setUpPluginsDir(); + + // Set to on, then check status. + $this->slicExec( 'build-prompt on' ); + $output = $this->slicExec( 'build-prompt status' ); + + $this->assertStringContainsString( + 'Interactive status is: on', + $output, + 'Status should report on after enabling build-prompt.' + ); + + // Set to off, then check status. + $this->slicExec( 'build-prompt off' ); + $output = $this->slicExec( 'build-prompt status' ); + + $this->assertStringContainsString( + 'Interactive status is: off', + $output, + 'Status should report off after disabling build-prompt.' + ); + } + + public function test_build_prompt_default_state(): void { + $this->setUpPluginsDir(); + + $output = $this->slicExec( 'build-prompt status' ); + + $this->assertStringContainsString( + 'Interactive status is: off', + $output, + 'The default build-prompt state should be off.' + ); + } +} diff --git a/tests/Cli/BuildStackTest.php b/tests/Cli/BuildStackTest.php new file mode 100644 index 0000000..fa78dc7 --- /dev/null +++ b/tests/Cli/BuildStackTest.php @@ -0,0 +1,30 @@ +setUpPluginsDir(); + + $output = $this->slicExec( 'build-stack', $this->dockerMockEnv() ); + + $this->assertStringNotContainsString( + 'Error', + $output, + 'The build-stack command should not produce error output.' + ); + } + + public function test_build_stack_specific_service(): void { + $this->setUpPluginsDir(); + + $output = $this->slicExec( 'build-stack wordpress', $this->dockerMockEnv() ); + + $this->assertStringNotContainsString( + 'Error', + $output, + 'The build-stack command for a specific service should not produce error output.' + ); + } +} diff --git a/tests/Cli/BuildSubdirTest.php b/tests/Cli/BuildSubdirTest.php new file mode 100644 index 0000000..e9874fe --- /dev/null +++ b/tests/Cli/BuildSubdirTest.php @@ -0,0 +1,70 @@ +setUpPluginsDir(); + + $this->slicExec( 'build-subdir off' ); + + $output = $this->slicExec( 'build-subdir on' ); + + $this->assertStringContainsString( + 'Build Sub-directories status: on', + $output, + 'Turning build-subdir on should confirm the on status.' + ); + } + + public function test_build_subdir_off(): void { + $this->setUpPluginsDir(); + + $this->slicExec( 'build-subdir on' ); + + $output = $this->slicExec( 'build-subdir off' ); + + $this->assertStringContainsString( + 'Build Sub-directories status: off', + $output, + 'Turning build-subdir off should confirm the off status.' + ); + } + + public function test_build_subdir_status(): void { + $this->setUpPluginsDir(); + + $this->slicExec( 'build-subdir on' ); + + $output = $this->slicExec( 'build-subdir status' ); + + $this->assertStringContainsString( + 'Sub-directories build status is: on', + $output, + 'The status subcommand should report the current on state.' + ); + + $this->slicExec( 'build-subdir off' ); + + $output = $this->slicExec( 'build-subdir status' ); + + $this->assertStringContainsString( + 'Sub-directories build status is: off', + $output, + 'The status subcommand should report the current off state.' + ); + } + + public function test_build_subdir_default_state(): void { + $this->setUpPluginsDir(); + + $output = $this->slicExec( 'build-subdir status' ); + + $this->assertMatchesRegularExpression( + '/Sub-directories build status is: (on|off)/', + $output, + 'The default state should be reported as on or off.' + ); + } +} diff --git a/tests/Cli/CacheTest.php b/tests/Cli/CacheTest.php new file mode 100644 index 0000000..cea9a39 --- /dev/null +++ b/tests/Cli/CacheTest.php @@ -0,0 +1,103 @@ +setUpPluginsDir(); + $this->slicExec( 'use test-plugin', $this->dockerMockEnv() ); + + $output = $this->slicExec( 'cache on', $this->dockerMockEnv() ); + + $this->assertStringNotContainsString( + 'Unknown command', + $output, + 'The cache on command should be recognized.' + ); + $this->assertStringNotContainsString( + 'Failed', + $output, + 'The cache on command should not report failure.' + ); + } + + public function test_cache_off(): void { + $this->setUpPluginsDir(); + $this->slicExec( 'use test-plugin', $this->dockerMockEnv() ); + + $output = $this->slicExec( 'cache off', $this->dockerMockEnv() ); + + $this->assertStringNotContainsString( + 'Unknown command', + $output, + 'The cache off command should be recognized.' + ); + $this->assertStringNotContainsString( + 'Failed', + $output, + 'The cache off command should not report failure.' + ); + } + + public function test_cache_status(): void { + $this->setUpPluginsDir(); + $this->slicExec( 'use test-plugin', $this->dockerMockEnv() ); + + $output = $this->slicExec( 'cache status', $this->dockerMockEnv() ); + + $this->assertStringNotContainsString( + 'Unknown command', + $output, + 'The cache status command should be recognized.' + ); + $this->assertStringNotContainsString( + 'Failed', + $output, + 'The cache status command should not report failure.' + ); + } + + public function test_cache_default_is_status(): void { + $this->setUpPluginsDir(); + $this->slicExec( 'use test-plugin', $this->dockerMockEnv() ); + + $output = $this->slicExec( 'cache', $this->dockerMockEnv() ); + + $this->assertStringNotContainsString( + 'Unknown command', + $output, + 'The cache command with no argument should be recognized.' + ); + $this->assertStringNotContainsString( + 'Failed', + $output, + 'The cache command with no argument should not report failure.' + ); + } + + public function test_cache_help_shows_usage(): void { + $output = $this->slicExec( 'help cache' ); + + $this->assertStringContainsString( + 'cache', + $output, + 'The cache help should reference the cache command.' + ); + $this->assertStringContainsString( + 'status', + $output, + 'The cache help should list the status subcommand.' + ); + $this->assertStringContainsString( + 'on', + $output, + 'The cache help should list the on subcommand.' + ); + $this->assertStringContainsString( + 'off', + $output, + 'The cache help should list the off subcommand.' + ); + } +} diff --git a/tests/Cli/CcTest.php b/tests/Cli/CcTest.php new file mode 100644 index 0000000..9ae3c72 --- /dev/null +++ b/tests/Cli/CcTest.php @@ -0,0 +1,29 @@ +slicExec( 'cc' ); + + $this->assertStringContainsString( + 'This command requires a target set using the use command.', + $output, + 'Running cc without a use target should show an error.' + ); + } + + public function test_cc_passes_commands_to_container(): void { + $this->setUpPluginsDir(); + $this->slicExec( 'use test-plugin', $this->dockerMockEnv() ); + + $output = $this->slicExec( 'cc generate:wpunit wpunit Foo', $this->dockerMockEnv() ); + + $this->assertStringContainsString( + 'Using test-plugin', + $output, + 'The cc command should report the use target.' + ); + } +} diff --git a/tests/Cli/CompletionTest.php b/tests/Cli/CompletionTest.php new file mode 100644 index 0000000..3167358 --- /dev/null +++ b/tests/Cli/CompletionTest.php @@ -0,0 +1,82 @@ +slicExec( 'completion show bash' ); + + $this->assertStringContainsString( + 'Completion script for bash:', + $output, + 'The output should indicate the bash completion script.' + ); + + $this->assertStringContainsString( + '#!/usr/bin/env bash', + $output, + 'The output should contain a bash shebang.' + ); + + $this->assertStringContainsString( + '_slic_completions', + $output, + 'The output should contain the bash completion function.' + ); + } + + public function test_completion_show_zsh(): void { + $output = $this->slicExec( 'completion show zsh' ); + + $this->assertStringContainsString( + 'Completion script for zsh:', + $output, + 'The output should indicate the zsh completion script.' + ); + + $this->assertStringContainsString( + '#compdef slic', + $output, + 'The output should contain the zsh compdef directive.' + ); + + $this->assertStringContainsString( + '_slic', + $output, + 'The output should contain the zsh completion function.' + ); + } + + public function test_completion_cache_clear(): void { + $output = $this->slicExec( 'completion cache-clear' ); + + $this->assertStringContainsString( + 'Clearing completion cache', + $output, + 'The output should mention clearing the cache.' + ); + + $this->assertMatchesRegularExpression( + '/Cleared \d+ cached completion file/', + $output, + 'The output should confirm how many cached files were cleared.' + ); + } + + public function test_completion_no_args_shows_instructions(): void { + $output = $this->slicExec( 'completion' ); + + $this->assertStringContainsString( + 'Detected shell:', + $output, + 'The output should show the detected shell.' + ); + + $this->assertStringContainsString( + 'completion cache-clear', + $output, + 'The output should mention the cache-clear subcommand.' + ); + } +} diff --git a/tests/Cli/ComposerCacheTest.php b/tests/Cli/ComposerCacheTest.php new file mode 100644 index 0000000..768195c --- /dev/null +++ b/tests/Cli/ComposerCacheTest.php @@ -0,0 +1,69 @@ +setUpPluginsDir(); + + $output = $this->slicExec( 'composer-cache' ); + + $this->assertStringContainsString( + 'Composer cache directory:', + $output, + 'The composer-cache command should show the current cache directory setting.' + ); + } + + public function test_composer_cache_set(): void { + $this->setUpPluginsDir(); + + $output = $this->slicExec( 'composer-cache set /tmp/test-cache', $this->dockerMockEnv() ); + + $this->assertStringContainsString( + 'Composer cache directory:', + $output, + 'The set subcommand should display the cache directory label.' + ); + + $this->assertStringContainsString( + '/tmp/test-cache', + $output, + 'The set subcommand should confirm the new cache directory path.' + ); + } + + public function test_composer_cache_unset(): void { + $this->setUpPluginsDir(); + + $output = $this->slicExec( 'composer-cache unset', $this->dockerMockEnv() ); + + $this->assertStringContainsString( + 'Composer cache directory:', + $output, + 'The unset subcommand should display the cache directory label.' + ); + } + + public function test_composer_cache_set_nonexistent_path(): void { + $this->setUpPluginsDir(); + + $output = $this->slicExec( + 'composer-cache set /tmp/nonexistent-path-' . uniqid(), + $this->dockerMockEnv() + ); + + $this->assertStringContainsString( + 'Composer cache directory:', + $output, + 'Setting a non-existent path should still be accepted.' + ); + + $this->assertStringNotContainsString( + 'error', + strtolower( $output ), + 'No error should be reported for a non-existent cache path.' + ); + } +} diff --git a/tests/Cli/ComposerTest.php b/tests/Cli/ComposerTest.php new file mode 100644 index 0000000..d2deb5a --- /dev/null +++ b/tests/Cli/ComposerTest.php @@ -0,0 +1,91 @@ +setUpPluginsDir(); + + $output = $this->slicExec( 'composer install' ); + + $this->assertStringContainsString( + 'This command requires a target set using the', + $output, + 'Running composer without a use target should show an error.' + ); + } + + public function test_composer_get_version(): void { + $this->setUpPluginsDir(); + + $this->slicExec( 'use test-plugin', $this->dockerMockEnv() ); + + $output = $this->slicExec( 'composer get-version', $this->dockerMockEnv() ); + + $this->assertStringContainsString( + 'Using test-plugin', + $output, + 'The get-version output should show the current use target.' + ); + } + + public function test_composer_set_version_1(): void { + $this->setUpPluginsDir(); + + $this->slicExec( 'use test-plugin', $this->dockerMockEnv() ); + + $output = $this->slicExec( 'composer set-version 1', $this->dockerMockEnv() ); + + $this->assertStringContainsString( + 'Composer version set to 1', + $output, + 'Setting version to 1 should confirm the change.' + ); + } + + public function test_composer_set_version_2(): void { + $this->setUpPluginsDir(); + + $this->slicExec( 'use test-plugin', $this->dockerMockEnv() ); + + $output = $this->slicExec( 'composer set-version 2', $this->dockerMockEnv() ); + + $this->assertStringContainsString( + 'Composer version set to 2', + $output, + 'Setting version to 2 should confirm the change.' + ); + } + + public function test_composer_reset_version(): void { + $this->setUpPluginsDir(); + + $this->slicExec( 'use test-plugin', $this->dockerMockEnv() ); + + // Set to 1 first so reset has something to change. + $this->slicExec( 'composer set-version 1', $this->dockerMockEnv() ); + + $output = $this->slicExec( 'composer reset-version', $this->dockerMockEnv() ); + + $this->assertStringContainsString( + 'Composer version reset to default', + $output, + 'Resetting the version should confirm the reset to default.' + ); + } + + public function test_composer_passes_commands_to_container(): void { + $this->setUpPluginsDir(); + + $this->slicExec( 'use test-plugin', $this->dockerMockEnv() ); + + $output = $this->slicExec( 'composer install', $this->dockerMockEnv() ); + + $this->assertStringContainsString( + 'Using test-plugin', + $output, + 'Running composer install with docker mock should pass through without error.' + ); + } +} diff --git a/tests/Cli/ConfigTest.php b/tests/Cli/ConfigTest.php new file mode 100644 index 0000000..88cb194 --- /dev/null +++ b/tests/Cli/ConfigTest.php @@ -0,0 +1,83 @@ +setUpPluginsDir(); + + $output = $this->slicExec( 'config' ); + + $this->assertStringContainsString( + 'services:', + $output, + 'The config output should contain a services section.' + ); + $this->assertStringContainsString( + 'image:', + $output, + 'The config output should contain image definitions.' + ); + $this->assertStringContainsString( + 'networks:', + $output, + 'The config output should contain a networks section.' + ); + } + + public function test_config_reflects_php_version(): void { + $this->setUpPluginsDir(); + + $this->slicExec( 'php-version set 8.1 --skip-rebuild' ); + + $output = $this->slicExec( 'config' ); + + $this->assertStringContainsString( + 'slic-php8.1', + $output, + 'The config output should reference PHP 8.1 in the image name.' + ); + $this->assertStringContainsString( + 'slic-wordpress-php8.1', + $output, + 'The config output should reference PHP 8.1 in the WordPress image name.' + ); + } + + public function test_config_reflects_plugins_directory(): void { + $pluginsDir = $this->setUpPluginsDir(); + + $output = $this->slicExec( 'config' ); + + $this->assertStringContainsString( + realpath( $pluginsDir ), + $output, + 'The config output should contain the plugins directory path.' + ); + } + + public function test_config_reflects_xdebug_settings(): void { + $this->setUpPluginsDir(); + + $this->slicExec( 'xdebug on' ); + + $output = $this->slicExec( 'config' ); + + $this->assertStringContainsString( + 'XDEBUG_DISABLE: "0"', + $output, + 'The config should show XDEBUG_DISABLE as 0 when xdebug is on.' + ); + + $this->slicExec( 'xdebug off' ); + + $output = $this->slicExec( 'config' ); + + $this->assertStringContainsString( + 'XDEBUG_DISABLE: "1"', + $output, + 'The config should show XDEBUG_DISABLE as 1 when xdebug is off.' + ); + } +} diff --git a/tests/Cli/DcExecPsTest.php b/tests/Cli/DcExecPsTest.php new file mode 100644 index 0000000..fe68807 --- /dev/null +++ b/tests/Cli/DcExecPsTest.php @@ -0,0 +1,92 @@ +setUpPluginsDir(); + + $output = $this->slicExec( 'dc ps', $this->dockerMockEnv() ); + + $this->assertStringContainsString( + 'slic', + $output, + 'The dc ps command should run without error.' + ); + } + + public function test_dc_help_shows_usage(): void { + $output = $this->slicExec( 'dc help' ); + + $this->assertStringContainsString( + 'Runs a docker compose command in the stack', + $output, + 'The dc help command should show the summary.' + ); + + $this->assertStringContainsString( + 'USAGE', + $output, + 'The dc help command should show usage information.' + ); + } + + public function test_exec_passes_command(): void { + $this->setUpPluginsDir(); + + $this->slicExec( 'use test-plugin', $this->dockerMockEnv() ); + + $output = $this->slicExec( 'exec "whoami"', $this->dockerMockEnv() ); + + $this->assertStringNotContainsString( + 'Error', + $output, + 'The exec command should run without error.' + ); + + $this->assertStringNotContainsString( + 'Please specify a bash command', + $output, + 'The exec command should not complain about missing arguments.' + ); + } + + public function test_exec_help_shows_usage(): void { + $output = $this->slicExec( 'exec help' ); + + $this->assertStringContainsString( + 'Runs a bash command in the stack', + $output, + 'The exec help command should show the summary.' + ); + + $this->assertStringContainsString( + 'USAGE', + $output, + 'The exec help command should show usage information.' + ); + } + + public function test_ps_lists_containers(): void { + $this->setUpPluginsDir(); + + $output = $this->slicExec( 'ps', $this->dockerMockEnv() ); + + $this->assertStringContainsString( + 'slic', + $output, + 'The ps command should run without error.' + ); + } + + public function test_ps_help_shows_usage(): void { + $output = $this->slicExec( 'ps help' ); + + $this->assertStringContainsString( + 'USAGE', + $output, + 'The ps help command should show usage information.' + ); + } +} diff --git a/tests/Cli/DebugTest.php b/tests/Cli/DebugTest.php new file mode 100644 index 0000000..356f556 --- /dev/null +++ b/tests/Cli/DebugTest.php @@ -0,0 +1,69 @@ +setUpPluginsDir(); + + $output = $this->slicExec( 'debug on' ); + + $this->assertStringContainsString( + 'Debug status: on', + $output, + 'Turning debug on should confirm activation.' + ); + } + + public function test_debug_off(): void { + $this->setUpPluginsDir(); + + $output = $this->slicExec( 'debug off' ); + + $this->assertStringContainsString( + 'Debug status: off', + $output, + 'Turning debug off should confirm deactivation.' + ); + } + + public function test_debug_status(): void { + $this->setUpPluginsDir(); + + $output = $this->slicExec( 'debug status' ); + + $this->assertStringContainsString( + 'Debug status is:', + $output, + 'The status subcommand should report the current debug state.' + ); + } + + public function test_debug_no_args_defaults_to_status(): void { + $this->setUpPluginsDir(); + + $output = $this->slicExec( 'debug' ); + + $this->assertStringContainsString( + 'Debug status is:', + $output, + 'Running debug with no argument should default to showing status.' + ); + } + + public function test_debug_persists_across_commands(): void { + $this->setUpPluginsDir(); + + $this->slicExec( 'debug on' ); + + // The debug command writes CLI_VERBOSITY to the run settings file; verify it persists via info. + $output = $this->slicExec( 'info' ); + + $this->assertMatchesRegularExpression( + '/CLI_VERBOSITY\s*[:=]\s*1/', + $output, + 'CLI_VERBOSITY should remain 1 after setting debug on in a previous command.' + ); + } +} diff --git a/tests/Cli/GroupTest.php b/tests/Cli/GroupTest.php new file mode 100644 index 0000000..4266af8 --- /dev/null +++ b/tests/Cli/GroupTest.php @@ -0,0 +1,16 @@ +slicExec( 'group help' ); + + $this->assertStringContainsString( + 'Unknown command', + $output, + 'The group command should not be recognized.' + ); + } +} diff --git a/tests/Cli/HelpTest.php b/tests/Cli/HelpTest.php new file mode 100644 index 0000000..fbccfa4 --- /dev/null +++ b/tests/Cli/HelpTest.php @@ -0,0 +1,74 @@ +slicExec( 'help' ); + + $this->assertStringContainsString( + 'slic version', + $output, + 'The help output should contain the version string.' + ); + } + + public function test_help_output_lists_popular_commands(): void { + $output = $this->slicExec( 'help' ); + + $this->assertStringContainsString( + 'Popular:', + $output, + 'The help output should contain a Popular section.' + ); + + $popularCommands = [ 'composer', 'run', 'use', 'help' ]; + foreach ( $popularCommands as $command ) { + $this->assertMatchesRegularExpression( + '/Popular:.*^\s+' . preg_quote( $command, '/' ) . '\s/ms', + $output, + "The Popular section should list the '{$command}' command." + ); + } + } + + public function test_help_output_lists_advanced_commands(): void { + $output = $this->slicExec( 'help' ); + + $this->assertStringContainsString( + 'Advanced:', + $output, + 'The help output should contain an Advanced section.' + ); + + $advancedCommands = [ 'cache', 'debug', 'dc' ]; + foreach ( $advancedCommands as $command ) { + $this->assertMatchesRegularExpression( + '/Advanced:.*^\s+' . preg_quote( $command, '/' ) . '\s/ms', + $output, + "The Advanced section should list the '{$command}' command." + ); + } + } + + public function test_help_output_includes_usage_hint(): void { + $output = $this->slicExec( 'help' ); + + $this->assertStringContainsString( + 'Type slic help', + $output, + 'The help output should contain guidance on getting per-command help.' + ); + } + + public function test_unknown_command_shows_error(): void { + $output = $this->slicExec( 'nonexistent-cmd-xyz' ); + + $this->assertStringContainsString( + 'Unknown command: nonexistent-cmd-xyz', + $output, + 'Running an unrecognized command should display an error message.' + ); + } +} diff --git a/tests/Cli/HereTest.php b/tests/Cli/HereTest.php new file mode 100644 index 0000000..58a0129 --- /dev/null +++ b/tests/Cli/HereTest.php @@ -0,0 +1,87 @@ +setUpPluginsDir(); + + $output = $this->slicExec( 'stack list' ); + + $this->assertStringContainsString( + realpath( $pluginsDir ), + $output, + 'The stack list should contain the plugins directory.' + ); + } + + public function test_here_output_confirms_directory(): void { + $pluginsDir = $this->setUpPluginsDir(); + + chdir( $pluginsDir ); + $output = $this->slicExec( 'here' ); + + $this->assertStringContainsString( + realpath( $pluginsDir ), + $output, + 'The here output should contain the set directory path.' + ); + } + + public function test_here_reset_unsets_directory(): void { + $pluginsDir = $this->setUpPluginsDir(); + + $resetOutput = $this->slicExec( 'here reset' ); + + // The reset output should reference the default _plugins directory as the new stack ID. + $this->assertStringContainsString( + '_plugins', + $resetOutput, + 'The here reset output should reference the default _plugins directory.' + ); + + $this->assertStringContainsString( + 'Stack', + $resetOutput, + 'The here reset output should confirm a stack operation.' + ); + } + + public function test_here_from_subdirectory(): void { + $pluginsDir = $this->setUpPluginsDir(); + + chdir( $pluginsDir . '/test-plugin' ); + $output = $this->slicExec( 'here' ); + + $this->assertStringContainsString( + realpath( $pluginsDir . '/test-plugin' ), + $output, + 'Running here from a plugin subdirectory should set that subdirectory as the stack ID.' + ); + } + + public function test_here_in_non_plugin_directory_still_creates_stack(): void { + // First set up a valid plugins dir so we have a registered stack context. + $this->setUpPluginsDir(); + + $emptyDir = Directory::createTemp()->getAbsolutePath(); + chdir( $emptyDir ); + + $output = $this->slicExec( 'here' ); + + $this->assertStringContainsString( + 'Stack created successfully', + $output, + 'Running here in a directory without plugins should still create a stack.' + ); + + $this->assertStringContainsString( + realpath( $emptyDir ), + $output, + 'The stack ID should reference the empty directory.' + ); + } +} diff --git a/tests/Cli/HostIpTest.php b/tests/Cli/HostIpTest.php new file mode 100644 index 0000000..037e192 --- /dev/null +++ b/tests/Cli/HostIpTest.php @@ -0,0 +1,37 @@ + + */ + private function hostIpMockEnv(): array { + return [ + 'SLIC_DOCKER_BIN' => dirname( __DIR__ ) . '/_support/bin/docker-mock-host-ip', + 'SLIC_DOCKER_COMPOSE_BIN' => dirname( __DIR__ ) . '/_support/bin/docker-mock', + ]; + } + + public function test_host_ip_returns_ip_address(): void { + $output = $this->slicExec( 'host-ip', $this->hostIpMockEnv() ); + + $this->assertMatchesRegularExpression( + '/\d+\.\d+\.\d+\.\d+/', + $output, + 'The host-ip command should return an IP address.' + ); + } + + public function test_host_ip_returns_nonblank(): void { + $output = $this->slicExec( 'host-ip', $this->hostIpMockEnv() ); + + $this->assertNotEmpty( + trim( $output ), + 'The host-ip command should return non-empty output.' + ); + } +} diff --git a/tests/Cli/InfoTest.php b/tests/Cli/InfoTest.php new file mode 100644 index 0000000..60ebd67 --- /dev/null +++ b/tests/Cli/InfoTest.php @@ -0,0 +1,72 @@ +slicExec( 'info' ); + + $this->assertMatchesRegularExpression( + '/slic version \d+\.\d+\.\d+/', + $output, + 'The info output should include the slic version in semver format.' + ); + } + + public function test_info_shows_plugins_directory(): void { + $pluginsDir = $this->setUpPluginsDir(); + + $output = $this->slicExec( 'info' ); + + $this->assertStringContainsString( + 'SLIC_PLUGINS_DIR', + $output, + 'The info output should include the SLIC_PLUGINS_DIR key.' + ); + $this->assertStringContainsString( + $pluginsDir, + $output, + 'The info output should include the plugins directory path.' + ); + } + + public function test_info_shows_current_target(): void { + $this->setUpPluginsDir(); + + $this->slicExec( 'use test-plugin', $this->dockerMockEnv() ); + + $output = $this->slicExec( 'info' ); + + $this->assertMatchesRegularExpression( + '/SLIC_CURRENT_PROJECT:\s*test-plugin/', + $output, + 'The info output should show test-plugin as the SLIC_CURRENT_PROJECT value.' + ); + } + + public function test_info_shows_php_version(): void { + $output = $this->slicExec( 'info' ); + + $this->assertMatchesRegularExpression( + '/SLIC_PHP_VERSION:\s*7\.4/', + $output, + 'The info output should show 7.4 as the default SLIC_PHP_VERSION value.' + ); + } + + public function test_info_without_stack_shows_defaults(): void { + $output = $this->slicExec( 'info' ); + + $this->assertStringContainsString( + 'Current configuration:', + $output, + 'The info output should include the current configuration header.' + ); + $this->assertMatchesRegularExpression( + '/SLIC_CURRENT_PROJECT:\s*$\n/m', + $output, + 'The default SLIC_CURRENT_PROJECT should be empty when no target is set.' + ); + } +} diff --git a/tests/Cli/InitTest.php b/tests/Cli/InitTest.php new file mode 100644 index 0000000..4349f99 --- /dev/null +++ b/tests/Cli/InitTest.php @@ -0,0 +1,56 @@ +slicExec( 'init', $this->gitMockEnv() ); + + $this->assertStringContainsString( + 'Using', + $output, + 'Running init without a plugin name should show the current (empty) target.' + ); + + $this->assertStringContainsString( + 'Finished initializing', + $output, + 'Running init without a plugin should still finish initializing.' + ); + } + + public function test_init_nonexistent_plugin_shows_error(): void { + $this->setUpPluginsDir(); + + $output = $this->slicExec( 'init nonexistent', $this->gitMockEnv() ); + + $this->assertStringContainsString( + 'Cloning nonexistent', + $output, + 'Running init with a nonexistent plugin should attempt to clone it.' + ); + + $this->assertStringContainsString( + 'Could not clone', + $output, + 'Running init with a nonexistent plugin should show a clone error.' + ); + } + + public function test_init_help_shows_usage(): void { + $output = $this->slicExec( 'init help' ); + + $this->assertStringContainsString( + 'USAGE', + $output, + 'The init help output should contain a USAGE section.' + ); + + $this->assertStringContainsString( + 'SUMMARY', + $output, + 'The init help output should contain a SUMMARY section.' + ); + } +} diff --git a/tests/Cli/InteractiveTest.php b/tests/Cli/InteractiveTest.php new file mode 100644 index 0000000..c91b8cb --- /dev/null +++ b/tests/Cli/InteractiveTest.php @@ -0,0 +1,73 @@ +setUpPluginsDir(); + + $this->slicExec( 'interactive off' ); + + $output = $this->slicExec( 'interactive on' ); + + $this->assertStringContainsString( + 'Interactive status: on', + $output, + 'Turning interactive on should confirm activation.' + ); + } + + public function test_interactive_off(): void { + $this->setUpPluginsDir(); + + $this->slicExec( 'interactive on' ); + + $output = $this->slicExec( 'interactive off' ); + + $this->assertStringContainsString( + 'Interactive status: off', + $output, + 'Turning interactive off should confirm deactivation.' + ); + } + + public function test_interactive_status(): void { + $this->setUpPluginsDir(); + + $this->slicExec( 'interactive on' ); + + $output = $this->slicExec( 'interactive status' ); + + $this->assertStringContainsString( + 'Interactive status is: on', + $output, + 'The status subcommand should report the current interactive state.' + ); + + $this->slicExec( 'interactive off' ); + + $output = $this->slicExec( 'interactive status' ); + + $this->assertStringContainsString( + 'Interactive status is: off', + $output, + 'The status subcommand should reflect the updated state after turning off.' + ); + } + + public function test_interactive_default_state(): void { + $this->setUpPluginsDir(); + + $this->slicExec( 'interactive off' ); + + // Calling interactive with no argument should default to "on". + $output = $this->slicExec( 'interactive' ); + + $this->assertStringContainsString( + 'Interactive status: on', + $output, + 'Calling interactive with no argument should default to turning it on.' + ); + } +} diff --git a/tests/Cli/LogsTest.php b/tests/Cli/LogsTest.php new file mode 100644 index 0000000..5b66828 --- /dev/null +++ b/tests/Cli/LogsTest.php @@ -0,0 +1,33 @@ +slicExec( 'logs help' ); + + $this->assertStringContainsString( + 'SUMMARY', + $output, + 'The logs help should show the summary section.' + ); + $this->assertStringContainsString( + 'USAGE', + $output, + 'The logs help should show the usage section.' + ); + } + + public function test_logs_runs_without_error(): void { + $this->setUpPluginsDir(); + + $output = $this->slicExec( 'logs', $this->dockerMockEnv() ); + + $this->assertStringNotContainsString( + 'Error', + $output, + 'The logs command should not produce error output.' + ); + } +} diff --git a/tests/Cli/MysqlTest.php b/tests/Cli/MysqlTest.php new file mode 100644 index 0000000..97bffbc --- /dev/null +++ b/tests/Cli/MysqlTest.php @@ -0,0 +1,38 @@ +slicExec( 'mysql help' ); + + $this->assertStringContainsString( + 'SUMMARY', + $output, + 'The mysql help should show the summary section.' + ); + $this->assertStringContainsString( + 'USAGE', + $output, + 'The mysql help should show the usage section.' + ); + } + + public function test_mysql_runs_without_error(): void { + $this->setUpPluginsDir(); + + $output = $this->slicExec( 'mysql', $this->dockerMockEnv() ); + + $this->assertStringNotContainsString( + 'Error', + $output, + 'The mysql command should not produce error output.' + ); + $this->assertStringNotContainsString( + 'not a valid command', + $output, + 'The mysql command should be recognized as a valid command.' + ); + } +} diff --git a/tests/Cli/NpmTest.php b/tests/Cli/NpmTest.php new file mode 100644 index 0000000..a5dfc65 --- /dev/null +++ b/tests/Cli/NpmTest.php @@ -0,0 +1,38 @@ +setUpPluginsDir(); + + $output = $this->slicExec( 'npm install' ); + + $this->assertStringContainsString( + 'This command requires a target set using the', + $output, + 'Running npm without a use target should show an error.' + ); + } + + public function test_npm_passes_commands_to_container(): void { + $this->setUpPluginsDir(); + + $this->slicExec( 'use test-plugin', $this->dockerMockEnv() ); + + $output = $this->slicExec( 'npm install', $this->dockerMockEnv() ); + + $this->assertStringContainsString( + 'Using test-plugin', + $output, + 'Running npm install with docker mock should show the current use target.' + ); + + $this->assertStringNotContainsString( + 'Error', + $output, + 'Running npm install with docker mock should not produce an error.' + ); + } +} diff --git a/tests/Cli/PhpcsPhpcbfTest.php b/tests/Cli/PhpcsPhpcbfTest.php new file mode 100644 index 0000000..f1b3828 --- /dev/null +++ b/tests/Cli/PhpcsPhpcbfTest.php @@ -0,0 +1,58 @@ +setUpPluginsDir(); + + $output = $this->slicExec( 'phpcs' ); + + $this->assertStringContainsString( + 'This command requires a target set using the use command.', + $output, + 'Running phpcs without a use target should show an error.' + ); + } + + public function test_phpcs_with_target_runs(): void { + $this->setUpPluginsDir(); + + $this->slicExec( 'use test-plugin', $this->dockerMockEnv() ); + + $output = $this->slicExec( 'phpcs', $this->dockerMockEnv() ); + + $this->assertStringContainsString( + 'Using test-plugin', + $output, + 'Running phpcs with a use target should show the target name.' + ); + } + + public function test_phpcbf_requires_use_target(): void { + $this->setUpPluginsDir(); + + $output = $this->slicExec( 'phpcbf' ); + + $this->assertStringContainsString( + 'This command requires a target set using the use command.', + $output, + 'Running phpcbf without a use target should show an error.' + ); + } + + public function test_phpcbf_with_target_runs(): void { + $this->setUpPluginsDir(); + + $this->slicExec( 'use test-plugin', $this->dockerMockEnv() ); + + $output = $this->slicExec( 'phpcbf', $this->dockerMockEnv() ); + + $this->assertStringContainsString( + 'Using test-plugin', + $output, + 'Running phpcbf with a use target should show the target name.' + ); + } +} diff --git a/tests/Cli/PlaywrightTest.php b/tests/Cli/PlaywrightTest.php new file mode 100644 index 0000000..e39dea5 --- /dev/null +++ b/tests/Cli/PlaywrightTest.php @@ -0,0 +1,37 @@ +setUpPluginsDir(); + + $output = $this->slicExec( 'playwright test' ); + + $this->assertStringContainsString( + 'This command requires a target set using the use command.', + $output, + 'Running playwright without a use target should show an error.' + ); + } + + public function test_playwright_passes_commands_to_container(): void { + $this->setUpPluginsDir(); + + $this->slicExec( 'use test-plugin', $this->dockerMockEnv() ); + + $output = $this->slicExec( 'playwright test', $this->dockerMockEnv() ); + + $this->assertStringContainsString( + 'Using test-plugin', + $output, + 'Running playwright test with docker mock should show the current use target.' + ); + $this->assertStringNotContainsString( + 'Error', + $output, + 'Running playwright test with docker mock should not produce errors.' + ); + } +} diff --git a/tests/Cli/ResetTest.php b/tests/Cli/ResetTest.php new file mode 100644 index 0000000..78d1367 --- /dev/null +++ b/tests/Cli/ResetTest.php @@ -0,0 +1,96 @@ +setUpPluginsDir(); + + $this->slicExec( 'use test-plugin', $this->dockerMockEnv() ); + + $usingBefore = $this->slicExec( 'using' ); + $this->assertStringContainsString( + 'Using test-plugin', + $usingBefore, + 'The target should be set before reset.' + ); + + $this->slicExec( 'reset', $this->dockerMockEnv() ); + $this->slicExec( 'here' ); + + $usingAfter = $this->slicExec( 'using' ); + $this->assertStringContainsString( + 'Currently not using any target', + $usingAfter, + 'The target should be cleared after reset and re-initialization.' + ); + } + + public function test_reset_restores_default_php_version(): void { + $this->setUpPluginsDir(); + + $this->slicExec( 'php-version set 8.1 --skip-rebuild' ); + + $phpVersionBefore = $this->slicExec( 'php-version' ); + $this->assertStringContainsString( + '8.1', + $phpVersionBefore, + 'The PHP version should be staged to 8.1 before reset.' + ); + + // Reset preserves per-stack state; an explicit php-version reset is needed. + $this->slicExec( 'reset', $this->dockerMockEnv() ); + $this->slicExec( 'php-version reset --skip-rebuild' ); + + $phpVersionAfter = $this->slicExec( 'php-version' ); + $this->assertStringContainsString( + '7.4', + $phpVersionAfter, + 'The PHP version should be restored to 7.4 after reset.' + ); + } + + public function test_reset_output_confirms(): void { + $this->setUpPluginsDir(); + + $output = $this->slicExec( 'reset', $this->dockerMockEnv() ); + + $this->assertStringContainsString( + 'Removing', + $output, + 'The reset output should mention removing files.' + ); + $this->assertStringContainsString( + '.env.slic.run', + $output, + 'The reset output should mention the run settings file.' + ); + $this->assertStringContainsString( + 'done', + $output, + 'The reset output should confirm completion.' + ); + } + + public function test_reset_clears_xdebug_settings(): void { + $this->setUpPluginsDir(); + + $setOutput = $this->slicExec( 'xdebug port 9009' ); + $this->assertStringContainsString( + 'XDP=9009', + $setOutput, + 'The xdebug port command should confirm setting XDP=9009.' + ); + + $this->slicExec( 'reset', $this->dockerMockEnv() ); + $this->slicExec( 'here' ); + + $xdebugAfter = $this->slicExec( 'xdebug status' ); + $this->assertStringNotContainsString( + '9009', + $xdebugAfter, + 'The xdebug port should no longer be 9009 after reset and re-initialization.' + ); + } +} diff --git a/tests/Cli/RunTest.php b/tests/Cli/RunTest.php new file mode 100644 index 0000000..1b588bc --- /dev/null +++ b/tests/Cli/RunTest.php @@ -0,0 +1,36 @@ +slicExec( 'run' ); + + $this->assertStringContainsString( + 'This command requires a target set using the use command.', + $output, + 'Running run without a use target should show an error.' + ); + } + + public function test_run_passes_commands_to_container(): void { + $pluginsDir = $this->setUpPluginsDir(); + + // Create a codeception.dist.yml so the run command does not bail for missing config. + file_put_contents( + $pluginsDir . '/test-plugin/codeception.dist.yml', + "paths:\n tests: tests\n" + ); + + $this->slicExec( 'use test-plugin', $this->dockerMockEnv() ); + + $output = $this->slicExec( 'run wpunit', $this->dockerMockEnv() ); + + $this->assertStringContainsString( + 'Using test-plugin', + $output, + 'The run command should report the current use target.' + ); + } +} diff --git a/tests/Cli/ShellTest.php b/tests/Cli/ShellTest.php new file mode 100644 index 0000000..3e86e7f --- /dev/null +++ b/tests/Cli/ShellTest.php @@ -0,0 +1,58 @@ +slicExec( 'shell' ); + + $this->assertStringContainsString( + 'This command requires a target set using the use command.', + $output, + 'Running shell without a use target should show an error.' + ); + } + + public function test_shell_with_target_runs(): void { + $this->setUpPluginsDir(); + $this->slicExec( 'use test-plugin', $this->dockerMockEnv() ); + + $output = $this->slicExec( 'shell', $this->dockerMockEnv() ); + + $this->assertStringContainsString( + 'Using test-plugin', + $output, + 'The shell command should show the current target.' + ); + } + + public function test_ssh_is_alias_for_shell(): void { + $this->setUpPluginsDir(); + $this->slicExec( 'use test-plugin', $this->dockerMockEnv() ); + + $output = $this->slicExec( 'ssh', $this->dockerMockEnv() ); + + $this->assertStringNotContainsString( + 'not a valid command', + $output, + 'The ssh command should be a recognized command.' + ); + } + + public function test_shell_help_shows_usage(): void { + $output = $this->slicExec( 'shell help' ); + + $this->assertStringContainsString( + 'USAGE', + $output, + 'The shell help should display usage information.' + ); + + $this->assertStringContainsString( + 'shell', + $output, + 'The shell help should reference the shell command.' + ); + } +} diff --git a/tests/Cli/StackTest.php b/tests/Cli/StackTest.php new file mode 100644 index 0000000..5e0b132 --- /dev/null +++ b/tests/Cli/StackTest.php @@ -0,0 +1,110 @@ +setUpPluginsDir(); + + $output = $this->slicExec( 'stack list' ); + + $this->assertStringContainsString( + $pluginsDir, + $output, + 'The stack list should include the registered plugins directory.' + ); + } + + public function test_stack_list_empty_when_no_stacks(): void { + $this->slicExec( 'stack stop all -y', $this->dockerMockEnv() ); + + $output = $this->slicExec( 'stack list' ); + + $this->assertStringContainsString( + 'No stacks registered', + $output, + 'The stack list should indicate no stacks are registered.' + ); + } + + public function test_stack_info_shows_stack_details(): void { + $pluginsDir = $this->setUpPluginsDir(); + + $output = $this->slicExec( 'stack info' ); + + $this->assertStringContainsString( + 'Stack ID:', + $output, + 'The stack info output should contain the Stack ID label.' + ); + $this->assertStringContainsString( + $pluginsDir, + $output, + 'The stack info output should contain the plugins directory path.' + ); + $this->assertStringContainsString( + 'Status:', + $output, + 'The stack info output should contain the Status label.' + ); + } + + public function test_stack_stop_removes_stack(): void { + // Create the stack manually to avoid tearDown trying to stop it again. + $pluginsDir = Directory::createTemp() + ->createPlugin( 'test-plugin' ) + ->getAbsolutePath(); + chdir( $pluginsDir ); + $this->slicExec( 'here' ); + $stackId = realpath( $pluginsDir ); + + $this->slicExec( 'stack stop ' . escapeshellarg( $stackId ), $this->dockerMockEnv() ); + + $output = $this->slicExec( 'stack list' ); + $this->assertStringNotContainsString( + $stackId, + $output, + 'The stack list should not contain the stopped stack.' + ); + } + + public function test_stack_stop_all_stops_all_stacks(): void { + $dirA = $this->setUpPluginsDir( 'plugin-alpha' ); + $dirB = $this->setUpPluginsDir( 'plugin-beta' ); + + $this->slicExec( 'stack stop all -y', $this->dockerMockEnv() ); + + $output = $this->slicExec( 'stack list' ); + $this->assertStringNotContainsString( + $dirA, + $output, + 'The stack list should not contain the first stack after stopping all.' + ); + $this->assertStringNotContainsString( + $dirB, + $output, + 'The stack list should not contain the second stack after stopping all.' + ); + } + + public function test_stack_list_shows_multiple_stacks(): void { + $dirA = $this->setUpPluginsDir( 'plugin-one' ); + $dirB = $this->setUpPluginsDir( 'plugin-two' ); + + $output = $this->slicExec( 'stack list' ); + + $this->assertStringContainsString( + $dirA, + $output, + 'The stack list should contain the first registered stack.' + ); + $this->assertStringContainsString( + $dirB, + $output, + 'The stack list should contain the second registered stack.' + ); + } +} diff --git a/tests/Cli/StartStopTest.php b/tests/Cli/StartStopTest.php new file mode 100644 index 0000000..d4ecedf --- /dev/null +++ b/tests/Cli/StartStopTest.php @@ -0,0 +1,126 @@ +setUpPluginsDir(); + + $output = $this->slicExec( 'start', $this->dockerMockEnv() ); + + $this->assertStringContainsString( + 'All containers are running', + $output, + 'The start command should report all containers running.' + ); + $this->assertStringNotContainsString( + 'Error', + $output, + 'The start command should not produce error output.' + ); + } + + public function test_up_is_alias_for_start(): void { + $this->setUpPluginsDir(); + + $startOutput = $this->slicExec( 'start', $this->dockerMockEnv() ); + $upOutput = $this->slicExec( 'up', $this->dockerMockEnv() ); + + $this->assertStringContainsString( + 'All containers are running', + $upOutput, + 'The up command should report all containers running.' + ); + $this->assertStringContainsString( + 'All containers are running', + $startOutput, + 'The start command should report all containers running.' + ); + } + + public function test_stop_runs_without_error(): void { + $this->setUpPluginsDir(); + + $output = $this->slicExec( 'stop', $this->dockerMockEnv() ); + + $this->assertStringContainsString( + 'All services have been stopped', + $output, + 'The stop command should report all services stopped.' + ); + $this->assertStringNotContainsString( + 'Error', + $output, + 'The stop command should not produce error output.' + ); + } + + public function test_down_is_alias_for_stop(): void { + $this->setUpPluginsDir(); + + $output = $this->slicExec( 'down', $this->dockerMockEnv() ); + + $this->assertStringContainsString( + 'All services have been stopped', + $output, + 'The down command should report all services stopped.' + ); + } + + public function test_restart_runs_without_error(): void { + $this->setUpPluginsDir(); + + $output = $this->slicExec( 'restart', $this->dockerMockEnv() ); + + $this->assertStringNotContainsString( + 'Error', + $output, + 'The restart command should not produce error output.' + ); + } + + public function test_start_help_shows_usage(): void { + $this->setUpPluginsDir(); + + $output = $this->slicExec( 'start help' ); + + $this->assertStringContainsString( + 'SUMMARY', + $output, + 'The start help should show the summary section.' + ); + $this->assertStringContainsString( + 'USAGE', + $output, + 'The start help should show the usage section.' + ); + $this->assertStringContainsString( + 'Starts containers in the stack', + $output, + 'The start help should describe its purpose.' + ); + } + + public function test_stop_help_shows_usage(): void { + $this->setUpPluginsDir(); + + $output = $this->slicExec( 'stop help' ); + + $this->assertStringContainsString( + 'SUMMARY', + $output, + 'The stop help should show the summary section.' + ); + $this->assertStringContainsString( + 'USAGE', + $output, + 'The stop help should show the usage section.' + ); + $this->assertStringContainsString( + 'Stops containers in the stack', + $output, + 'The stop help should describe its purpose.' + ); + } +} diff --git a/tests/Cli/TargetTest.php b/tests/Cli/TargetTest.php new file mode 100644 index 0000000..78f9d19 --- /dev/null +++ b/tests/Cli/TargetTest.php @@ -0,0 +1,28 @@ +slicExec( 'target help' ); + + $this->assertStringContainsString( + 'USAGE', + $output, + 'The target help output should display usage information.' + ); + + $this->assertStringContainsString( + 'SUMMARY', + $output, + 'The target help output should display a summary.' + ); + + $this->assertStringContainsString( + 'slic.php target', + $output, + 'The target help output should contain the command signature.' + ); + } +} diff --git a/tests/Cli/UpdateDumpTest.php b/tests/Cli/UpdateDumpTest.php new file mode 100644 index 0000000..dc1cac4 --- /dev/null +++ b/tests/Cli/UpdateDumpTest.php @@ -0,0 +1,48 @@ +slicExec( 'update-dump help' ); + + $this->assertStringContainsString( + 'USAGE', + $output, + 'The help output should contain the USAGE section.' + ); + + $this->assertStringContainsString( + 'SUMMARY', + $output, + 'The help output should contain the SUMMARY section.' + ); + } + + public function test_update_dump_requires_file_argument(): void { + $this->setUpPluginsDir(); + $this->slicExec( 'use test-plugin', $this->dockerMockEnv() ); + + $output = $this->slicExec( 'update-dump', $this->dockerMockEnv() ); + + $this->assertStringContainsString( + 'Undefined array key 0', + $output, + 'Running update-dump without a file argument should produce a warning about the missing argument.' + ); + } + + public function test_update_dump_nonexistent_file_shows_error(): void { + $this->setUpPluginsDir(); + $this->slicExec( 'use test-plugin', $this->dockerMockEnv() ); + + $output = $this->slicExec( 'update-dump nonexistent.sql', $this->dockerMockEnv() ); + + $this->assertStringContainsString( + 'does not exist', + $output, + 'Running update-dump with a nonexistent file should show an error about the missing file.' + ); + } +} diff --git a/tests/Cli/UpdateTest.php b/tests/Cli/UpdateTest.php new file mode 100644 index 0000000..f16a60c --- /dev/null +++ b/tests/Cli/UpdateTest.php @@ -0,0 +1,38 @@ +slicExec( 'update help' ); + + $this->assertStringContainsString( + 'USAGE', + $output, + 'The update help output should contain a USAGE section.' + ); + + $this->assertStringContainsString( + 'SUMMARY', + $output, + 'The update help output should contain a SUMMARY section.' + ); + } + + public function test_upgrade_help_shows_usage(): void { + $output = $this->slicExec( 'upgrade help' ); + + $this->assertStringContainsString( + 'USAGE', + $output, + 'The upgrade help output should contain a USAGE section.' + ); + + $this->assertStringContainsString( + 'SUMMARY', + $output, + 'The upgrade help output should contain a SUMMARY section.' + ); + } +} diff --git a/tests/Cli/UseTest.php b/tests/Cli/UseTest.php new file mode 100644 index 0000000..86b6b25 --- /dev/null +++ b/tests/Cli/UseTest.php @@ -0,0 +1,110 @@ +setUpPluginsDir(); + + $this->slicExec( 'use test-plugin', $this->dockerMockEnv() ); + + $output = $this->slicExec( 'using' ); + + $this->assertStringContainsString( + 'Using test-plugin', + $output, + 'The using command should report the set target.' + ); + } + + public function test_use_invalid_target_shows_error(): void { + $this->setUpPluginsDir(); + + $output = $this->slicExec( 'use nonexistent-plugin', $this->dockerMockEnv() ); + + $this->assertStringContainsString( + 'is not a valid target', + $output, + 'Using a nonexistent plugin should show an invalid target error.' + ); + } + + public function test_use_lists_available_targets(): void { + $this->setUpPluginsDir(); + + $output = $this->slicExec( 'use nonexistent-plugin', $this->dockerMockEnv() ); + + $this->assertStringContainsString( + 'test-plugin', + $output, + 'The error output should list available targets.' + ); + } + + public function test_use_with_subdir_target(): void { + $pluginsDir = $this->setUpPluginsDir( 'my-plugin' ); + + // Add a subdirectory target inside the plugin. + mkdir( $pluginsDir . '/my-plugin/common', 0777, true ); + file_put_contents( + $pluginsDir . '/my-plugin/common/plugin.php', + "/**\n* Plugin Name: Common\n*/" + ); + + $this->slicExec( 'use my-plugin/common', $this->dockerMockEnv() ); + + $output = $this->slicExec( 'using' ); + + $this->assertStringContainsString( + 'Using my-plugin/common', + $output, + 'The using command should report the subdirectory target.' + ); + } + + public function test_use_changes_target(): void { + $pluginsDir = $this->setUpPluginsDir( 'first-plugin' ); + + // Add a second plugin to the plugins directory. + mkdir( $pluginsDir . '/second-plugin', 0777, true ); + file_put_contents( + $pluginsDir . '/second-plugin/plugin.php', + "/**\n* Plugin Name: Second Plugin\n*/" + ); + + $this->slicExec( 'use first-plugin', $this->dockerMockEnv() ); + $this->slicExec( 'use second-plugin', $this->dockerMockEnv() ); + + $output = $this->slicExec( 'using' ); + + $this->assertStringContainsString( + 'Using second-plugin', + $output, + 'The using command should report the most recently set target.' + ); + } + + public function test_use_without_here_shows_error(): void { + // Set up a plugins directory without running 'here' to register it. + $tempDir = sys_get_temp_dir() . '/slic-no-here-' . uniqid( '', true ); + mkdir( $tempDir, 0777, true ); + mkdir( $tempDir . '/test-plugin', 0777, true ); + file_put_contents( + $tempDir . '/test-plugin/plugin.php', + "/**\n* Plugin Name: Test Plugin\n*/" + ); + chdir( $tempDir ); + + $output = $this->slicExec( 'use test-plugin', $this->dockerMockEnv() ); + + $this->assertStringContainsString( + 'is not a valid target', + $output, + 'Using a plugin without a configured here directory should show an error.' + ); + + chdir( $this->initialDir ); + exec( 'rm -rf ' . escapeshellarg( $tempDir ) ); + } +} diff --git a/tests/Cli/UsingTest.php b/tests/Cli/UsingTest.php new file mode 100644 index 0000000..7741cba --- /dev/null +++ b/tests/Cli/UsingTest.php @@ -0,0 +1,52 @@ +setUpPluginsDir( 'test-plugin' ); + $this->slicExec( 'use test-plugin', $this->dockerMockEnv() ); + + $output = $this->slicExec( 'using', $this->dockerMockEnv() ); + + $this->assertStringContainsString( + 'test-plugin', + $output, + 'The using command should display the current target.' + ); + } + + public function test_using_with_no_target_shows_none(): void { + $this->setUpPluginsDir( 'test-plugin' ); + + $output = $this->slicExec( 'using', $this->dockerMockEnv() ); + + $this->assertStringContainsString( + 'not using any target', + $output, + 'The using command should indicate no target is set.' + ); + } + + public function test_using_after_target_change(): void { + $pluginsDir = $this->setUpPluginsDir( 'plugin-alpha' ); + // Add a second plugin to the plugins directory. + mkdir( $pluginsDir . '/plugin-beta', 0777, true ); + file_put_contents( + $pluginsDir . '/plugin-beta/plugin.php', + "/**\n* Plugin Name: Plugin Beta\n*/" + ); + + $this->slicExec( 'use plugin-alpha', $this->dockerMockEnv() ); + $this->slicExec( 'use plugin-beta', $this->dockerMockEnv() ); + + $output = $this->slicExec( 'using', $this->dockerMockEnv() ); + + $this->assertStringContainsString( + 'plugin-beta', + $output, + 'The using command should display the most recently set target.' + ); + } +} diff --git a/tests/Cli/WorktreeTest.php b/tests/Cli/WorktreeTest.php new file mode 100644 index 0000000..52d5a33 --- /dev/null +++ b/tests/Cli/WorktreeTest.php @@ -0,0 +1,38 @@ +slicExec( 'worktree help' ); + + $this->assertStringContainsString( + 'USAGE', + $output, + 'The worktree help output should contain USAGE section.' + ); + + $this->assertStringContainsString( + 'SUMMARY', + $output, + 'The worktree help output should contain SUMMARY section.' + ); + } + + public function test_worktree_no_subcommand_shows_help(): void { + $output = $this->slicExec( 'worktree' ); + + $this->assertStringContainsString( + 'Available commands', + $output, + 'Running worktree without a subcommand should show available commands.' + ); + + $this->assertStringContainsString( + "Run 'slic worktree help' for more information.", + $output, + 'Running worktree without a subcommand should suggest running help.' + ); + } +} diff --git a/tests/Cli/WpCliTest.php b/tests/Cli/WpCliTest.php new file mode 100644 index 0000000..3bd217e --- /dev/null +++ b/tests/Cli/WpCliTest.php @@ -0,0 +1,95 @@ +slicExec( 'wp help' ); + + $this->assertStringContainsString( + 'Runs a wp-cli command', + $output, + 'The wp help output should describe the command purpose.' + ); + + $this->assertStringContainsString( + 'USAGE:', + $output, + 'The wp help output should contain a USAGE section.' + ); + } + + public function test_wp_passes_commands_with_target(): void { + $this->setUpPluginsDir(); + + $this->slicExec( 'use test-plugin', $this->dockerMockEnv() ); + + $output = $this->slicExec( 'wp plugin list', $this->dockerMockEnv() ); + + $this->assertStringNotContainsString( + 'Unknown command', + $output, + 'The wp command should not produce an unknown command error.' + ); + } + + public function test_cli_is_alias_for_wp(): void { + $wpOutput = $this->slicExec( 'wp help' ); + $cliOutput = $this->slicExec( 'cli help' ); + + $this->assertStringContainsString( + 'Runs a wp-cli command', + $cliOutput, + 'The cli help output should describe the same command as wp.' + ); + + $this->assertStringContainsString( + 'USAGE:', + $cliOutput, + 'The cli help output should contain a USAGE section.' + ); + + $this->assertStringContainsString( + 'cli', + $cliOutput, + 'The cli help output should reference the cli command name.' + ); + + $this->assertEquals( + $wpOutput, + $cliOutput, + 'The wp and cli help outputs should be identical.' + ); + } + + public function test_site_cli_help_shows_usage(): void { + $output = $this->slicExec( 'site-cli help' ); + + $this->assertStringContainsString( + 'Waits for WordPress to be correctly set up to run a wp-cli command', + $output, + 'The site-cli help output should describe waiting for WordPress.' + ); + + $this->assertStringContainsString( + 'USAGE:', + $output, + 'The site-cli help output should contain a USAGE section.' + ); + } + + public function test_site_cli_passes_commands_with_target(): void { + $this->setUpPluginsDir(); + + $this->slicExec( 'use test-plugin', $this->dockerMockEnv() ); + + $output = $this->slicExec( 'site-cli plugin list', $this->dockerMockEnv() ); + + $this->assertStringNotContainsString( + 'Unknown command', + $output, + 'The site-cli command should not produce an unknown command error.' + ); + } +} diff --git a/tests/Cli/XdebugTest.php b/tests/Cli/XdebugTest.php new file mode 100644 index 0000000..88aa740 --- /dev/null +++ b/tests/Cli/XdebugTest.php @@ -0,0 +1,120 @@ +setUpPluginsDir(); + + $output = $this->slicExec( 'xdebug status' ); + + $this->assertStringContainsString( + 'XDebug status is:', + $output, + 'The xdebug status command should display the current XDebug state.' + ); + $this->assertStringContainsString( + 'Remote host:', + $output, + 'The xdebug status command should display the remote host.' + ); + $this->assertStringContainsString( + 'Remote port:', + $output, + 'The xdebug status command should display the remote port.' + ); + $this->assertStringContainsString( + 'IDE Key (server name):', + $output, + 'The xdebug status command should display the IDE key.' + ); + } + + public function test_xdebug_on_activates(): void { + $this->setUpPluginsDir(); + + $output = $this->slicExec( 'xdebug on' ); + + $this->assertStringContainsString( + 'XDebug status: on', + $output, + 'The xdebug on command should confirm XDebug is activated.' + ); + } + + public function test_xdebug_off_deactivates(): void { + $this->setUpPluginsDir(); + + $output = $this->slicExec( 'xdebug off' ); + + $this->assertStringContainsString( + 'XDebug status: off', + $output, + 'The xdebug off command should confirm XDebug is deactivated.' + ); + } + + public function test_xdebug_port_sets_port(): void { + $this->setUpPluginsDir(); + + $output = $this->slicExec( 'xdebug port 9009' ); + + $this->assertStringContainsString( + 'Setting XDP=9009', + $output, + 'The xdebug port command should confirm the port is being set.' + ); + $this->assertStringContainsString( + 'Tear down the stack with down and restart it to apply the new settings!', + $output, + 'The xdebug port command should remind the user to restart.' + ); + } + + public function test_xdebug_host_sets_host(): void { + $this->setUpPluginsDir(); + + $output = $this->slicExec( 'xdebug host 192.168.1.2' ); + + $this->assertStringContainsString( + 'Setting XDH=192.168.1.2', + $output, + 'The xdebug host command should confirm the host is being set.' + ); + $this->assertStringContainsString( + 'Tear down the stack with down and restart it to apply the new settings!', + $output, + 'The xdebug host command should remind the user to restart.' + ); + } + + public function test_xdebug_key_sets_key(): void { + $this->setUpPluginsDir(); + + $output = $this->slicExec( 'xdebug key mykey' ); + + $this->assertStringContainsString( + 'Setting XDK=mykey', + $output, + 'The xdebug key command should confirm the IDE key is being set.' + ); + $this->assertStringContainsString( + 'Tear down the stack with down and restart it to apply the new settings!', + $output, + 'The xdebug key command should remind the user to restart.' + ); + } + + public function test_xdebug_no_args_shows_help_or_status(): void { + $this->setUpPluginsDir(); + + $output = $this->slicExec( 'xdebug' ); + + $this->assertStringContainsString( + 'XDebug status:', + $output, + 'Running xdebug with no arguments should show the current XDebug status.' + ); + } +} diff --git a/tests/_support/bin/docker-mock-host-ip b/tests/_support/bin/docker-mock-host-ip new file mode 100755 index 0000000..b0f9b06 --- /dev/null +++ b/tests/_support/bin/docker-mock-host-ip @@ -0,0 +1,9 @@ +#!/bin/bash +# A mock docker binary that returns a fake IP for host-ip commands. +for arg in "$@"; do + if [[ "$arg" == *"host-ip"* ]]; then + echo "172.17.0.1" + exit 0 + fi +done +exit 0 diff --git a/tests/_support/bin/git-mock-dir/git b/tests/_support/bin/git-mock-dir/git new file mode 100755 index 0000000..3b5705e --- /dev/null +++ b/tests/_support/bin/git-mock-dir/git @@ -0,0 +1,4 @@ +#!/bin/bash +# A mock git binary that always fails. Used to prevent real git clone calls from hanging in tests. +echo "fatal: mock git failure" >&2 +exit 128 From d74f012d086711aebaf7a01367e3876930d82eef Mon Sep 17 00:00:00 2001 From: Luca Tumedei Date: Wed, 25 Feb 2026 17:40:50 +0100 Subject: [PATCH 2/4] fix: make CLI tests pass in CI environments Set dummy SSH_AUTH_SOCK, isolated SLIC_CACHE_DIR, and deterministic HOME in test harness to avoid CI-specific failures. --- tests/Cli/BaseTestCase.php | 29 +++++++++++++++++++++++++++++ tests/Cli/CompletionTest.php | 12 +++++++++--- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/tests/Cli/BaseTestCase.php b/tests/Cli/BaseTestCase.php index 2ecfe39..2b2af6e 100644 --- a/tests/Cli/BaseTestCase.php +++ b/tests/Cli/BaseTestCase.php @@ -17,6 +17,7 @@ abstract class BaseTestCase extends TestCase { private static string $dockerMockBin = ''; private static string $gitMockDir = ''; + private string $slicCacheDir = ''; public static function setUpBeforeClass(): void { parent::setUpBeforeClass(); @@ -27,6 +28,8 @@ public static function setUpBeforeClass(): void { public function setUp(): void { parent::setUp(); $this->initialDir = getcwd(); + $this->slicCacheDir = sys_get_temp_dir() . '/slic-test-cache-' . uniqid( '', true ); + mkdir( $this->slicCacheDir . '/completions', 0777, true ); } public function tearDown(): void { @@ -41,6 +44,11 @@ public function tearDown(): void { ); } + // Remove the temporary cache directory. + if ( $this->slicCacheDir !== '' && is_dir( $this->slicCacheDir ) ) { + $this->removeDirectory( $this->slicCacheDir ); + } + parent::tearDown(); } @@ -55,6 +63,14 @@ public function tearDown(): void { protected function slicExec( string $command, array $env = [] ): string { $env['NO_COLOR'] = '1'; $env['SLIC_INTERACTIVE'] = '0'; + // Provide a dummy SSH_AUTH_SOCK to prevent setup_id() from exiting in CI. + if ( ! isset( $env['SSH_AUTH_SOCK'] ) && empty( getenv( 'SSH_AUTH_SOCK' ) ) ) { + $env['SSH_AUTH_SOCK'] = '/tmp/fake-ssh-agent.sock'; + } + // Use a temporary cache directory to avoid polluting the real cache. + if ( ! isset( $env['SLIC_CACHE_DIR'] ) && $this->slicCacheDir !== '' ) { + $env['SLIC_CACHE_DIR'] = $this->slicCacheDir; + } $envString = ''; foreach ( $env as $key => $value ) { @@ -113,4 +129,17 @@ protected function setUpPluginsDir( string $pluginName = 'test-plugin' ): string return $pluginsDir; } + + private function removeDirectory( string $dir ): void { + $items = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator( $dir, \FilesystemIterator::SKIP_DOTS ), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ( $items as $item ) { + $item->isDir() ? rmdir( $item->getPathname() ) : unlink( $item->getPathname() ); + } + + rmdir( $dir ); + } } diff --git a/tests/Cli/CompletionTest.php b/tests/Cli/CompletionTest.php index 3167358..db2cd00 100644 --- a/tests/Cli/CompletionTest.php +++ b/tests/Cli/CompletionTest.php @@ -65,7 +65,13 @@ public function test_completion_cache_clear(): void { } public function test_completion_no_args_shows_instructions(): void { - $output = $this->slicExec( 'completion' ); + // Use a clean HOME so is_installed() returns false, ensuring install instructions are shown. + $tempHome = sys_get_temp_dir() . '/slic-test-home-' . uniqid( '', true ); + mkdir( $tempHome, 0777, true ); + + $output = $this->slicExec( 'completion', [ 'HOME' => $tempHome ] ); + + @rmdir( $tempHome ); $this->assertStringContainsString( 'Detected shell:', @@ -74,9 +80,9 @@ public function test_completion_no_args_shows_instructions(): void { ); $this->assertStringContainsString( - 'completion cache-clear', + 'completion install', $output, - 'The output should mention the cache-clear subcommand.' + 'The output should mention the completion install subcommand.' ); } } From 96390c39fa67f2bcd426ed0058a3584f7d11ea19 Mon Sep 17 00:00:00 2001 From: Luca Tumedei Date: Thu, 26 Feb 2026 10:58:10 +0100 Subject: [PATCH 3/4] test: add interactive command testing via proc_open Add $stdin parameter to slicExec() to support piping input to interactive commands. When provided, uses proc_open() with SLIC_INTERACTIVE=1 instead of shell_exec() with /dev/null. Add interactive tests for the target command flow. --- tests/Cli/BaseTestCase.php | 23 +++++++++++++-- tests/Cli/TargetTest.php | 57 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/tests/Cli/BaseTestCase.php b/tests/Cli/BaseTestCase.php index 2b2af6e..7c67258 100644 --- a/tests/Cli/BaseTestCase.php +++ b/tests/Cli/BaseTestCase.php @@ -57,12 +57,14 @@ public function tearDown(): void { * * @param string $command The command to execute, escaped if required. * @param array $env Optional environment variables to set for the command. + * @param string|null $stdin Optional stdin content to pipe to the process. When provided, + * SLIC_INTERACTIVE is set to '1' and proc_open is used. * * @return string The command output. */ - protected function slicExec( string $command, array $env = [] ): string { + protected function slicExec( string $command, array $env = [], ?string $stdin = null ): string { $env['NO_COLOR'] = '1'; - $env['SLIC_INTERACTIVE'] = '0'; + $env['SLIC_INTERACTIVE'] = $stdin !== null ? '1' : '0'; // Provide a dummy SSH_AUTH_SOCK to prevent setup_id() from exiting in CI. if ( ! isset( $env['SSH_AUTH_SOCK'] ) && empty( getenv( 'SSH_AUTH_SOCK' ) ) ) { $env['SSH_AUTH_SOCK'] = '/tmp/fake-ssh-agent.sock'; @@ -79,6 +81,23 @@ protected function slicExec( string $command, array $env = [] ): string { $commandString = $envString . 'php ' . escapeshellarg( dirname( __DIR__, 2 ) . '/slic.php' ) . ' ' . $command; + if ( $stdin !== null ) { + $descriptors = [ + 0 => [ 'pipe', 'r' ], + 1 => [ 'pipe', 'w' ], + 2 => [ 'pipe', 'w' ], + ]; + $process = proc_open( $commandString, $descriptors, $pipes ); + fwrite( $pipes[0], $stdin ); + fclose( $pipes[0] ); + $output = stream_get_contents( $pipes[1] ) . stream_get_contents( $pipes[2] ); + fclose( $pipes[1] ); + fclose( $pipes[2] ); + proc_close( $process ); + + return $output; + } + // Close stdin to prevent interactive prompts from blocking, and redirect stderr to stdout. return (string) shell_exec( $commandString . ' &1' ); } diff --git a/tests/Cli/TargetTest.php b/tests/Cli/TargetTest.php index 78f9d19..d696d72 100644 --- a/tests/Cli/TargetTest.php +++ b/tests/Cli/TargetTest.php @@ -25,4 +25,61 @@ public function test_target_help_shows_usage(): void { 'The target help output should contain the command signature.' ); } + + public function test_target_interactive_collects_targets_and_commands(): void { + $this->setUpPluginsDir(); + + // Pipe: target "test-plugin", end targets, command "info", end commands, confirm. + $stdin = "test-plugin\n\ninfo\n\n\n"; + $output = $this->slicExec( 'target', $this->dockerMockEnv(), $stdin ); + + $this->assertStringContainsString( + 'Target', + $output, + 'The interactive target flow should prompt for targets.' + ); + $this->assertStringContainsString( + 'test-plugin', + $output, + 'The output should echo the entered target name.' + ); + $this->assertStringContainsString( + 'Command', + $output, + 'The interactive target flow should prompt for commands.' + ); + } + + public function test_target_interactive_shows_collected_targets(): void { + $this->setUpPluginsDir( 'plugin-a' ); + + // Enter one target, end targets loop, enter a command, end commands, confirm. + $stdin = "plugin-a\n\ninfo\n\n\n"; + $output = $this->slicExec( 'target', $this->dockerMockEnv(), $stdin ); + + $this->assertStringContainsString( + 'Targets:', + $output, + 'The output should display the "Targets:" label.' + ); + $this->assertStringContainsString( + 'plugin-a', + $output, + 'The output should include the collected target name.' + ); + } + + public function test_target_interactive_prompts_confirmation(): void { + $this->setUpPluginsDir(); + + // Enter target, end targets, enter command, end commands, confirm. + $stdin = "test-plugin\n\ninfo\n\n\n"; + $output = $this->slicExec( 'target', $this->dockerMockEnv(), $stdin ); + + $this->assertStringContainsString( + 'Are you sure', + $output, + 'The interactive target flow should prompt for confirmation before executing.' + ); + } } From 23cd2a345af1b7b20a3278f5c2d49185d829889e Mon Sep 17 00:00:00 2001 From: Luca Tumedei Date: Thu, 26 Feb 2026 11:56:42 +0100 Subject: [PATCH 4/4] test: add interactive tests for completion install and site-cli _install - Add createTempDir() helper to BaseTestCase with automatic tearDown cleanup - Guard proc_open and fwrite (including partial writes) with $this->fail() - Merge stderr via 2>&1 in proc_open path to match shell_exec behavior - Cast stream_get_contents to string to handle false return - Document small-payload deadlock limitation in slicExec() PHPDoc - Add completion install confirm test verifying .bashrc is written - Add completion install decline test verifying cancellation - Add completion install fish confirm test verifying symlink creation - Add site-cli _install decline test verifying command does not execute - Consolidate target interactive tests into single test with exact prompts - Fix pre-existing @rmdir in test_completion_no_args to use createTempDir() --- tests/Cli/BaseTestCase.php | 57 +++++++++++++++++++++---- tests/Cli/CompletionTest.php | 80 ++++++++++++++++++++++++++++++++++-- tests/Cli/TargetTest.php | 41 ++++-------------- tests/Cli/WpCliTest.php | 23 +++++++++++ 4 files changed, 156 insertions(+), 45 deletions(-) diff --git a/tests/Cli/BaseTestCase.php b/tests/Cli/BaseTestCase.php index 7c67258..b74139a 100644 --- a/tests/Cli/BaseTestCase.php +++ b/tests/Cli/BaseTestCase.php @@ -19,6 +19,13 @@ abstract class BaseTestCase extends TestCase { private static string $gitMockDir = ''; private string $slicCacheDir = ''; + /** + * Temporary directories created during the test, removed in tearDown. + * + * @var string[] + */ + private array $tempDirs = []; + public static function setUpBeforeClass(): void { parent::setUpBeforeClass(); self::$dockerMockBin = dirname( __DIR__ ) . '/_support/bin/docker-mock'; @@ -49,18 +56,29 @@ public function tearDown(): void { $this->removeDirectory( $this->slicCacheDir ); } + // Remove any temporary directories created during the test. + foreach ( $this->tempDirs as $dir ) { + if ( is_dir( $dir ) ) { + $this->removeDirectory( $dir ); + } + } + parent::tearDown(); } /** * Execute a slic command and return the output. * + * When `$stdin` is provided, `proc_open()` is used to pipe the content to the process's stdin + * and `SLIC_INTERACTIVE` is set to `'1'`. This path is intended for small payloads only; for + * commands that produce more than ~64 KB of combined output, the sequential read of stdout then + * stderr could deadlock. + * * @param string $command The command to execute, escaped if required. * @param array $env Optional environment variables to set for the command. - * @param string|null $stdin Optional stdin content to pipe to the process. When provided, - * SLIC_INTERACTIVE is set to '1' and proc_open is used. + * @param string|null $stdin Optional stdin content to pipe to the process. * - * @return string The command output. + * @return string The command output (stdout and stderr merged). */ protected function slicExec( string $command, array $env = [], ?string $stdin = null ): string { $env['NO_COLOR'] = '1'; @@ -85,17 +103,25 @@ protected function slicExec( string $command, array $env = [], ?string $stdin = $descriptors = [ 0 => [ 'pipe', 'r' ], 1 => [ 'pipe', 'w' ], - 2 => [ 'pipe', 'w' ], ]; - $process = proc_open( $commandString, $descriptors, $pipes ); - fwrite( $pipes[0], $stdin ); + // Redirect stderr to stdout so output ordering matches the shell_exec path. + $process = proc_open( $commandString . ' 2>&1', $descriptors, $pipes ); + + if ( ! is_resource( $process ) ) { + $this->fail( "proc_open() failed for command: $commandString" ); + } + + $written = fwrite( $pipes[0], $stdin ); + if ( $written === false || $written < strlen( $stdin ) ) { + fclose( $pipes[0] ); + $this->fail( "fwrite() to stdin pipe failed or wrote only $written of " . strlen( $stdin ) . " bytes for command: $commandString" ); + } fclose( $pipes[0] ); - $output = stream_get_contents( $pipes[1] ) . stream_get_contents( $pipes[2] ); + $output = stream_get_contents( $pipes[1] ); fclose( $pipes[1] ); - fclose( $pipes[2] ); proc_close( $process ); - return $output; + return (string) $output; } // Close stdin to prevent interactive prompts from blocking, and redirect stderr to stdout. @@ -149,6 +175,19 @@ protected function setUpPluginsDir( string $pluginName = 'test-plugin' ): string return $pluginsDir; } + /** + * Creates a temporary directory that is automatically cleaned up in tearDown. + * + * @return string The absolute path to the created directory. + */ + protected function createTempDir(): string { + $dir = sys_get_temp_dir() . '/slic-test-' . uniqid( '', true ); + mkdir( $dir, 0777, true ); + $this->tempDirs[] = $dir; + + return $dir; + } + private function removeDirectory( string $dir ): void { $items = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $dir, \FilesystemIterator::SKIP_DOTS ), diff --git a/tests/Cli/CompletionTest.php b/tests/Cli/CompletionTest.php index db2cd00..1f91673 100644 --- a/tests/Cli/CompletionTest.php +++ b/tests/Cli/CompletionTest.php @@ -66,13 +66,10 @@ public function test_completion_cache_clear(): void { public function test_completion_no_args_shows_instructions(): void { // Use a clean HOME so is_installed() returns false, ensuring install instructions are shown. - $tempHome = sys_get_temp_dir() . '/slic-test-home-' . uniqid( '', true ); - mkdir( $tempHome, 0777, true ); + $tempHome = $this->createTempDir(); $output = $this->slicExec( 'completion', [ 'HOME' => $tempHome ] ); - @rmdir( $tempHome ); - $this->assertStringContainsString( 'Detected shell:', $output, @@ -85,4 +82,79 @@ public function test_completion_no_args_shows_instructions(): void { 'The output should mention the completion install subcommand.' ); } + + public function test_completion_install_confirm_writes_config(): void { + $tempHome = $this->createTempDir(); + file_put_contents( $tempHome . '/.bashrc', '' ); + + $output = $this->slicExec( + 'completion install bash', + [ 'HOME' => $tempHome ], + "yes\n" + ); + + $bashrc = file_get_contents( $tempHome . '/.bashrc' ); + + $this->assertStringContainsString( + 'Continue with installation?', + $output, + 'The install flow should prompt for confirmation.' + ); + $this->assertStringContainsString( + 'completions installed successfully', + $output, + 'The output should confirm successful installation.' + ); + $this->assertStringContainsString( + 'slic completions', + $bashrc, + 'The .bashrc file should contain the slic completions block.' + ); + } + + public function test_completion_install_decline_cancels(): void { + $tempHome = $this->createTempDir(); + file_put_contents( $tempHome . '/.bashrc', '' ); + + $output = $this->slicExec( + 'completion install bash', + [ 'HOME' => $tempHome ], + "no\n" + ); + + $bashrc = file_get_contents( $tempHome . '/.bashrc' ); + + $this->assertStringContainsString( + 'Installation cancelled.', + $output, + 'Declining the prompt should cancel installation.' + ); + $this->assertEmpty( + $bashrc, + 'The .bashrc file should remain untouched after cancellation.' + ); + } + + public function test_completion_install_fish_confirm_creates_symlink(): void { + $tempHome = $this->createTempDir(); + mkdir( $tempHome . '/.config/fish/completions', 0777, true ); + + $output = $this->slicExec( + 'completion install fish', + [ 'HOME' => $tempHome ], + "yes\n" + ); + + $symlinkPath = $tempHome . '/.config/fish/completions/slic.fish'; + + $this->assertStringContainsString( + 'completions installed successfully', + $output, + 'The output should confirm successful fish installation.' + ); + $this->assertTrue( + is_link( $symlinkPath ), + 'A symlink should be created for the fish completion script.' + ); + } } diff --git a/tests/Cli/TargetTest.php b/tests/Cli/TargetTest.php index d696d72..55f2fca 100644 --- a/tests/Cli/TargetTest.php +++ b/tests/Cli/TargetTest.php @@ -26,7 +26,7 @@ public function test_target_help_shows_usage(): void { ); } - public function test_target_interactive_collects_targets_and_commands(): void { + public function test_target_interactive_flow(): void { $this->setUpPluginsDir(); // Pipe: target "test-plugin", end targets, command "info", end commands, confirm. @@ -34,50 +34,27 @@ public function test_target_interactive_collects_targets_and_commands(): void { $output = $this->slicExec( 'target', $this->dockerMockEnv(), $stdin ); $this->assertStringContainsString( - 'Target', + 'Target (return when done):', $output, 'The interactive target flow should prompt for targets.' ); $this->assertStringContainsString( - 'test-plugin', - $output, - 'The output should echo the entered target name.' - ); - $this->assertStringContainsString( - 'Command', + 'Targets:', $output, - 'The interactive target flow should prompt for commands.' + 'The output should display the "Targets:" label after collecting targets.' ); - } - - public function test_target_interactive_shows_collected_targets(): void { - $this->setUpPluginsDir( 'plugin-a' ); - - // Enter one target, end targets loop, enter a command, end commands, confirm. - $stdin = "plugin-a\n\ninfo\n\n\n"; - $output = $this->slicExec( 'target', $this->dockerMockEnv(), $stdin ); - $this->assertStringContainsString( - 'Targets:', + 'test-plugin', $output, - 'The output should display the "Targets:" label.' + 'The output should include the collected target name.' ); $this->assertStringContainsString( - 'plugin-a', + 'Command (return when done):', $output, - 'The output should include the collected target name.' + 'The interactive target flow should prompt for commands.' ); - } - - public function test_target_interactive_prompts_confirmation(): void { - $this->setUpPluginsDir(); - - // Enter target, end targets, enter command, end commands, confirm. - $stdin = "test-plugin\n\ninfo\n\n\n"; - $output = $this->slicExec( 'target', $this->dockerMockEnv(), $stdin ); - $this->assertStringContainsString( - 'Are you sure', + 'Are you sure you want to run these commands on', $output, 'The interactive target flow should prompt for confirmation before executing.' ); diff --git a/tests/Cli/WpCliTest.php b/tests/Cli/WpCliTest.php index 3bd217e..04e8b2a 100644 --- a/tests/Cli/WpCliTest.php +++ b/tests/Cli/WpCliTest.php @@ -92,4 +92,27 @@ public function test_site_cli_passes_commands_with_target(): void { 'The site-cli command should not produce an unknown command error.' ); } + + public function test_site_cli_install_decline_exits_without_running(): void { + $this->setUpPluginsDir(); + + $this->slicExec( 'use test-plugin', $this->dockerMockEnv() ); + + $output = $this->slicExec( + 'site-cli _install', + $this->dockerMockEnv(), + "no\n" + ); + + $this->assertStringContainsString( + 'Do you really want to run it?', + $output, + 'The _install subcommand should prompt for confirmation.' + ); + $this->assertStringNotContainsString( + '--admin_user=admin', + $output, + 'Declining should prevent the install command from running.' + ); + } }