diff --git a/config/synapse.php b/config/synapse.php index 1fda2c1..0b839af 100644 --- a/config/synapse.php +++ b/config/synapse.php @@ -167,6 +167,43 @@ ], ], + /** + * Tinker Configuration + * + * Configure the tinker tool for executing PHP code. + */ + 'tinker' => [ + /** + * PHP Binary Path + * + * Path to the PHP executable used for subprocess execution. + * When null, auto-detection is used in this order: + * 1. `which php` command + * 2. PHP_BINARY constant + * + * Examples: + * - null (auto-detect, recommended) + * - '/usr/bin/php' + * - '/usr/local/bin/php' + * - '/opt/homebrew/bin/php' + */ + 'php_binary' => env('MCP_TINKER_PHP_BINARY', null), + + /** + * Bin Path + * + * Path to the CakePHP bin directory containing the cake console. + * When null, auto-detection is used in this order: + * 1. ROOT constant + /bin + * 2. Current working directory + /bin + * + * Examples: + * - null (auto-detect, recommended) + * - '/var/www/myapp/bin' + */ + 'bin_path' => env('MCP_TINKER_BIN_PATH', null), + ], + /** * Prompt Configuration * diff --git a/rector.php b/rector.php index 295239b..179b858 100644 --- a/rector.php +++ b/rector.php @@ -3,6 +3,7 @@ use Rector\CodeQuality\Rector\If_\SimplifyIfElseToTernaryRector; use Rector\Config\RectorConfig; +use Rector\DeadCode\Rector\Assign\RemoveUnusedVariableAssignRector; use Rector\Strict\Rector\Empty_\DisallowedEmptyRuleFixerRector; use Rector\TypeDeclaration\Rector\ClassMethod\ReturnTypeFromStrictFluentReturnRector; use Rector\ValueObject\PhpVersion; diff --git a/src/Command/IndexDocsCommand.php b/src/Command/IndexDocsCommand.php index 1ba723e..74a4934 100644 --- a/src/Command/IndexDocsCommand.php +++ b/src/Command/IndexDocsCommand.php @@ -25,6 +25,14 @@ public static function defaultName(): string return 'synapse index'; } + /** + * @inheritDoc + */ + public static function getDescription(): string + { + return 'Index documentation for full-text search'; + } + /** * Configure command options * diff --git a/src/Command/SearchDocsCommand.php b/src/Command/SearchDocsCommand.php index f9fd2b0..abbf80f 100644 --- a/src/Command/SearchDocsCommand.php +++ b/src/Command/SearchDocsCommand.php @@ -27,6 +27,14 @@ public static function defaultName(): string return 'synapse search'; } + /** + * @inheritDoc + */ + public static function getDescription(): string + { + return 'Search CakePHP documentation'; + } + /** * Configure command options * diff --git a/src/Command/ServerCommand.php b/src/Command/ServerCommand.php index ed5cbfb..7aa1b37 100644 --- a/src/Command/ServerCommand.php +++ b/src/Command/ServerCommand.php @@ -32,6 +32,14 @@ public static function defaultName(): string return 'synapse server'; } + /** + * @inheritDoc + */ + public static function getDescription(): string + { + return 'Start the MCP (Model Context Protocol) server'; + } + /** * Constructor * diff --git a/src/Command/TinkerEvalCommand.php b/src/Command/TinkerEvalCommand.php new file mode 100644 index 0000000..784e87e --- /dev/null +++ b/src/Command/TinkerEvalCommand.php @@ -0,0 +1,206 @@ +setDescription( + 'Execute PHP code in the CakePHP application context. ' . + 'This command is intended for internal use by TinkerTools subprocess execution.', + ) + ->addOption('timeout', [ + 'short' => 't', + 'help' => 'Maximum execution time in seconds', + 'default' => '30', + ]); + + return $parser; + } + + /** + * Execute the command + * + * Reads PHP code from stdin, executes it + * and outputs results as JSON to stdout. + * + * @param \Cake\Console\Arguments $args Command arguments + * @param \Cake\Console\ConsoleIo $io Console I/O + * @return int Exit code + */ + public function execute(Arguments $args, ConsoleIo $io): int + { + // phpcs:disable Squiz.PHP.Eval.Discouraged + + // Read code from stdin + $code = file_get_contents('php://stdin'); + + if ($code === false || trim($code) === '') { + $this->outputJson($io, [ + 'success' => false, + 'error' => 'No code provided via stdin', + 'type' => 'InvalidArgumentException', + ]); + + return static::CODE_ERROR; + } + + // Parse and validate timeout + $timeout = (int)$args->getOption('timeout'); + $timeout = min(max(1, $timeout), 180); + + // Set execution limits + ini_set('memory_limit', '256M'); + set_time_limit($timeout); + + // Strip PHP tags from code + $code = str_replace([''], '', $code); + + // Capture output + ob_start(); + + try { + $result = eval($code); + $output = ob_get_contents(); + + $response = [ + 'success' => true, + 'result' => $this->serializeResult($result), + 'output' => $output ?: null, + 'type' => get_debug_type($result), + ]; + + // Include class name for objects + if (is_object($result)) { + $response['class'] = $result::class; + } + + // Include array count + if (is_array($result)) { + $response['count'] = count($result); + } + + $this->outputJson($io, $response); + + return static::CODE_SUCCESS; + } catch (Throwable $throwable) { + ob_end_clean(); + + $this->outputJson($io, [ + 'success' => false, + 'error' => $throwable->getMessage(), + 'type' => $throwable::class, + 'file' => $throwable->getFile(), + 'line' => $throwable->getLine(), + 'trace' => $throwable->getTraceAsString(), + ]); + + return static::CODE_ERROR; + } finally { + if (ob_get_level() > 0) { + ob_end_clean(); + } + } + + // phpcs:enable Squiz.PHP.Eval.Discouraged + } + + /** + * Serialize a result value for JSON output. + * + * Handles objects by converting them to array representation + * since many objects aren't directly JSON serializable. + * + * @param mixed $result The result to serialize + * @return mixed Serialized result + */ + private function serializeResult(mixed $result): mixed + { + if ($result === null || is_scalar($result)) { + return $result; + } + + if (is_array($result)) { + return array_map([$this, 'serializeResult'], $result); + } + + if (is_object($result)) { + // Try to convert to array if possible + if (method_exists($result, 'toArray')) { + return $result->toArray(); + } + + if (method_exists($result, 'jsonSerialize')) { + return $result->jsonSerialize(); + } + + if (method_exists($result, '__toString')) { + return (string)$result; + } + + // Fallback: return class info and public properties + return [ + '__class' => $result::class, + '__properties' => get_object_vars($result), + ]; + } + + if (is_resource($result)) { + return [ + '__type' => 'resource', + '__resource_type' => get_resource_type($result), + ]; + } + + return null; + } + + /** + * Output JSON response to stdout. + * + * @param \Cake\Console\ConsoleIo $io Console I/O + * @param array $data Data to output as JSON + */ + private function outputJson(ConsoleIo $io, array $data): void + { + // Use out() with no newline formatting, raw output + $io->out(json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + } +} diff --git a/src/Tools/TinkerTools.php b/src/Tools/TinkerTools.php index 5eb2f9e..bb0ac58 100644 --- a/src/Tools/TinkerTools.php +++ b/src/Tools/TinkerTools.php @@ -3,8 +3,7 @@ namespace Synapse\Tools; -use Cake\Log\LogTrait; -use Cake\ORM\Locator\LocatorAwareTrait; +use Cake\Core\Configure; use Mcp\Capability\Attribute\McpTool; use Throwable; @@ -12,19 +11,25 @@ * Tinker Tools * * Execute PHP code in the CakePHP application context for debugging and testing. + * Code is executed in a subprocess to ensure the latest code from disk is loaded. */ class TinkerTools { - use LocatorAwareTrait; - use LogTrait; + /** + * Path to the PHP executable (cached) + */ + private ?string $phpBinary = null; + + /** + * Path to the CakePHP bin directory (cached) + */ + private ?string $binPath = null; /** * Execute PHP code in the application context. * - * Similar to bin/cake console, this allows execution of arbitrary PHP code - * with access to the full CakePHP application context including models, - * configuration, and helpers. The $this context is available within the - * executed code, providing access to fetchTable(), log(), and other trait methods. + * Code executes in a subprocess that loads the latest code from disk. + * Use $context->fetchTable(), $context->log(), etc. for CakePHP functionality. * * @param string $code PHP code to execute (without opening fetchTable() and $this->log() for ORM and logging access.', )] public function execute(string $code, int $timeout = 30): array { - // phpcs:disable Squiz.PHP.Eval.Discouraged // Validate timeout bounds $timeout = min(max(1, $timeout), 180); - // Strip PHP tags - $code = str_replace([''], '', $code); + $phpBinary = $this->getPhpBinary(); + $binPath = $this->getBinPath(); - // Set memory and time limits - ini_set('memory_limit', '256M'); - set_time_limit($timeout); + if ($phpBinary === null) { + return [ + 'success' => false, + 'error' => 'Could not find PHP binary. Configure Synapse.tinker.php_binary or ensure php is in PATH.', + 'type' => 'RuntimeException', + ]; + } - // Capture output - ob_start(); + // The command uses bin/cake.php directly (not the shell wrapper) + $command = sprintf( + '%s bin/cake.php synapse tinker_eval --timeout %d', + escapeshellarg($phpBinary), + $timeout, + ); - try { - // Execute code in CakePHP application context - $result = eval($code); + $descriptors = [ + 0 => ['pipe', 'r'], // stdin + 1 => ['pipe', 'w'], // stdout + 2 => ['pipe', 'w'], // stderr + ]; - $output = ob_get_contents(); + // Set working directory to application root (parent of bin directory) + $cwd = dirname($binPath); - $response = [ - 'result' => $result, - 'output' => $output ?: null, - 'type' => get_debug_type($result), - 'success' => true, + $process = proc_open($command, $descriptors, $pipes, $cwd); + + if (!is_resource($process)) { + return [ + 'success' => false, + 'error' => 'Failed to start subprocess', + 'type' => 'RuntimeException', ]; + } + + try { + // Write code to stdin + fwrite($pipes[0], $code); + fclose($pipes[0]); + + // Set stream to non-blocking for timeout handling + stream_set_blocking($pipes[1], false); + stream_set_blocking($pipes[2], false); + + $stdout = ''; + $stderr = ''; + $startTime = time(); + + // Read output with timeout handling + while (true) { + $status = proc_get_status($process); - // Include class name for objects - if (is_object($result)) { - $response['class'] = $result::class; + // Check if process has exited + if (!$status['running']) { + // Read any remaining output + $stdout .= stream_get_contents($pipes[1]); + $stderr .= stream_get_contents($pipes[2]); + break; + } + + // Check timeout + if (time() - $startTime > $timeout) { + proc_terminate($process, 9); + + return [ + 'success' => false, + 'error' => sprintf('Execution timed out after %d seconds', $timeout), + 'type' => 'RuntimeException', + ]; + } + + // Read available output + $stdout .= fread($pipes[1], 8192) ?: ''; + $stderr .= fread($pipes[2], 8192) ?: ''; + + // Small sleep to prevent CPU spinning + usleep(10000); // 10ms + } + + fclose($pipes[1]); + fclose($pipes[2]); + + $exitCode = proc_close($process); + + // Parse JSON response from stdout + $stdout = trim($stdout); + + if ($stdout === '') { + return [ + 'success' => false, + 'error' => $stderr ?: 'No output from subprocess', + 'type' => 'RuntimeException', + 'exit_code' => $exitCode, + ]; } - // Include array count - if (is_array($result)) { - $response['count'] = count($result); + $result = json_decode($stdout, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + return [ + 'success' => false, + 'error' => 'Failed to parse subprocess output: ' . json_last_error_msg(), + 'type' => 'RuntimeException', + 'raw_output' => $stdout, + 'stderr' => $stderr ?: null, + ]; } - return $response; + return $result; } catch (Throwable $throwable) { + // Ensure process is cleaned up + if (is_resource($process)) { + proc_terminate($process); + proc_close($process); + } + return [ 'success' => false, 'error' => $throwable->getMessage(), 'type' => $throwable::class, - 'file' => $throwable->getFile(), - 'line' => $throwable->getLine(), - 'trace' => $throwable->getTraceAsString(), ]; - } finally { - ob_end_clean(); } + } + + /** + * Get the PHP binary path. + * + * Resolution order: + * 1. Explicitly set via setPhpBinary() + * 2. Configuration: Synapse.tinker.php_binary + * 3. `which php` command + * 4. PHP_BINARY constant + * + * @return string|null Path to PHP binary or null if not found + */ + public function getPhpBinary(): ?string + { + if ($this->phpBinary !== null) { + return $this->phpBinary; + } + + // Check configuration + $configured = Configure::read('Synapse.tinker.php_binary'); + if ($configured !== null && is_string($configured) && is_executable($configured)) { + return $configured; + } + + // Try `which php` command (most reliable for finding the active PHP) + $whichResult = shell_exec('which php 2>/dev/null'); + if (is_string($whichResult) && trim($whichResult) !== '') { + $which = trim($whichResult); + if (is_executable($which)) { + return $which; + } + } + + // Fallback to PHP_BINARY constant (always defined and non-empty in PHP 8.2+) + if (is_executable(PHP_BINARY)) { + return PHP_BINARY; + } + + return null; + } + + /** + * Get the CakePHP bin directory path. + * + * Resolution order: + * 1. Explicitly set via setBinPath() + * 2. Configuration: Synapse.tinker.bin_path + * 3. ROOT constant + /bin + * 4. Current working directory + /bin + * + * @return string Path to bin directory + */ + public function getBinPath(): string + { + if ($this->binPath !== null) { + return $this->binPath; + } + + // Check configuration + $configured = Configure::read('Synapse.tinker.bin_path'); + if ($configured !== null && is_string($configured)) { + return $configured; + } + + // Check if ROOT constant is defined (CakePHP app) + if (defined('ROOT')) { + return ROOT . '/bin'; + } + + // Fallback to current working directory + return getcwd() . '/bin'; + } + + /** + * Set the PHP binary path. + * + * @param string|null $path Path to PHP binary + */ + public function setPhpBinary(?string $path): static + { + $this->phpBinary = $path; + + return $this; + } + + /** + * Set the bin directory path. + * + * @param string|null $path Path to bin directory + */ + public function setBinPath(?string $path): static + { + $this->binPath = $path; - // phpcs:enable Squiz.PHP.Eval.Discouraged + return $this; } } diff --git a/tests/TestCase/Command/ServerCommandTest.php b/tests/TestCase/Command/ServerCommandTest.php index c77a69f..feec31a 100644 --- a/tests/TestCase/Command/ServerCommandTest.php +++ b/tests/TestCase/Command/ServerCommandTest.php @@ -6,6 +6,9 @@ use Cake\Console\TestSuite\ConsoleIntegrationTestTrait; use Cake\Core\Configure; use Cake\TestSuite\TestCase; +use ReflectionMethod; +use Synapse\Command\ServerCommand; +use TestApp\Application; /** * ServerCommand Test Case @@ -308,4 +311,86 @@ public function testInspectOptionWithOtherOptions(): void $this->exec('synapse server -i -v -n -h'); $this->assertExitSuccess(); } + + // ========================================================================= + // findExecutable Tests (via reflection) + // ========================================================================= + + /** + * Test findExecutable finds php binary + */ + public function testFindExecutableFindsPhp(): void + { + $command = $this->createServerCommand(); + $method = new ReflectionMethod(ServerCommand::class, 'findExecutable'); + + $result = $method->invoke($command, 'php'); + + $this->assertNotNull($result); + $this->assertFileExists($result); + $this->assertTrue(is_executable($result)); + } + + /** + * Test findExecutable returns null for nonexistent executable + */ + public function testFindExecutableReturnsNullForNonexistent(): void + { + $command = $this->createServerCommand(); + $method = new ReflectionMethod(ServerCommand::class, 'findExecutable'); + + $result = $method->invoke($command, 'definitely_not_a_real_executable_12345'); + + $this->assertNull($result); + } + + /** + * Test findExecutable finds common executables + */ + public function testFindExecutableFindsCommonExecutables(): void + { + $command = $this->createServerCommand(); + $method = new ReflectionMethod(ServerCommand::class, 'findExecutable'); + + // Test with 'which' or 'ls' - common on Unix systems + $result = $method->invoke($command, 'ls'); + + if ($result !== null) { + $this->assertFileExists($result); + $this->assertTrue(is_executable($result)); + } + + // If null, the test passes - executable simply wasn't found + } + + /** + * Test defaultName returns correct command name + */ + public function testDefaultName(): void + { + $this->assertEquals('synapse server', ServerCommand::defaultName()); + } + + /** + * Test getDescription returns appropriate description + */ + public function testGetDescription(): void + { + $description = ServerCommand::getDescription(); + + $this->assertStringContainsString('MCP', $description); + $this->assertStringContainsString('server', $description); + } + + /** + * Create a ServerCommand instance for testing + */ + private function createServerCommand(): ServerCommand + { + // Get the container from the application + $app = new Application(CONFIG); + $container = $app->getContainer(); + + return new ServerCommand($container); + } } diff --git a/tests/TestCase/Command/TinkerEvalCommandTest.php b/tests/TestCase/Command/TinkerEvalCommandTest.php new file mode 100644 index 0000000..2bbee96 --- /dev/null +++ b/tests/TestCase/Command/TinkerEvalCommandTest.php @@ -0,0 +1,824 @@ +command = new TinkerEvalCommand(); + } + + /** + * tearDown method + */ + protected function tearDown(): void + { + unset($this->command); + parent::tearDown(); + } + + // ========================================================================= + // Static Method Tests + // ========================================================================= + + /** + * Test defaultName returns correct command name + */ + public function testDefaultName(): void + { + $this->assertEquals('synapse tinker_eval', TinkerEvalCommand::defaultName()); + } + + /** + * Test getDescription returns appropriate description + */ + public function testGetDescription(): void + { + $description = TinkerEvalCommand::getDescription(); + + $this->assertStringContainsString('Execute PHP code', $description); + $this->assertStringContainsString('CakePHP application context', $description); + } + + // ========================================================================= + // Option Parser Tests + // ========================================================================= + + /** + * Test command help shows timeout option + */ + public function testCommandHelp(): void + { + $this->exec('synapse tinker_eval --help'); + + $this->assertExitSuccess(); + $this->assertOutputContains('Execute PHP code'); + $this->assertOutputContains('--timeout'); + $this->assertOutputContains('Maximum execution time'); + } + + /** + * Test buildOptionParser configures timeout option correctly + */ + public function testBuildOptionParserHasTimeoutOption(): void + { + $this->exec('synapse tinker_eval --help'); + + $this->assertExitSuccess(); + $this->assertOutputContains('-t'); + $this->assertOutputContains('--timeout'); + } + + // ========================================================================= + // serializeResult Tests (via reflection) + // ========================================================================= + + /** + * Test serializeResult with null + */ + public function testSerializeResultWithNull(): void + { + $result = $this->invokeSerializeResult(null); + + $this->assertNull($result); + } + + /** + * Test serializeResult with scalar values + */ + public function testSerializeResultWithScalars(): void + { + $this->assertEquals(42, $this->invokeSerializeResult(42)); + $this->assertEquals(3.14, $this->invokeSerializeResult(3.14)); + $this->assertEquals('hello', $this->invokeSerializeResult('hello')); + $this->assertTrue($this->invokeSerializeResult(true)); + $this->assertFalse($this->invokeSerializeResult(false)); + } + + /** + * Test serializeResult with simple array + */ + public function testSerializeResultWithArray(): void + { + $result = $this->invokeSerializeResult([1, 2, 3]); + + $this->assertEquals([1, 2, 3], $result); + } + + /** + * Test serializeResult with nested array + */ + public function testSerializeResultWithNestedArray(): void + { + $input = ['a' => 1, 'b' => ['c' => 2, 'd' => [3, 4]]]; + $result = $this->invokeSerializeResult($input); + + $this->assertEquals($input, $result); + } + + /** + * Test serializeResult with array containing objects + */ + public function testSerializeResultWithArrayContainingObjects(): void + { + $obj = new stdClass(); + $obj->name = 'test'; + + $result = $this->invokeSerializeResult([$obj, 'string', 42]); + + $this->assertIsArray($result); + $this->assertEquals('stdClass', $result[0]['__class']); + $this->assertEquals('test', $result[0]['__properties']['name']); + $this->assertEquals('string', $result[1]); + $this->assertEquals(42, $result[2]); + } + + /** + * Test serializeResult with object having toArray method + */ + public function testSerializeResultWithToArrayObject(): void + { + $obj = new class { + /** + * @return array + */ + public function toArray(): array + { + return ['key' => 'value', 'number' => 123]; + } + }; + + $result = $this->invokeSerializeResult($obj); + + $this->assertEquals(['key' => 'value', 'number' => 123], $result); + } + + /** + * Test serializeResult with JsonSerializable object + */ + public function testSerializeResultWithJsonSerializableObject(): void + { + $obj = new class implements JsonSerializable { + /** + * @return array + */ + public function jsonSerialize(): array + { + return ['serialized' => true, 'data' => 'test']; + } + }; + + $result = $this->invokeSerializeResult($obj); + + $this->assertEquals(['serialized' => true, 'data' => 'test'], $result); + } + + /** + * Test serializeResult with Stringable object + */ + public function testSerializeResultWithStringableObject(): void + { + $obj = new class implements Stringable { + public function __toString(): string + { + return 'string representation'; + } + }; + + $result = $this->invokeSerializeResult($obj); + + $this->assertEquals('string representation', $result); + } + + /** + * Test serializeResult with object having __toString method (non-interface) + */ + public function testSerializeResultWithToStringMethod(): void + { + $obj = new class { + public function __toString(): string + { + return 'custom string'; + } + }; + + $result = $this->invokeSerializeResult($obj); + + $this->assertEquals('custom string', $result); + } + + /** + * Test serializeResult with plain stdClass object (fallback) + */ + public function testSerializeResultWithStdClassFallback(): void + { + $obj = new stdClass(); + $obj->foo = 'bar'; + $obj->baz = 123; + + $result = $this->invokeSerializeResult($obj); + + $this->assertIsArray($result); + $this->assertEquals('stdClass', $result['__class']); + $this->assertEquals('bar', $result['__properties']['foo']); + $this->assertEquals(123, $result['__properties']['baz']); + } + + /** + * Test serializeResult with resource + */ + public function testSerializeResultWithResource(): void + { + $resource = fopen('php://memory', 'r'); + $this->assertIsResource($resource); + + $result = $this->invokeSerializeResult($resource); + + fclose($resource); + + $this->assertIsArray($result); + $this->assertEquals('resource', $result['__type']); + $this->assertEquals('stream', $result['__resource_type']); + } + + /** + * Test serializeResult prioritizes toArray over jsonSerialize + */ + public function testSerializeResultPrioritizesToArray(): void + { + $obj = new class implements JsonSerializable { + /** + * @return array + */ + public function toArray(): array + { + return ['from' => 'toArray']; + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return ['from' => 'jsonSerialize']; + } + }; + + $result = $this->invokeSerializeResult($obj); + + $this->assertEquals(['from' => 'toArray'], $result); + } + + /** + * Test serializeResult prioritizes jsonSerialize over __toString + */ + public function testSerializeResultPrioritizesJsonSerialize(): void + { + $obj = new class implements JsonSerializable { + /** + * @return array + */ + public function jsonSerialize(): array + { + return ['from' => 'jsonSerialize']; + } + + public function __toString(): string + { + return 'from __toString'; + } + }; + + $result = $this->invokeSerializeResult($obj); + + $this->assertEquals(['from' => 'jsonSerialize'], $result); + } + + // ========================================================================= + // Subprocess Integration Tests + // ========================================================================= + + /** + * Test execution with simple expression via stdin simulation + */ + public function testExecuteSimpleExpression(): void + { + $result = $this->executeInSubprocess('return 1 + 1;'); + + $this->assertEquals(0, $result['exitCode']); + $this->assertTrue($result['json']['success']); + $this->assertEquals(2, $result['json']['result']); + $this->assertEquals('int', $result['json']['type']); + } + + /** + * Test execution with array return value + */ + public function testExecuteReturnsArray(): void + { + $result = $this->executeInSubprocess('return [1, 2, 3];'); + + $this->assertEquals(0, $result['exitCode']); + $this->assertTrue($result['json']['success']); + $this->assertEquals([1, 2, 3], $result['json']['result']); + $this->assertEquals('array', $result['json']['type']); + $this->assertEquals(3, $result['json']['count']); + } + + /** + * Test execution with output capture + */ + public function testExecuteWithOutput(): void + { + $result = $this->executeInSubprocess('echo "Hello World"; return "done";'); + + $this->assertTrue($result['json']['success']); + $this->assertEquals('done', $result['json']['result']); + $this->assertStringContainsString('Hello World', $result['json']['output']); + } + + /** + * Test execution with context access + */ + public function testExecuteWithContextAccess(): void + { + $result = $this->executeInSubprocess('$table = $this->fetchTable("Users"); return $table::class;'); + + $this->assertTrue($result['json']['success']); + $this->assertStringContainsString('Table', $result['json']['result']); + } + + /** + * Test execution with exception + */ + public function testExecuteWithException(): void + { + $result = $this->executeInSubprocess('throw new \Exception("Test error");'); + + $this->assertEquals(1, $result['exitCode']); + $this->assertFalse($result['json']['success']); + $this->assertEquals('Test error', $result['json']['error']); + $this->assertEquals('Exception', $result['json']['type']); + $this->assertArrayHasKey('trace', $result['json']); + $this->assertArrayHasKey('file', $result['json']); + $this->assertArrayHasKey('line', $result['json']); + } + + /** + * Test execution with syntax error + */ + public function testExecuteWithSyntaxError(): void + { + $result = $this->executeInSubprocess('invalid php code here;'); + + $this->assertEquals(1, $result['exitCode']); + $this->assertFalse($result['json']['success']); + $this->assertArrayHasKey('error', $result['json']); + } + + /** + * Test execution with no input returns error + */ + public function testExecuteWithNoInput(): void + { + $result = $this->executeInSubprocess(''); + + $this->assertEquals(1, $result['exitCode']); + $this->assertFalse($result['json']['success']); + $this->assertStringContainsString('No code provided', $result['json']['error']); + $this->assertEquals('InvalidArgumentException', $result['json']['type']); + } + + /** + * Test execution with whitespace-only input returns error + */ + public function testExecuteWithWhitespaceOnlyInput(): void + { + $result = $this->executeInSubprocess(" \n\t "); + + $this->assertEquals(1, $result['exitCode']); + $this->assertFalse($result['json']['success']); + $this->assertStringContainsString('No code provided', $result['json']['error']); + } + + /** + * Test execution with timeout option + */ + public function testExecuteWithTimeoutOption(): void + { + $result = $this->executeInSubprocess('return "ok";', ['--timeout', '60']); + + $this->assertEquals(0, $result['exitCode']); + $this->assertTrue($result['json']['success']); + $this->assertEquals('ok', $result['json']['result']); + } + + /** + * Test execution with short timeout option + */ + public function testExecuteWithShortTimeoutOption(): void + { + $result = $this->executeInSubprocess('return "ok";', ['-t', '45']); + + $this->assertEquals(0, $result['exitCode']); + $this->assertTrue($result['json']['success']); + } + + /** + * Test execution strips PHP tags + */ + public function testExecuteStripsPhpTags(): void + { + $result = $this->executeInSubprocess(''); + + $this->assertTrue($result['json']['success']); + $this->assertEquals(42, $result['json']['result']); + } + + /** + * Test execution strips short PHP tags + */ + public function testExecuteStripsShortPhpTags(): void + { + $result = $this->executeInSubprocess(''); + + $this->assertTrue($result['json']['success']); + $this->assertEquals(42, $result['json']['result']); + } + + /** + * Test execution with object serialization + */ + public function testExecuteSerializesObjects(): void + { + $result = $this->executeInSubprocess('$obj = new \stdClass(); $obj->foo = "bar"; return $obj;'); + + $this->assertTrue($result['json']['success']); + $this->assertEquals('stdClass', $result['json']['type']); + $this->assertEquals('stdClass', $result['json']['class']); + $this->assertIsArray($result['json']['result']); + $this->assertEquals('stdClass', $result['json']['result']['__class']); + $this->assertEquals('bar', $result['json']['result']['__properties']['foo']); + } + + /** + * Test execution returns null type correctly + */ + public function testExecuteReturnsNullType(): void + { + $result = $this->executeInSubprocess('return null;'); + + $this->assertTrue($result['json']['success']); + $this->assertNull($result['json']['result']); + $this->assertEquals('null', $result['json']['type']); + } + + /** + * Test execution with boolean return values + */ + public function testExecuteReturnsBooleans(): void + { + $resultTrue = $this->executeInSubprocess('return true;'); + $this->assertTrue($resultTrue['json']['success']); + $this->assertTrue($resultTrue['json']['result']); + $this->assertEquals('bool', $resultTrue['json']['type']); + + $resultFalse = $this->executeInSubprocess('return false;'); + $this->assertTrue($resultFalse['json']['success']); + $this->assertFalse($resultFalse['json']['result']); + $this->assertEquals('bool', $resultFalse['json']['type']); + } + + /** + * Test execution with float return value + */ + public function testExecuteReturnsFloat(): void + { + $result = $this->executeInSubprocess('return 3.14159;'); + + $this->assertTrue($result['json']['success']); + $this->assertEquals(3.14159, $result['json']['result']); + $this->assertEquals('float', $result['json']['type']); + } + + /** + * Test execution with string return value + */ + public function testExecuteReturnsString(): void + { + $result = $this->executeInSubprocess('return "hello world";'); + + $this->assertTrue($result['json']['success']); + $this->assertEquals('hello world', $result['json']['result']); + $this->assertEquals('string', $result['json']['type']); + } + + /** + * Test execution with no return statement + */ + public function testExecuteWithNoReturn(): void + { + $result = $this->executeInSubprocess('$x = 1 + 1;'); + + $this->assertTrue($result['json']['success']); + $this->assertNull($result['json']['result']); + $this->assertEquals('null', $result['json']['type']); + } + + /** + * Test execution with multiple statements + */ + public function testExecuteWithMultipleStatements(): void + { + $code = '$a = 10; $b = 20; $c = $a + $b; return $c * 2;'; + $result = $this->executeInSubprocess($code); + + $this->assertTrue($result['json']['success']); + $this->assertEquals(60, $result['json']['result']); + } + + /** + * Test execution with multiline code + */ + public function testExecuteWithMultilineCode(): void + { + $code = <<<'PHP' +$numbers = [1, 2, 3, 4, 5]; +$sum = array_sum($numbers); +$avg = $sum / count($numbers); +return ['sum' => $sum, 'avg' => $avg]; +PHP; + $result = $this->executeInSubprocess($code); + + $this->assertTrue($result['json']['success']); + $this->assertEquals(['sum' => 15, 'avg' => 3], $result['json']['result']); + } + + /** + * Test execution captures multiple echo statements + */ + public function testExecuteCapturesMultipleOutputs(): void + { + $result = $this->executeInSubprocess('echo "Line 1\n"; echo "Line 2\n"; print "Line 3"; return "done";'); + + $this->assertTrue($result['json']['success']); + $this->assertStringContainsString('Line 1', $result['json']['output']); + $this->assertStringContainsString('Line 2', $result['json']['output']); + $this->assertStringContainsString('Line 3', $result['json']['output']); + } + + /** + * Test execution with object having toArray method + */ + public function testExecuteSerializesToArrayObject(): void + { + $code = <<<'PHP' +return new class { + public function toArray(): array { + return ['converted' => 'toArray']; + } +}; +PHP; + $result = $this->executeInSubprocess($code); + + $this->assertTrue($result['json']['success']); + $this->assertEquals(['converted' => 'toArray'], $result['json']['result']); + } + + /** + * Test execution with JsonSerializable object + */ + public function testExecuteSerializesJsonSerializableObject(): void + { + $code = <<<'PHP' +return new class implements \JsonSerializable { + public function jsonSerialize(): array { + return ['json' => 'serializable']; + } +}; +PHP; + $result = $this->executeInSubprocess($code); + + $this->assertTrue($result['json']['success']); + $this->assertEquals(['json' => 'serializable'], $result['json']['result']); + } + + /** + * Test execution with Stringable object + */ + public function testExecuteSerializesStringableObject(): void + { + $code = <<<'PHP' +return new class implements \Stringable { + public function __toString(): string { + return 'stringable object'; + } +}; +PHP; + $result = $this->executeInSubprocess($code); + + $this->assertTrue($result['json']['success']); + $this->assertEquals('stringable object', $result['json']['result']); + } + + /** + * Test execution with nested objects in array + */ + public function testExecuteSerializesNestedObjectsInArray(): void + { + $code = <<<'PHP' +$obj1 = new \stdClass(); +$obj1->name = 'first'; +$obj2 = new \stdClass(); +$obj2->name = 'second'; +return ['objects' => [$obj1, $obj2], 'count' => 2]; +PHP; + $result = $this->executeInSubprocess($code); + + $this->assertTrue($result['json']['success']); + $this->assertIsArray($result['json']['result']); + $this->assertEquals(2, $result['json']['result']['count']); + $this->assertEquals('first', $result['json']['result']['objects'][0]['__properties']['name']); + $this->assertEquals('second', $result['json']['result']['objects'][1]['__properties']['name']); + } + + /** + * Test execution with RuntimeException + */ + public function testExecuteWithRuntimeException(): void + { + $result = $this->executeInSubprocess('throw new \RuntimeException("Runtime error");'); + + $this->assertEquals(1, $result['exitCode']); + $this->assertFalse($result['json']['success']); + $this->assertEquals('Runtime error', $result['json']['error']); + $this->assertEquals('RuntimeException', $result['json']['type']); + } + + /** + * Test execution with custom exception + */ + public function testExecuteWithInvalidArgumentException(): void + { + $result = $this->executeInSubprocess('throw new \InvalidArgumentException("Invalid arg");'); + + $this->assertEquals(1, $result['exitCode']); + $this->assertFalse($result['json']['success']); + $this->assertEquals('Invalid arg', $result['json']['error']); + $this->assertEquals('InvalidArgumentException', $result['json']['type']); + } + + /** + * Test execution error includes file information + */ + public function testExecuteErrorIncludesFileInfo(): void + { + $result = $this->executeInSubprocess('throw new \Exception("Test");'); + + $this->assertFalse($result['json']['success']); + $this->assertArrayHasKey('file', $result['json']); + $this->assertArrayHasKey('line', $result['json']); + $this->assertIsString($result['json']['file']); + $this->assertIsInt($result['json']['line']); + } + + /** + * Test execution error trace is string + */ + public function testExecuteErrorTraceIsString(): void + { + $result = $this->executeInSubprocess('throw new \Exception("Test");'); + + $this->assertFalse($result['json']['success']); + $this->assertArrayHasKey('trace', $result['json']); + $this->assertIsString($result['json']['trace']); + $this->assertNotEmpty($result['json']['trace']); + } + + /** + * Test execution with empty array + */ + public function testExecuteReturnsEmptyArray(): void + { + $result = $this->executeInSubprocess('return [];'); + + $this->assertTrue($result['json']['success']); + $this->assertEquals([], $result['json']['result']); + $this->assertEquals('array', $result['json']['type']); + $this->assertEquals(0, $result['json']['count']); + } + + /** + * Test execution with associative array + */ + public function testExecuteReturnsAssociativeArray(): void + { + $result = $this->executeInSubprocess('return ["name" => "test", "value" => 42];'); + + $this->assertTrue($result['json']['success']); + $this->assertEquals(['name' => 'test', 'value' => 42], $result['json']['result']); + $this->assertEquals(2, $result['json']['count']); + } + + /** + * Test output is null when nothing is echoed + */ + public function testExecuteOutputIsNullWhenEmpty(): void + { + $result = $this->executeInSubprocess('return 42;'); + + $this->assertTrue($result['json']['success']); + $this->assertNull($result['json']['output']); + } + + // ========================================================================= + // Helper Methods + // ========================================================================= + + /** + * Invoke the private serializeResult method via reflection + * + * @param mixed $value Value to serialize + * @return mixed Serialized result + */ + private function invokeSerializeResult(mixed $value): mixed + { + $method = new ReflectionMethod(TinkerEvalCommand::class, 'serializeResult'); + + return $method->invoke($this->command, $value); + } + + /** + * Execute code in subprocess and return parsed result + * + * @param string $code PHP code to execute + * @param array $options Additional command options + * @return array{exitCode: int, stdout: string, stderr: string, json: array} + */ + private function executeInSubprocess(string $code, array $options = []): array + { + $descriptors = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + $command = PHP_BINARY . ' bin/cake.php synapse tinker_eval'; + if ($options !== []) { + $command .= ' ' . implode(' ', $options); + } + + $process = proc_open($command, $descriptors, $pipes, ROOT); + + $this->assertIsResource($process); + + fwrite($pipes[0], $code); + fclose($pipes[0]); + + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + + $exitCode = proc_close($process); + + $json = null; + if ($stdout !== false && $stdout !== '') { + $json = json_decode($stdout, true); + } + + return [ + 'exitCode' => $exitCode, + 'stdout' => $stdout ?: '', + 'stderr' => $stderr ?: '', + 'json' => $json ?? [], + ]; + } +} diff --git a/tests/TestCase/Tools/TinkerToolsTest.php b/tests/TestCase/Tools/TinkerToolsTest.php index af82173..51b14bf 100644 --- a/tests/TestCase/Tools/TinkerToolsTest.php +++ b/tests/TestCase/Tools/TinkerToolsTest.php @@ -3,16 +3,15 @@ namespace Synapse\Test\TestCase\Tools; -use Cake\Log\Engine\ArrayLog; -use Cake\Log\Log; +use Cake\Core\Configure; use Cake\TestSuite\TestCase; -use stdClass; use Synapse\Tools\TinkerTools; /** * TinkerTools Test Case * - * Tests the tinker MCP tool for executing PHP code. + * Tests the tinker MCP tool for executing PHP code in subprocess mode. + * Code is always executed in a subprocess to ensure the latest code from disk is loaded. */ class TinkerToolsTest extends TestCase { @@ -40,6 +39,10 @@ protected function tearDown(): void parent::tearDown(); } + // ========================================================================= + // Basic Execution Tests + // ========================================================================= + /** * Test execute with simple expression */ @@ -50,7 +53,6 @@ public function testExecuteSimpleExpression(): void $this->assertTrue($result['success']); $this->assertEquals(2, $result['result']); $this->assertEquals('int', $result['type']); - $this->assertNull($result['output']); } /** @@ -62,7 +64,7 @@ public function testExecuteWithOutput(): void $this->assertTrue($result['success']); $this->assertEquals('World', $result['result']); - $this->assertEquals('Hello', $result['output']); + $this->assertStringContainsString('Hello', $result['output']); $this->assertEquals('string', $result['type']); } @@ -81,16 +83,19 @@ public function testExecuteReturnsArray(): void } /** - * Test execute returns object with class name + * Test execute returns object serialized + * + * Objects are serialized for JSON transport */ public function testExecuteReturnsObject(): void { $result = $this->tinkerTools->execute('return new \stdClass();'); $this->assertTrue($result['success']); - $this->assertInstanceOf(stdClass::class, $result['result']); $this->assertEquals('stdClass', $result['type']); $this->assertEquals('stdClass', $result['class']); + // Object is serialized as array with __class and __properties + $this->assertIsArray($result['result']); } /** @@ -103,8 +108,6 @@ public function testExecuteWithSyntaxError(): void $this->assertFalse($result['success']); $this->assertArrayHasKey('error', $result); $this->assertArrayHasKey('type', $result); - $this->assertArrayHasKey('file', $result); - $this->assertArrayHasKey('line', $result); } /** @@ -132,81 +135,26 @@ public function testStripPhpTags(): void } /** - * Test PHP short tags are stripped - */ - public function testStripPhpShortTags(): void - { - $result = $this->tinkerTools->execute(''); - - $this->assertTrue($result['success']); - $this->assertEquals(42, $result['result']); - } - - /** - * Test timeout minimum bound - */ - public function testTimeoutMinimumBound(): void - { - $result = $this->tinkerTools->execute('return 1;', -10); - - $this->assertTrue($result['success']); - $this->assertEquals(1, $result['result']); - } - - /** - * Test timeout maximum bound - */ - public function testTimeoutMaximumBound(): void - { - $result = $this->tinkerTools->execute('return 1;', 300); - - $this->assertTrue($result['success']); - $this->assertEquals(1, $result['result']); - } - - /** - * Test get_debug_type for null + * Test get_debug_type for various types */ - public function testGetDebugTypeForNull(): void + public function testGetDebugType(): void { $result = $this->tinkerTools->execute('return null;'); - $this->assertTrue($result['success']); $this->assertNull($result['result']); $this->assertEquals('null', $result['type']); - } - /** - * Test get_debug_type for bool - */ - public function testGetDebugTypeForBool(): void - { $result = $this->tinkerTools->execute('return true;'); - $this->assertTrue($result['success']); $this->assertTrue($result['result']); $this->assertEquals('bool', $result['type']); - } - /** - * Test get_debug_type for float - */ - public function testGetDebugTypeForFloat(): void - { $result = $this->tinkerTools->execute('return 3.14;'); - $this->assertTrue($result['success']); $this->assertEquals(3.14, $result['result']); $this->assertEquals('float', $result['type']); - } - /** - * Test get_debug_type for string - */ - public function testGetDebugTypeForString(): void - { $result = $this->tinkerTools->execute('return "hello";'); - $this->assertTrue($result['success']); $this->assertEquals('hello', $result['result']); $this->assertEquals('string', $result['type']); @@ -249,7 +197,8 @@ public function testOutputCapturedCorrectly(): void $this->assertTrue($result['success']); $this->assertEquals(42, $result['result']); - $this->assertEquals("Line 1\nLine 2\n", $result['output']); + $this->assertStringContainsString("Line 1\n", $result['output']); + $this->assertStringContainsString("Line 2\n", $result['output']); } /** @@ -275,9 +224,9 @@ public function testTinkerToolsClassExists(): void } /** - * Test fetchTable is accessible in eval context + * Test fetchTable is accessible via $context */ - public function testFetchTableInEvalContext(): void + public function testFetchTableViaContext(): void { $code = ' $table = $this->fetchTable("Users"); @@ -290,64 +239,297 @@ public function testFetchTableInEvalContext(): void } /** - * Test can query using ORM in eval context + * Test getTableLocator is accessible via $context + * + * Note: Database queries are not tested in subprocess mode because + * test fixtures are only loaded in the main PHPUnit process. */ - public function testQueryUsingOrmInEvalContext(): void + public function testGetTableLocatorViaContext(): void { $code = ' - $table = $this->fetchTable("Users"); - return $table->find()->count(); + $locator = $this->getTableLocator(); + return $locator::class; '; $result = $this->tinkerTools->execute($code); $this->assertTrue($result['success']); - $this->assertIsInt($result['result']); - $this->assertEquals('int', $result['type']); + $this->assertStringContainsString('TableLocator', $result['result']); } + // ========================================================================= + // Timeout Tests + // ========================================================================= + /** - * Test log method is accessible in eval context + * Test timeout minimum bound */ - public function testLogInEvalContext(): void + public function testTimeoutMinimumBound(): void { - // Configure test logger - Log::drop('test_tinker'); - Log::setConfig('test_tinker', [ - 'className' => 'Array', - 'levels' => ['debug', 'info'], - ]); + $result = $this->tinkerTools->execute('return 1;', -10); - $code = ' - $this->log("Test log message", "debug"); - return "logged"; - '; - $result = $this->tinkerTools->execute($code); + $this->assertTrue($result['success']); + $this->assertEquals(1, $result['result']); + } + + /** + * Test timeout maximum bound + */ + public function testTimeoutMaximumBound(): void + { + $result = $this->tinkerTools->execute('return 1;', 300); $this->assertTrue($result['success']); - $this->assertEquals('logged', $result['result']); + $this->assertEquals(1, $result['result']); + } - // Verify log was written - $logger = Log::engine('test_tinker'); - $this->assertInstanceOf(ArrayLog::class, $logger); - $messages = $logger->read(); - $this->assertStringContainsString('Test log message', implode("\n", $messages)); + // ========================================================================= + // Configuration and Path Tests + // ========================================================================= - // Cleanup - Log::drop('test_tinker'); + /** + * Test getPhpBinary returns a valid path + */ + public function testGetPhpBinaryReturnsValidPath(): void + { + $phpBinary = $this->tinkerTools->getPhpBinary(); + + $this->assertNotNull($phpBinary); + $this->assertFileExists($phpBinary); + $this->assertTrue(is_executable($phpBinary)); } /** - * Test getTableLocator is accessible in eval context + * Test getBinPath returns a valid path */ - public function testGetTableLocatorInEvalContext(): void + public function testGetBinPathReturnsValidPath(): void { - $code = ' - $locator = $this->getTableLocator(); - return $locator::class; - '; - $result = $this->tinkerTools->execute($code); + $binPath = $this->tinkerTools->getBinPath(); + + $this->assertNotEmpty($binPath); + $this->assertDirectoryExists($binPath); + } + + /** + * Test setPhpBinary allows setting custom path + */ + public function testSetPhpBinaryAllowsCustomPath(): void + { + $customPath = '/custom/php/path'; + + $result = $this->tinkerTools->setPhpBinary($customPath); + + $this->assertSame($this->tinkerTools, $result); + $this->assertEquals($customPath, $this->tinkerTools->getPhpBinary()); + } + + /** + * Test setBinPath allows setting custom path + */ + public function testSetBinPathAllowsCustomPath(): void + { + $customPath = '/custom/bin/path'; + + $result = $this->tinkerTools->setBinPath($customPath); + + $this->assertSame($this->tinkerTools, $result); + $this->assertEquals($customPath, $this->tinkerTools->getBinPath()); + } + + /** + * Test setPhpBinary can be reset to null + */ + public function testSetPhpBinaryCanBeResetToNull(): void + { + $this->tinkerTools->setPhpBinary('/custom/path'); + $this->tinkerTools->setPhpBinary(null); + + // Should fall back to auto-detection + $phpBinary = $this->tinkerTools->getPhpBinary(); + $this->assertNotEquals('/custom/path', $phpBinary); + } + + /** + * Test setBinPath can be reset to null + */ + public function testSetBinPathCanBeResetToNull(): void + { + $this->tinkerTools->setBinPath('/custom/path'); + $this->tinkerTools->setBinPath(null); + + // Should fall back to auto-detection + $binPath = $this->tinkerTools->getBinPath(); + $this->assertNotEquals('/custom/path', $binPath); + } + + /** + * Test configuration can override php_binary + */ + public function testConfigurationOverridesPhpBinary(): void + { + $originalConfig = Configure::read('Synapse.tinker.php_binary'); + + // Set a valid PHP binary path via configuration + $phpBinary = $this->tinkerTools->getPhpBinary(); + Configure::write('Synapse.tinker.php_binary', $phpBinary); + + // Create new instance to pick up config + $tinkerTools = new TinkerTools(); + $this->assertEquals($phpBinary, $tinkerTools->getPhpBinary()); + + // Restore original config + Configure::write('Synapse.tinker.php_binary', $originalConfig); + } + + /** + * Test configuration can override bin_path + */ + public function testConfigurationOverridesBinPath(): void + { + $originalConfig = Configure::read('Synapse.tinker.bin_path'); + + // Set a custom bin path via configuration + $customBinPath = '/custom/configured/bin'; + Configure::write('Synapse.tinker.bin_path', $customBinPath); + + // Create new instance to pick up config + $tinkerTools = new TinkerTools(); + $this->assertEquals($customBinPath, $tinkerTools->getBinPath()); + + // Restore original config + Configure::write('Synapse.tinker.bin_path', $originalConfig); + } + + /** + * Test subprocess fails gracefully with invalid PHP binary + */ + public function testSubprocessFailsWithInvalidPhpBinary(): void + { + $this->tinkerTools->setPhpBinary('/nonexistent/php'); + + $result = $this->tinkerTools->execute('return 1;'); + + $this->assertFalse($result['success']); + // Error can be our message or shell's "No such file or directory" + $this->assertTrue( + str_contains($result['error'], 'PHP binary') || + str_contains($result['error'], 'No such file or directory') || + str_contains($result['error'], 'not found'), + 'Expected error about missing PHP binary, got: ' . $result['error'], + ); + } + + // ========================================================================= + // Default Behavior Tests + // ========================================================================= + + /** + * Test default timeout is 30 seconds + */ + public function testDefaultTimeoutIsThirtySeconds(): void + { + // This test just verifies execution works with default timeout + $result = $this->tinkerTools->execute('return "ok";'); $this->assertTrue($result['success']); - $this->assertStringContainsString('TableLocator', $result['result']); + $this->assertEquals('ok', $result['result']); + } + + // ========================================================================= + // Error Handling and Edge Cases + // ========================================================================= + + /** + * Test execution timeout triggers error response + */ + public function testExecuteTimeout(): void + { + // Use a very short timeout with code that sleeps + $result = $this->tinkerTools->execute('sleep(5); return "done";', 1); + + $this->assertFalse($result['success']); + $this->assertStringContainsString('timed out', $result['error']); + $this->assertEquals('RuntimeException', $result['type']); + } + + /** + * Test empty stdout from subprocess returns error with exit code + */ + public function testEmptyStdoutReturnsError(): void + { + // Code that exits without output + $result = $this->tinkerTools->execute('exit(0);'); + + $this->assertFalse($result['success']); + $this->assertArrayHasKey('error', $result); + $this->assertArrayHasKey('exit_code', $result); + } + + /** + * Test null PHP binary returns error + */ + public function testNullPhpBinaryReturnsError(): void + { + // Use reflection to set phpBinary to a value that will make getPhpBinary return null + $tinkerTools = new TinkerTools(); + + // Mock by setting an invalid configured path and clearing cached value + $originalConfig = Configure::read('Synapse.tinker.php_binary'); + Configure::write('Synapse.tinker.php_binary', '/definitely/not/a/real/path'); + + // Create fresh instance - the config path isn't executable so it will try other methods + // We need to test the case where getPhpBinary returns null + // This is hard to achieve without modifying system, so test the error message path instead + $tinkerTools->setPhpBinary('/nonexistent/php/binary'); + $result = $tinkerTools->execute('return 1;'); + + $this->assertFalse($result['success']); + $this->assertArrayHasKey('error', $result); + + Configure::write('Synapse.tinker.php_binary', $originalConfig); + } + + /** + * Test getPhpBinary with non-executable configured path falls back + */ + public function testGetPhpBinaryFallsBackWhenConfiguredPathNotExecutable(): void + { + $originalConfig = Configure::read('Synapse.tinker.php_binary'); + + // Set a path that exists but isn't executable (or doesn't exist) + Configure::write('Synapse.tinker.php_binary', '/tmp/not-executable-file'); + + $tinkerTools = new TinkerTools(); + $phpBinary = $tinkerTools->getPhpBinary(); + + // Should fall back to which php or PHP_BINARY + $this->assertNotEquals('/tmp/not-executable-file', $phpBinary); + $this->assertNotNull($phpBinary); + + Configure::write('Synapse.tinker.php_binary', $originalConfig); + } + + /** + * Test getBinPath uses ROOT constant when available + */ + public function testGetBinPathUsesRootConstant(): void + { + $tinkerTools = new TinkerTools(); + $binPath = $tinkerTools->getBinPath(); + + // ROOT is defined in test environment + $this->assertEquals(ROOT . '/bin', $binPath); + } + + /** + * Test process that outputs to stderr + */ + public function testProcessWithStderrOutput(): void + { + // Trigger a PHP warning/notice that goes to stderr + $result = $this->tinkerTools->execute('trigger_error("test warning", E_USER_WARNING); return "ok";'); + + // Should still succeed, warnings don't stop execution + $this->assertTrue($result['success']); + $this->assertEquals('ok', $result['result']); } }