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..b74139a 100644
--- a/tests/Cli/BaseTestCase.php
+++ b/tests/Cli/BaseTestCase.php
@@ -16,15 +16,27 @@ abstract class BaseTestCase extends TestCase {
private array $createdStackIds = [];
private static string $dockerMockBin = '';
+ 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';
+ self::$gitMockDir = dirname( __DIR__ ) . '/_support/bin/git-mock-dir';
}
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 {
@@ -39,19 +51,46 @@ public function tearDown(): void {
);
}
+ // Remove the temporary cache directory.
+ if ( $this->slicCacheDir !== '' && is_dir( $this->slicCacheDir ) ) {
+ $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.
*
- * @return string The command output.
+ * @return string The command output (stdout and stderr merged).
*/
- 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'] = $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';
+ }
+ // 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 ) {
@@ -60,8 +99,33 @@ 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' );
+ if ( $stdin !== null ) {
+ $descriptors = [
+ 0 => [ 'pipe', 'r' ],
+ 1 => [ 'pipe', 'w' ],
+ ];
+ // 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] );
+ fclose( $pipes[1] );
+ proc_close( $process );
+
+ return (string) $output;
+ }
+
+ // Close stdin to prevent interactive prompts from blocking, and redirect stderr to stdout.
+ return (string) shell_exec( $commandString . ' &1' );
}
/**
@@ -76,6 +140,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`.
*
@@ -97,4 +174,30 @@ 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 ),
+ \RecursiveIteratorIterator::CHILD_FIRST
+ );
+
+ foreach ( $items as $item ) {
+ $item->isDir() ? rmdir( $item->getPathname() ) : unlink( $item->getPathname() );
+ }
+
+ rmdir( $dir );
+ }
}
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..1f91673
--- /dev/null
+++ b/tests/Cli/CompletionTest.php
@@ -0,0 +1,160 @@
+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 {
+ // Use a clean HOME so is_installed() returns false, ensuring install instructions are shown.
+ $tempHome = $this->createTempDir();
+
+ $output = $this->slicExec( 'completion', [ 'HOME' => $tempHome ] );
+
+ $this->assertStringContainsString(
+ 'Detected shell:',
+ $output,
+ 'The output should show the detected shell.'
+ );
+
+ $this->assertStringContainsString(
+ 'completion install',
+ $output,
+ '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/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..55f2fca
--- /dev/null
+++ b/tests/Cli/TargetTest.php
@@ -0,0 +1,62 @@
+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.'
+ );
+ }
+
+ public function test_target_interactive_flow(): 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 (return when done):',
+ $output,
+ 'The interactive target flow should prompt for targets.'
+ );
+ $this->assertStringContainsString(
+ 'Targets:',
+ $output,
+ 'The output should display the "Targets:" label after collecting targets.'
+ );
+ $this->assertStringContainsString(
+ 'test-plugin',
+ $output,
+ 'The output should include the collected target name.'
+ );
+ $this->assertStringContainsString(
+ 'Command (return when done):',
+ $output,
+ 'The interactive target flow should prompt for commands.'
+ );
+ $this->assertStringContainsString(
+ '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/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..04e8b2a
--- /dev/null
+++ b/tests/Cli/WpCliTest.php
@@ -0,0 +1,118 @@
+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.'
+ );
+ }
+
+ 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.'
+ );
+ }
+}
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