diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f999f9a..a8effdb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,32 @@ # Release Notes -## [Unreleased](https://github.com/laravel/boost/compare/v1.0.18...main) +## [Unreleased](https://github.com/laravel/boost/compare/v1.0.19...main) + +## [v1.0.19](https://github.com/laravel/boost/compare/v1.0.18...v1.0.19) - 2025-08-27 + +### What's Changed + +* Refactor creating laravel application instance using Testbench by [@crynobone](https://github.com/crynobone) in https://github.com/laravel/boost/pull/127 +* Fix Tailwind CSS title on README.md for consistency by [@xavizera](https://github.com/xavizera) in https://github.com/laravel/boost/pull/159 +* feat: don't run Boost during testing by [@ashleyhindle](https://github.com/ashleyhindle) in https://github.com/laravel/boost/pull/144 +* Hide Internal Command `ExecuteToolCommand.php` from Artisan List by [@yitzwillroth](https://github.com/yitzwillroth) in https://github.com/laravel/boost/pull/155 +* chore: removes non necessary php version constrant by [@nunomaduro](https://github.com/nunomaduro) in https://github.com/laravel/boost/pull/166 +* chore: removes non necessary pint version constrant by [@nunomaduro](https://github.com/nunomaduro) in https://github.com/laravel/boost/pull/167 +* Do not autoload classes while boost:install by [@pushpak1300](https://github.com/pushpak1300) in https://github.com/laravel/boost/pull/180 +* fix: prevent unwanted "null" file creation on Windows during installation by [@andreilungeanu](https://github.com/andreilungeanu) in https://github.com/laravel/boost/pull/189 +* Improve `InjectBoost` middleware for response-type handling by [@pushpak1300](https://github.com/pushpak1300) in https://github.com/laravel/boost/pull/179 +* docs: README: Add Nova 4.x and 5.x by [@ashleyhindle](https://github.com/ashleyhindle) in https://github.com/laravel/boost/pull/213 +* refactor: change ./artisan to artisan by [@ashleyhindle](https://github.com/ashleyhindle) in https://github.com/laravel/boost/pull/214 +* feat: guidelines: add Inertia form guidelines by [@ashleyhindle](https://github.com/ashleyhindle) in https://github.com/laravel/boost/pull/211 + +### New Contributors + +* [@crynobone](https://github.com/crynobone) made their first contribution in https://github.com/laravel/boost/pull/127 +* [@xavizera](https://github.com/xavizera) made their first contribution in https://github.com/laravel/boost/pull/159 +* [@nunomaduro](https://github.com/nunomaduro) made their first contribution in https://github.com/laravel/boost/pull/166 +* [@andreilungeanu](https://github.com/andreilungeanu) made their first contribution in https://github.com/laravel/boost/pull/189 + +**Full Changelog**: https://github.com/laravel/boost/compare/v1.0.18...v1.0.19 ## [v1.0.18](https://github.com/laravel/boost/compare/v1.0.17...v1.0.18) - 2025-08-16 diff --git a/src/BoostServiceProvider.php b/src/BoostServiceProvider.php index b799859a..a0d53219 100644 --- a/src/BoostServiceProvider.php +++ b/src/BoostServiceProvider.php @@ -180,7 +180,9 @@ private static function mapJsTypeToPsr3Level(string $type): string private function hookIntoResponses(Router $router): void { - $router->pushMiddlewareToGroup('web', InjectBoost::class); + $this->app->booted(function () use ($router) { + $router->pushMiddlewareToGroup('web', InjectBoost::class); + }); } private function shouldRun(): bool diff --git a/src/Install/CodeEnvironment/CodeEnvironment.php b/src/Install/CodeEnvironment/CodeEnvironment.php index dcddfb40..2e6550cc 100644 --- a/src/Install/CodeEnvironment/CodeEnvironment.php +++ b/src/Install/CodeEnvironment/CodeEnvironment.php @@ -12,6 +12,7 @@ use Laravel\Boost\Install\Detection\DetectionStrategyFactory; use Laravel\Boost\Install\Enums\McpInstallationStrategy; use Laravel\Boost\Install\Enums\Platform; +use Laravel\Boost\Install\Mcp\FileWriter; abstract class CodeEnvironment { @@ -185,21 +186,9 @@ protected function installFileMcp(string $key, string $command, array $args = [] return false; } - File::ensureDirectoryExists(dirname($path)); - - $config = File::exists($path) - ? json_decode(File::get($path), true) ?: [] - : []; - - $mcpKey = $this->mcpConfigKey(); - data_set($config, "{$mcpKey}.{$key}", collect([ - 'command' => $command, - 'args' => $args, - 'env' => $env, - ])->filter()->toArray()); - - $json = json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - - return $json && File::put($path, $json); + return (new FileWriter($path)) + ->configKey($this->mcpConfigKey()) + ->addServer($key, $command, $args, $env) + ->save(); } } diff --git a/src/Install/Mcp/FileWriter.php b/src/Install/Mcp/FileWriter.php new file mode 100644 index 00000000..dce5e66d --- /dev/null +++ b/src/Install/Mcp/FileWriter.php @@ -0,0 +1,410 @@ +filePath = $filePath; + } + + public function configKey(string $key): self + { + $this->configKey = $key; + + return $this; + } + + /** + * @param string $key MCP Server Name + * @param string $command + * @param array $args + * @param array $env + */ + public function addServer(string $key, string $command, array $args = [], array $env = []): self + { + $this->serversToAdd[$key] = collect([ + 'command' => $command, + 'args' => $args, + 'env' => $env, + ])->filter()->toArray(); + + return $this; + } + + public function save(): bool + { + $this->ensureDirectoryExists(); + + if ($this->shouldWriteNew()) { + return $this->createNewFile(); + } + + $content = $this->readFile(); + + if ($this->isPlainJson($content)) { + return $this->updatePlainJsonFile($content); + } + + return $this->updateJson5File($content); + } + + protected function updatePlainJsonFile(string $content): bool + { + $config = json_decode($content, true); + if (json_last_error() !== JSON_ERROR_NONE) { + return false; + } + + $this->addServersToConfig($config); + + return $this->writeJsonConfig($config); + } + + protected function updateJson5File(string $content): bool + { + $configKeyPattern = '/["\']'.preg_quote($this->configKey, '/').'["\']\\s*:\\s*\\{/'; + + if (preg_match($configKeyPattern, $content, $matches, PREG_OFFSET_CAPTURE)) { + return $this->injectIntoExistingConfigKey($content, $matches); + } else { + return $this->injectNewConfigKey($content); + } + } + + protected function injectIntoExistingConfigKey(string $content, array $matches): bool + { + // $matches[0][1] contains the position of the configKey pattern match + $configKeyStart = $matches[0][1]; + + // Find the opening brace of the configKey object + $openBracePos = strpos($content, '{', $configKeyStart); + if ($openBracePos === false) { + return false; + } + + // Find the matching closing brace for this configKey object + $closeBracePos = $this->findMatchingClosingBrace($content, $openBracePos); + if ($closeBracePos === false) { + return false; + } + + // Filter out servers that already exist + $serversToAdd = $this->filterExistingServers($content, $openBracePos, $closeBracePos); + + if (empty($serversToAdd)) { + return true; + } + + // Detect indentation from surrounding content + $indentLength = $this->detectIndentation($content, $closeBracePos); + + $serverJsonParts = []; + foreach ($serversToAdd as $key => $serverConfig) { + $serverJsonParts[] = $this->generateServerJson($key, $serverConfig, $indentLength); + } + $serversJson = implode(','."\n", $serverJsonParts); + + // Check if we need a comma and add it to the preceding content + $needsComma = $this->needsCommaBeforeClosingBrace($content, $openBracePos, $closeBracePos); + if (! $needsComma) { + $newContent = substr_replace($content, $serversJson, $closeBracePos, 0); + + return $this->writeFile($newContent); + } + + // Find the position to add comma (after the last meaningful character) + $commaPosition = $this->findCommaInsertionPoint($content, $openBracePos, $closeBracePos); + if ($commaPosition !== -1) { + $newContent = substr_replace($content, ',', $commaPosition, 0); + $newContent = substr_replace($newContent, $serversJson, $commaPosition + 1, 0); + } else { + $newContent = substr_replace($content, $serversJson, $closeBracePos, 0); + } + + return $this->writeFile($newContent); + } + + protected function filterExistingServers(string $content, int $openBracePos, int $closeBracePos): array + { + $configContent = substr($content, $openBracePos + 1, $closeBracePos - $openBracePos - 1); + $filteredServers = []; + + foreach ($this->serversToAdd as $key => $serverConfig) { + if (! $this->serverExistsInContent($configContent, $key)) { + $filteredServers[$key] = $serverConfig; + } + } + + return $filteredServers; + } + + protected function serverExistsInContent(string $content, string $serverKey): bool + { + $quotedPattern = '/["\']'.preg_quote($serverKey, '/').'["\']\\s*:/'; + $unquotedPattern = '/(?<=^|\\s|,|{)'.preg_quote($serverKey, '/').'\\s*:/m'; + + return preg_match($quotedPattern, $content) || preg_match($unquotedPattern, $content); + } + + protected function injectNewConfigKey(string $content): bool + { + $openBracePos = strpos($content, '{'); + if ($openBracePos === false) { + return false; + } + + $serverJsonParts = []; + foreach ($this->serversToAdd as $key => $serverConfig) { + $serverJsonParts[] = $this->generateServerJson($key, $serverConfig); + } + + $serversJson = implode(',', $serverJsonParts); + $configKeySection = '"'.$this->configKey.'": {'.$serversJson.'}'; + + $needsComma = $this->needsCommaAfterBrace($content, $openBracePos); + $injection = $configKeySection.($needsComma ? ',' : ''); + + $newContent = substr_replace($content, $injection, $openBracePos + 1, 0); + + return $this->writeFile($newContent); + } + + protected function generateServerJson(string $key, array $serverConfig, int $baseIndent = 0): string + { + $json = json_encode($serverConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + + // If no indentation needed, return as-is + if (empty($baseIndent)) { + return '"'.$key.'": '.$json; + } + + // Apply indentation to each line of the JSON + $baseIndent = str_repeat(' ', $baseIndent); + $lines = explode("\n", $json); + $firstLine = array_shift($lines); + $indentedLines = [ + "{$baseIndent}\"{$key}\": {$firstLine}", + ...array_map(fn ($line) => $baseIndent.$line, $lines), + ]; + + return "\n".implode("\n", $indentedLines); + } + + protected function needsCommaAfterBrace(string $content, int $bracePosition): bool + { + $afterBrace = substr($content, $bracePosition + 1); + $trimmed = preg_replace('/^\s*(?:\/\/.*$)?/m', '', $afterBrace); + + return filled($trimmed) && ! Str::startsWith($trimmed, '}'); + } + + protected function findMatchingClosingBrace(string $content, int $openBracePos): int|false + { + $braceCount = 1; + $length = strlen($content); + $inString = false; + $escaped = false; + + for ($i = $openBracePos + 1; $i < $length; $i++) { + $char = $content[$i]; + + if (! $inString) { + if ($char === '{') { + $braceCount++; + } elseif ($char === '}') { + $braceCount--; + if ($braceCount === 0) { + return $i; + } + } + } + + // Handle string detection (similar to hasUnquotedComments logic) + if ($char === '"' && ! $escaped) { + $inString = ! $inString; + } + + $escaped = ($char === '\\' && ! $escaped); + } + + return false; + } + + protected function needsCommaBeforeClosingBrace(string $content, int $openBracePos, int $closeBracePos): bool + { + // Get content between opening and closing braces + $innerContent = substr($content, $openBracePos + 1, $closeBracePos - $openBracePos - 1); + + // Skip whitespace and comments to find last meaningful character + $trimmed = preg_replace('/\s+|\/\/.*$/m', '', $innerContent); + + // If empty or ends with opening brace, no comma needed + if (blank($trimmed) || Str::endsWith($trimmed, '{')) { + return false; + } + + // If ends with comma, no additional comma needed + if (Str::endsWith($trimmed, ',')) { + return false; + } + + return true; + } + + protected function findCommaInsertionPoint(string $content, int $openBracePos, int $closeBracePos): int + { + // Work backwards from closing brace to find last meaningful character + for ($i = $closeBracePos - 1; $i > $openBracePos; $i--) { + $char = $content[$i]; + + // Skip whitespace and newlines + if (in_array($char, [' ', "\t", "\n", "\r"])) { + continue; + } + + // Skip comments (simple approach - if we hit //, skip to start of line) + if ($i > 0 && $content[$i - 1] === '/' && $char === '/') { + // Find start of this line + $lineStart = strrpos($content, "\n", $i - strlen($content)) ?: 0; + $i = $lineStart; + continue; + } + + // Found last meaningful character, comma goes after it + if ($char !== ',') { + return $i + 1; + } else { + // Already has comma, no insertion needed + return -1; + } + } + + // Fallback - insert right after opening brace + return $openBracePos + 1; + } + + public function detectIndentation(string $content, int $nearPosition): int + { + // Look backwards from the position to find server-level indentation + // We want to find lines that look like: "server-name": { + + $lines = explode("\n", substr($content, 0, $nearPosition)); + + // Look for the most recent server definition to match its indentation + for ($i = count($lines) - 1; $i >= 0; $i--) { + $line = $lines[$i]; + // Match server definitions: any amount of whitespace + "key": { + if (preg_match('/^(\s*)"[^"]+"\s*:\s*\{/', $line, $matches)) { + return strlen($matches[1]); + } + } + + // Fallback: assume 8 spaces (2 levels of 4-space indentation typical for JSON) + return $this->defaultIndentation; + } + + /** + * Is the file content plain JSON, without JSON5 features? + */ + protected function isPlainJson(string $content): bool + { + if ($this->hasUnquotedComments($content)) { + return false; + } + + // Trailing commas (,] or ,}) - supported in JSON 5 + if (preg_match('/,\s*[\]}]/', $content)) { + return false; + } + + // Unquoted keys - supported in JSON 5 + if (preg_match('/^\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*:/m', $content)) { + return false; + } + + json_decode($content); + + return json_last_error() === JSON_ERROR_NONE; + } + + protected function hasUnquotedComments(string $content): bool + { + // Regex that matches both quoted strings and comments + // Group 1: Double-quoted strings with escaped characters + // Group 2: Line comments starting with // + $pattern = '/"(\\\\.|[^"\\\\])*"|(\/\/.*)/'; + + if (preg_match_all($pattern, $content, $matches, PREG_SET_ORDER)) { + foreach ($matches as $match) { + // If group 2 is set, we found a // comment outside strings + if (! empty($match[2])) { + return true; + } + } + } + + return false; + } + + protected function createNewFile(): bool + { + $config = []; + $this->addServersToConfig($config); + + return $this->writeJsonConfig($config); + } + + protected function addServersToConfig(array &$config): void + { + foreach ($this->serversToAdd as $key => $serverConfig) { + data_set($config, $this->configKey.'.'.$key, $serverConfig); + } + } + + protected function writeJsonConfig(array $config): bool + { + $json = json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + + return $json && $this->writeFile($json); + } + + protected function ensureDirectoryExists(): void + { + File::ensureDirectoryExists(dirname($this->filePath)); + } + + protected function fileExists(): bool + { + return File::exists($this->filePath); + } + + protected function shouldWriteNew(): bool + { + return ! $this->fileExists() || File::size($this->filePath) < 3; // To account for files that are just `{}` + } + + protected function readFile(): string + { + return File::get($this->filePath); + } + + protected function writeFile(string $content): bool + { + return File::put($this->filePath, $content) !== false; + } +} diff --git a/src/Mcp/ToolExecutor.php b/src/Mcp/ToolExecutor.php index 7c8533e0..43cf68f5 100644 --- a/src/Mcp/ToolExecutor.php +++ b/src/Mcp/ToolExecutor.php @@ -4,6 +4,8 @@ namespace Laravel\Boost\Mcp; +use Dotenv\Dotenv; +use Illuminate\Support\Env; use Laravel\Mcp\Server\Tools\ToolResult; use Symfony\Component\Process\Exception\ProcessFailedException; use Symfony\Component\Process\Exception\ProcessTimedOutException; @@ -13,14 +15,8 @@ class ToolExecutor { public function __construct() { - // } - /** - * Execute a tool with the given arguments. - * - * @param array $arguments - */ public function execute(string $toolClass, array $arguments = []): ToolResult { if (! ToolRegistry::isToolAllowed($toolClass)) { @@ -34,23 +30,23 @@ public function execute(string $toolClass, array $arguments = []): ToolResult return $this->executeInline($toolClass, $arguments); } - /** - * Execute tool in a separate process for isolation. - * - * @param array $arguments - */ protected function executeInProcess(string $toolClass, array $arguments): ToolResult { - $command = [ - PHP_BINARY, - base_path('artisan'), - 'boost:execute-tool', - $toolClass, - base64_encode(json_encode($arguments)), - ]; - - $process = new Process($command); - $process->setTimeout($this->getTimeout()); + $command = $this->buildCommand($toolClass, $arguments); + + // We need to 'unset' env vars that will be passed from the parent process to the child process, stopping the child process from reading .env and getting updated values + $env = (Dotenv::create( + Env::getRepository(), + app()->environmentPath(), + app()->environmentFile() + ))->safeLoad(); + $cleanEnv = array_fill_keys(array_keys($env), false); + + $process = new Process( + command: $command, + env: $cleanEnv, + timeout: $this->getTimeout() + ); try { $process->mustRun(); @@ -62,9 +58,7 @@ protected function executeInProcess(string $toolClass, array $arguments): ToolRe return ToolResult::error('Invalid JSON output from tool process: '.json_last_error_msg()); } - // Reconstruct ToolResult from the JSON output return $this->reconstructToolResult($decoded); - } catch (ProcessTimedOutException $e) { $process->stop(); @@ -77,14 +71,10 @@ protected function executeInProcess(string $toolClass, array $arguments): ToolRe } } - /** - * Execute tool inline (current process). - * - * @param array $arguments - */ protected function executeInline(string $toolClass, array $arguments): ToolResult { try { + /** @var \Laravel\Mcp\Server\Tool $tool */ $tool = app($toolClass); return $tool->handle($arguments); @@ -93,22 +83,15 @@ protected function executeInline(string $toolClass, array $arguments): ToolResul } } - /** - * Check if process isolation should be used. - */ protected function shouldUseProcessIsolation(): bool { - // Never use process isolation in testing environment if (app()->environment('testing')) { return false; } - return config('boost.process_isolation.enabled', false); + return config('boost.process_isolation.enabled', true); } - /** - * Get the execution timeout. - */ protected function getTimeout(): int { return config('boost.process_isolation.timeout', 180); @@ -157,4 +140,22 @@ protected function reconstructToolResult(array $data): ToolResult return ToolResult::text(''); } + + /** + * Build the command array for executing a tool in a subprocess. + * + * @param string $toolClass + * @param array $arguments + * @return array + */ + protected function buildCommand(string $toolClass, array $arguments): array + { + return [ + PHP_BINARY, + base_path('artisan'), + 'boost:execute-tool', + $toolClass, + base64_encode(json_encode($arguments)), + ]; + } } diff --git a/tests/Feature/Mcp/ToolExecutorTest.php b/tests/Feature/Mcp/ToolExecutorTest.php index 4bb998d7..b226b520 100644 --- a/tests/Feature/Mcp/ToolExecutorTest.php +++ b/tests/Feature/Mcp/ToolExecutorTest.php @@ -2,6 +2,8 @@ use Laravel\Boost\Mcp\ToolExecutor; use Laravel\Boost\Mcp\Tools\ApplicationInfo; +use Laravel\Boost\Mcp\Tools\GetConfig; +use Laravel\Boost\Mcp\Tools\Tinker; use Laravel\Mcp\Server\Tools\ToolResult; test('can execute tool inline', function () { @@ -14,10 +16,136 @@ expect($result)->toBeInstanceOf(ToolResult::class); }); +test('can execute tool with process isolation', function () { + // Enable process isolation for this test + config(['boost.process_isolation.enabled' => true]); + + // Create a mock that overrides buildCommand to work with testbench + $executor = Mockery::mock(ToolExecutor::class)->makePartial() + ->shouldAllowMockingProtectedMethods(); + $executor->shouldReceive('buildCommand') + ->once() + ->andReturnUsing(fn ($toolClass, $arguments) => buildSubprocessCommand($toolClass, $arguments)); + + $result = $executor->execute(GetConfig::class, ['key' => 'app.name']); + + expect($result)->toBeInstanceOf(ToolResult::class); + + // If there's an error, extract the text content properly + if ($result->isError) { + $errorText = $result->content[0]->text ?? 'Unknown error'; + expect(false)->toBeTrue("Tool execution failed with error: {$errorText}"); + } + + expect($result->isError)->toBeFalse(); + expect($result->content)->toBeArray(); + + // The content should contain the app name (which should be "Laravel" in testbench) + $textContent = $result->content[0]->text ?? ''; + expect($textContent)->toContain('Laravel'); +}); + test('rejects unregistered tools', function () { $executor = app(ToolExecutor::class); - $result = $executor->execute('NonExistentToolClass', []); + $result = $executor->execute('NonExistentToolClass'); - expect($result)->toBeInstanceOf(ToolResult::class); - expect($result->isError)->toBeTrue(); + expect($result)->toBeInstanceOf(ToolResult::class) + ->and($result->isError)->toBeTrue(); }); + +test('subprocess proves fresh process isolation', function () { + config(['boost.process_isolation.enabled' => true]); + + $executor = Mockery::mock(ToolExecutor::class)->makePartial() + ->shouldAllowMockingProtectedMethods(); + $executor->shouldReceive('buildCommand') + ->andReturnUsing(fn ($toolClass, $arguments) => buildSubprocessCommand($toolClass, $arguments)); + + $result1 = $executor->execute(Tinker::class, ['code' => 'return getmypid();']); + $result2 = $executor->execute(Tinker::class, ['code' => 'return getmypid();']); + + expect($result1->isError)->toBeFalse(); + expect($result2->isError)->toBeFalse(); + + $pid1 = json_decode($result1->content[0]->text, true)['result']; + $pid2 = json_decode($result2->content[0]->text, true)['result']; + + expect($pid1)->toBeInt()->not->toBe(getmypid()); + expect($pid2)->toBeInt()->not->toBe(getmypid()); + expect($pid1)->not()->toBe($pid2); +}); + +test('subprocess sees modified autoloaded code changes', function () { + config(['boost.process_isolation.enabled' => true]); + + $executor = Mockery::mock(ToolExecutor::class)->makePartial() + ->shouldAllowMockingProtectedMethods(); + $executor->shouldReceive('buildCommand') + ->andReturnUsing(fn ($toolClass, $arguments) => buildSubprocessCommand($toolClass, $arguments)); + + // Path to the GetConfig tool that we'll temporarily modify + // TODO: Improve for parallelisation + $toolPath = dirname(__DIR__, 3).'/src/Mcp/Tools/GetConfig.php'; + $originalContent = file_get_contents($toolPath); + + $cleanup = function () use ($toolPath, $originalContent) { + file_put_contents($toolPath, $originalContent); + }; + + try { + $result1 = $executor->execute(GetConfig::class, ['key' => 'app.name']); + + expect($result1->isError)->toBeFalse(); + $response1 = json_decode($result1->content[0]->text, true); + expect($response1['value'])->toBe('Laravel'); // Normal testbench app name + + // Modify GetConfig.php to return a different hardcoded value + $modifiedContent = str_replace( + "'value' => Config::get(\$key),", + "'value' => 'MODIFIED_BY_TEST',", + $originalContent + ); + file_put_contents($toolPath, $modifiedContent); + + $result2 = $executor->execute(GetConfig::class, ['key' => 'app.name']); + $response2 = json_decode($result2->content[0]->text, true); + + expect($result2->isError)->toBeFalse(); + expect($response2['value'])->toBe('MODIFIED_BY_TEST'); // Using updated code, not cached + } finally { + $cleanup(); + } +}); + +/** + * Build a subprocess command that bootstraps testbench and executes an MCP tool via artisan. + */ +function buildSubprocessCommand(string $toolClass, array $arguments): array +{ + $argumentsEncoded = base64_encode(json_encode($arguments)); + $testScript = sprintf( + 'require_once "%s/vendor/autoload.php"; '. + 'use Orchestra\Testbench\Foundation\Application as Testbench; '. + 'use Orchestra\Testbench\Foundation\Config as TestbenchConfig; '. + 'use Illuminate\Support\Facades\Artisan; '. + 'use Symfony\Component\Console\Output\BufferedOutput; '. + // Bootstrap testbench like all.php does + '$app = Testbench::createFromConfig(new TestbenchConfig([]), options: ["enables_package_discoveries" => false]); '. + 'Illuminate\Container\Container::setInstance($app); '. + '$kernel = $app->make("Illuminate\Contracts\Console\Kernel"); '. + '$kernel->bootstrap(); '. + // Register the ExecuteToolCommand + '$kernel->registerCommand(new \Laravel\Boost\Console\ExecuteToolCommand()); '. + '$output = new BufferedOutput(); '. + '$result = Artisan::call("boost:execute-tool", ['. + ' "tool" => "%s", '. + ' "arguments" => "%s" '. + '], $output); '. + 'echo $output->fetch();', + dirname(__DIR__, 3), // Go up from tests/Feature/Mcp to project root + addslashes($toolClass), + $argumentsEncoded + ); + + return [PHP_BINARY, '-r', $testScript]; +} diff --git a/tests/Pest.php b/tests/Pest.php index e1914f05..b5f85e2d 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -26,6 +26,7 @@ | */ -expect()->extend('toBeOne', function () { - return $this->toBe(1); -}); +function fixture(string $name): string +{ + return file_get_contents(\Pest\testDirectory('fixtures/'.$name)); +} diff --git a/tests/TestCase.php b/tests/TestCase.php index ca1769c7..5d5bbe67 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -4,7 +4,6 @@ namespace Tests; -use Illuminate\Support\Facades\Artisan; use Laravel\Boost\BoostServiceProvider; use Laravel\Mcp\Server\Registrar; use Orchestra\Testbench\TestCase as OrchestraTestCase; @@ -13,11 +12,8 @@ abstract class TestCase extends OrchestraTestCase { protected function defineEnvironment($app) { - // Set environment to local so commands are registered $app['env'] = 'local'; - Artisan::call('vendor:publish', ['--tag' => 'boost-assets']); - $app->singleton('mcp', Registrar::class); } diff --git a/tests/Unit/Install/CodeEnvironment/CodeEnvironmentTest.php b/tests/Unit/Install/CodeEnvironment/CodeEnvironmentTest.php index 800ab41b..1a628d5c 100644 --- a/tests/Unit/Install/CodeEnvironment/CodeEnvironmentTest.php +++ b/tests/Unit/Install/CodeEnvironment/CodeEnvironmentTest.php @@ -9,6 +9,7 @@ use Laravel\Boost\Contracts\Agent; use Laravel\Boost\Contracts\McpClient; use Laravel\Boost\Install\CodeEnvironment\CodeEnvironment; +use Laravel\Boost\Install\CodeEnvironment\VSCode; use Laravel\Boost\Install\Contracts\DetectionStrategy; use Laravel\Boost\Install\Detection\DetectionStrategyFactory; use Laravel\Boost\Install\Enums\McpInstallationStrategy; @@ -64,19 +65,6 @@ public function mcpConfigPath(): string } } -class TestAgentAndMcpClient extends TestCodeEnvironment implements Agent, McpClient -{ - public function guidelinesPath(): string - { - return 'test-guidelines.md'; - } - - public function mcpConfigPath(): string - { - return '.test/mcp.json'; - } -} - test('detectOnSystem delegates to strategy factory and detection strategy', function () { $platform = Platform::Darwin; $config = ['paths' => ['/test/path']]; @@ -303,6 +291,22 @@ public function mcpConfigPath(): string test('installFileMcp creates new config file when none exists', function () { $environment = Mockery::mock(TestMcpClient::class)->makePartial(); $environment->shouldAllowMockingProtectedMethods(); + $capturedContent = ''; + $expectedContent = <<<'JSON' +{ + "mcpServers": { + "test-key": { + "command": "test-command", + "args": [ + "arg1" + ], + "env": { + "ENV": "value" + } + } + } +} +JSON; $environment->shouldReceive('mcpInstallationStrategy') ->andReturn(McpInstallationStrategy::FILE); @@ -318,17 +322,21 @@ public function mcpConfigPath(): string File::shouldReceive('put') ->once() - ->with('.test/mcp.json', Mockery::type('string')) + ->with(Mockery::capture($capturedPath), Mockery::capture($capturedContent)) ->andReturn(true); $result = $environment->installMcp('test-key', 'test-command', ['arg1'], ['ENV' => 'value']); expect($result)->toBe(true); + expect($capturedPath)->toBe($environment->mcpConfigPath()); + expect($capturedContent)->toBe($expectedContent); }); test('installFileMcp updates existing config file', function () { $environment = Mockery::mock(TestMcpClient::class)->makePartial(); $environment->shouldAllowMockingProtectedMethods(); + $capturedPath = ''; + $capturedContent = ''; $environment->shouldReceive('mcpInstallationStrategy') ->andReturn(McpInstallationStrategy::FILE); @@ -339,6 +347,8 @@ public function mcpConfigPath(): string ->once() ->with('.test'); + File::shouldReceive('size')->once()->andReturn(10); + File::shouldReceive('exists') ->once() ->with('.test/mcp.json') @@ -351,15 +361,51 @@ public function mcpConfigPath(): string File::shouldReceive('put') ->once() - ->with('.test/mcp.json', Mockery::on(function ($json) { - $config = json_decode($json, true); - - return isset($config['mcpServers']['test-key']) && - isset($config['mcpServers']['existing']); - })) + ->with(Mockery::capture($capturedPath), Mockery::capture($capturedContent)) ->andReturn(true); $result = $environment->installMcp('test-key', 'test-command', ['arg1'], ['ENV' => 'value']); - expect($result)->toBe(true); + expect($result)->toBe(true) + ->and($capturedContent) + ->json() + ->toMatchArray([ + 'mcpServers' => [ + 'existing' => [ + 'command' => 'existing-cmd', + ], + 'test-key' => [ + 'command' => 'test-command', + 'args' => ['arg1'], + 'env' => ['ENV' => 'value'], + ], + ], + ]); + +}); + +test('installFileMcp works with existing config file using JSON 5', function () { + $vscode = new VSCode($this->strategyFactory); + $capturedPath = ''; + $capturedContent = ''; + $json5 = fixture('mcp.json5'); + + File::shouldReceive('exists')->once()->andReturn(true); + File::shouldReceive('size')->once()->andReturn(10); + File::shouldReceive('put') + ->with( + Mockery::capture($capturedPath), + Mockery::capture($capturedContent), + ) + ->andReturn(true); + + File::shouldReceive('get') + ->with($vscode->mcpConfigPath()) + ->andReturn($json5)->getMock()->shouldIgnoreMissing(); + + $wasWritten = $vscode->installMcp('boost', 'php', ['artisan', 'boost:mcp'], ['SITE_PATH' => '/tmp/']); + + expect($wasWritten)->toBeTrue() + ->and($capturedPath)->toBe($vscode->mcpConfigPath()) + ->and($capturedContent)->toBe(fixture('mcp-expected.json5')); }); diff --git a/tests/Unit/Install/Detection/FileDetectionStrategyTest.php b/tests/Unit/Install/Detection/FileDetectionStrategyTest.php index db0a5171..cab3b0df 100644 --- a/tests/Unit/Install/Detection/FileDetectionStrategyTest.php +++ b/tests/Unit/Install/Detection/FileDetectionStrategyTest.php @@ -5,13 +5,13 @@ use Laravel\Boost\Install\Detection\FileDetectionStrategy; beforeEach(function () { - $this->strategy = new FileDetectionStrategy(); + $this->strategy = new FileDetectionStrategy; $this->tempDir = sys_get_temp_dir().'/boost_test_'.uniqid(); mkdir($this->tempDir); }); afterEach(function () { - if (is_dir($this->tempDir)) { + if (is_dir($this->tempDir) && str_contains($this->tempDir, sys_get_temp_dir())) { removeDirectoryForFileTests($this->tempDir); } }); diff --git a/tests/Unit/Install/HerdTest.php b/tests/Unit/Install/HerdTest.php index 1bbaeadf..822d6b53 100644 --- a/tests/Unit/Install/HerdTest.php +++ b/tests/Unit/Install/HerdTest.php @@ -77,7 +77,7 @@ function getHerdTestTempDir(): string expect($herd->mcpPath())->toBe($expected); })->onlyOnWindows(); -test('isMcpAvailable returns false when MCP file is missing', function () { +test('isMcpAvailable returns false when MCP file is missing from home', function () { $testHome = getHerdTestTempDir().'/home'; mkdir($testHome, 0755, true); $_SERVER['HOME'] = $testHome; @@ -85,9 +85,9 @@ function getHerdTestTempDir(): string $herd = new Herd; expect($herd->isMcpAvailable())->toBeFalse(); -}); +})->onlyOnWindows(); -test('isMcpAvailable returns true when MCP file exists', function () { +test('isMcpAvailable returns true when MCP file exists in home', function () { $testHome = getHerdTestTempDir().'/home'; mkdir($testHome, 0755, true); $_SERVER['HOME'] = $testHome; diff --git a/tests/Unit/Install/Mcp/FileWriterTest.php b/tests/Unit/Install/Mcp/FileWriterTest.php new file mode 100644 index 00000000..5d1dc442 --- /dev/null +++ b/tests/Unit/Install/Mcp/FileWriterTest.php @@ -0,0 +1,616 @@ +toBeInstanceOf(FileWriter::class); +}); + +test('configKey method returns self for chaining', function () { + $writer = new FileWriter('/path/to/mcp.json'); + $result = $writer->configKey('customKey'); + + expect($result)->toBe($writer); +}); + +test('addServer method returns self for chaining', function () { + $writer = new FileWriter('/path/to/mcp.json'); + $result = $writer + ->configKey('servers') + ->addServer('test', 'php', ['artisan'], ['ENV' => 'value']); + + expect($result)->toBe($writer); +}); + +test('save method returns boolean', function () { + mockFileOperations(); + $writer = new FileWriter('/path/to/mcp.json'); + $result = $writer->save(); + + expect($result)->toBe(true); +}); + +test('written data is correct for brand new file', function (string $configKey, array $servers, string $expectedJson) { + $writtenPath = ''; + $writtenContent = ''; + mockFileOperations(capturedPath: $writtenPath, capturedContent: $writtenContent); + + $writer = (new FileWriter('/path/to/mcp.json')) + ->configKey($configKey); + + foreach ($servers as $serverKey => $serverConfig) { + $writer->addServer( + $serverKey, + $serverConfig['command'], + $serverConfig['args'] ?? [], + $serverConfig['env'] ?? [] + ); + } + + $result = $writer->save(); + + $simpleContents = Str::of($writtenContent)->replaceMatches('/\s+/', ''); + expect($result)->toBe(true); + expect($simpleContents)->toEqual($expectedJson); +})->with(newFileServerConfigurations()); + +test('updates existing plain JSON file using simple method', function () { + $writtenPath = ''; + $writtenContent = ''; + + mockFileOperations( + fileExists: true, + content: fixture('mcp-plain.json'), + capturedPath: $writtenPath, + capturedContent: $writtenContent + ); + + // Need to mock File::size for fileEmpty check + File::shouldReceive('size')->andReturn(100); + + $result = (new FileWriter('/path/to/mcp.json')) + ->configKey('servers') + ->addServer('new-server', 'npm', ['start']) + ->save(); + + expect($result)->toBeTrue(); + + $decoded = json_decode($writtenContent, true); + expect($decoded)->toHaveKey('existing'); + expect($decoded)->toHaveKey('other'); + expect($decoded)->toHaveKey('nested.key'); // From fixture + expect($decoded)->toHaveKey('servers.new-server'); + expect($decoded['servers']['new-server']['command'])->toBe('npm'); +}); + +test('adds to existing mcpServers in plain JSON', function () { + $writtenPath = ''; + $writtenContent = ''; + + mockFileOperations( + fileExists: true, + content: fixture('mcp-with-servers.json'), + capturedPath: $writtenPath, + capturedContent: $writtenContent + ); + + File::shouldReceive('size')->andReturn(200); + + $result = (new FileWriter('/path/to/mcp.json')) + ->addServer('boost', 'php', ['artisan', 'boost:mcp']) + ->save(); + + expect($result)->toBeTrue(); + + $decoded = json_decode($writtenContent, true); + + expect($decoded)->toHaveKey('mcpServers.existing-server'); // Original preserved + expect($decoded)->toHaveKey('mcpServers.boost'); // New server added + expect($decoded['mcpServers']['boost']['command'])->toBe('php'); +}); + +test('preserves complex JSON5 features that VS Code supports', function () { + $writtenContent = ''; + + mockFileOperations( + fileExists: true, + content: fixture('mcp.json5'), + capturedContent: $writtenContent + ); + + File::shouldReceive('size')->andReturn(1000); + + $result = (new FileWriter('/path/to/mcp.json')) + ->configKey('servers') // mcp.json5 uses "servers", not "mcpServers" + ->addServer('test', 'cmd') + ->save(); + + expect($result)->toBeTrue(); + expect($writtenContent)->toContain('"test"'); // New server added + expect($writtenContent)->toContain('// Here are comments within my JSON'); // Preserve block comments + expect($writtenContent)->toContain("// I'm trailing"); // Preserve inline comments + expect($writtenContent)->toContain('// Ooo, pretty cool'); // Preserve comments in arrays + expect($writtenContent)->toContain('MYSQL_HOST'); // Preserve complex nested structure +}); + +test('detects plain JSON with comments inside strings as safe', function () { + $writtenContent = ''; + + mockFileOperations( + fileExists: true, + content: fixture('mcp-comments-in-strings.json'), + capturedContent: $writtenContent + ); + + File::shouldReceive('size')->andReturn(200); + + $result = (new FileWriter('/path/to/mcp.json')) + ->addServer('new-server', 'test-cmd') + ->save(); + + expect($result)->toBeTrue(); + + $decoded = json_decode($writtenContent, true); + expect($decoded)->toHaveKey('exampleCode'); // Original preserved + expect($decoded)->toHaveKey('mcpServers.new-server'); // New server added + expect($decoded['exampleCode'])->toContain('// here is the example code'); // Comment in string preserved +}); + +test('hasUnquotedComments detects comments correctly', function (string $content, bool $expected, string $description) { + $writer = new FileWriter('/tmp/test.json'); + $reflection = new \ReflectionClass($writer); + $method = $reflection->getMethod('hasUnquotedComments'); + $method->setAccessible(true); + + $result = $method->invokeArgs($writer, [$content]); + + expect($result)->toBe($expected, $description); +})->with(commentDetectionCases()); + +test('trailing comma detection works across newlines', function (string $content, bool $expected, string $description) { + $writer = new FileWriter('/tmp/test.json'); + $reflection = new \ReflectionClass($writer); + $method = $reflection->getMethod('isPlainJson'); + $method->setAccessible(true); + + $result = $method->invokeArgs($writer, [$content]); + + expect($result)->toBe($expected, $description); +})->with(trailingCommaCases()); + +test('generateServerJson creates correct JSON snippet', function () { + $writer = new FileWriter('/tmp/test.json'); + $reflection = new \ReflectionClass($writer); + $method = $reflection->getMethod('generateServerJson'); + $method->setAccessible(true); + + // Test with simple server + $result = $method->invokeArgs($writer, ['boost', ['command' => 'php']]); + expect($result)->toBe('"boost": { + "command": "php" +}'); + + // Test with full server config + $result = $method->invokeArgs($writer, ['mysql', [ + 'command' => 'npx', + 'args' => ['@benborla29/mcp-server-mysql'], + 'env' => ['DB_HOST' => 'localhost'], + ]]); + expect($result)->toBe('"mysql": { + "command": "npx", + "args": [ + "@benborla29/mcp-server-mysql" + ], + "env": { + "DB_HOST": "localhost" + } +}'); +}); + +test('fixture mcp-no-configkey.json5 is detected as JSON5 and will use injectNewConfigKey', function () { + $content = fixture('mcp-no-configkey.json5'); + $writer = new FileWriter('/tmp/test.json'); + $reflection = new \ReflectionClass($writer); + + // Verify it's detected as JSON5 (not plain JSON) + $isPlainJsonMethod = $reflection->getMethod('isPlainJson'); + $isPlainJsonMethod->setAccessible(true); + $isPlainJson = $isPlainJsonMethod->invokeArgs($writer, [$content]); + expect($isPlainJson)->toBeFalse('Should be detected as JSON5 due to comments'); + + // Verify it doesn't have mcpServers key (will use injectNewConfigKey path) + $configKeyPattern = '/["\']mcpServers["\']\\s*:\\s*\\{/'; + $hasConfigKey = preg_match($configKeyPattern, $content); + expect($hasConfigKey)->toBe(0, 'Should not have mcpServers key, triggering injectNewConfigKey'); +}); + +test('injects new configKey when it does not exist', function () { + $writtenContent = ''; + + mockFileOperations( + fileExists: true, + content: fixture('mcp-no-configkey.json5'), + capturedContent: $writtenContent + ); + + File::shouldReceive('size')->andReturn(200); + + $result = (new FileWriter('/path/to/mcp.json')) + ->addServer('boost', 'php', ['artisan', 'boost:mcp']) + ->save(); + + expect($result)->toBeTrue(); + expect($writtenContent)->toContain('"mcpServers"'); + expect($writtenContent)->toContain('"boost"'); + expect($writtenContent)->toContain('"php"'); + expect($writtenContent)->toContain('// No mcpServers key at all'); // Preserve existing comments +}); + +test('injects into existing configKey preserving JSON5 features', function () { + $writtenContent = ''; + + mockFileOperations( + fileExists: true, + content: fixture('mcp.json5'), + capturedContent: $writtenContent + ); + + File::shouldReceive('size')->andReturn(1000); + + $result = (new FileWriter('/path/to/mcp.json')) + ->configKey('servers') // mcp.json5 uses "servers" not "mcpServers" + ->addServer('boost', 'php', ['artisan', 'boost:mcp']) + ->save(); + + expect($result)->toBeTrue(); + expect($writtenContent)->toContain('"boost"'); // New server added + expect($writtenContent)->toContain('mysql'); // Existing server preserved + expect($writtenContent)->toContain('laravel-boost'); // Existing server preserved + expect($writtenContent)->toContain('// Here are comments within my JSON'); // Comments preserved + expect($writtenContent)->toContain('// Ooo, pretty cool'); // Inline comments preserved +}); + +test('injecting twice into existing JSON 5 doesn\'t cause duplicates', function () { + $capturedContent = ''; + + File::clearResolvedInstances(); + File::partialMock(); + + File::shouldReceive('ensureDirectoryExists')->once(); + File::shouldReceive('exists')->andReturn(true); + File::shouldReceive('size')->andReturn(1000); + File::shouldReceive('get')->andReturn(fixture('mcp.json5')); + File::shouldReceive('put') + ->with( + Mockery::capture($capturedPath), + Mockery::capture($capturedContent) + ) + ->andReturn(true); + + $result = (new FileWriter('/path/to/mcp.json')) + ->configKey('servers') // mcp.json5 uses "servers" not "mcpServers" + ->addServer('boost', 'php', ['artisan', 'boost:mcp']) + ->save(); + + $boostCounts = substr_count($capturedContent, '"boost"'); + expect($result)->toBeTrue(); + expect($boostCounts)->toBe(1); + expect($capturedContent)->toContain('"boost"'); // New server added + expect($capturedContent)->toContain('mysql'); // Existing server preserved + expect($capturedContent)->toContain('laravel-boost'); // Existing server preserved + expect($capturedContent)->toContain('// Here are comments within my JSON'); // Comments preserved + expect($capturedContent)->toContain('// Ooo, pretty cool'); // Inline comments preserved + + $newContent = $capturedContent; + + File::clearResolvedInstances(); + File::partialMock(); + + File::shouldReceive('ensureDirectoryExists')->once(); + File::shouldReceive('exists')->andReturn(true); + File::shouldReceive('size')->andReturn(1000); + File::shouldReceive('get')->andReturn($newContent); + + $result = (new FileWriter('/path/to/mcp.json')) + ->configKey('servers') + ->addServer('boost', 'php', ['artisan', 'boost:mcp']) + ->save(); + + // Second call should return true but not modify the file since boost already exists + expect($result)->toBeTrue(); + + // We should still have only one instance of the boost MCP server + $boostCounts = substr_count($capturedContent, '"boost"'); + expect($boostCounts)->toBe(1); +}); + +test('injects into empty configKey object', function () { + $writtenContent = ''; + + mockFileOperations( + fileExists: true, + content: fixture('mcp-empty-configkey.json5'), + capturedContent: $writtenContent + ); + + File::shouldReceive('size')->andReturn(200); + + $result = (new FileWriter('/path/to/mcp.json')) + ->addServer('boost', 'php', ['artisan', 'boost:mcp']) + ->save(); + + expect($result)->toBeTrue(); + expect($writtenContent)->toContain('"boost"'); // New server added + expect($writtenContent)->toContain('// Empty mcpServers object'); // Comments preserved + expect($writtenContent)->toContain('test_input'); // Existing content preserved +}); + +test('preserves trailing commas when injecting into existing servers', function () { + $writtenContent = ''; + + mockFileOperations( + fileExists: true, + content: fixture('mcp-trailing-comma.json5'), + capturedContent: $writtenContent + ); + + File::shouldReceive('size')->andReturn(200); + + $result = (new FileWriter('/path/to/mcp.json')) + ->addServer('boost', 'php', ['artisan', 'boost:mcp']) + ->save(); + + expect($result)->toBeTrue(); + expect($writtenContent)->toContain('"boost"'); // New server added + expect($writtenContent)->toContain('existing-server'); // Existing server preserved + expect($writtenContent)->toContain('// Trailing comma here'); // Comments preserved + expect($writtenContent)->toContain('arg1'); // Existing args preserved +}); + +test('detectIndentation works correctly with various patterns', function (string $content, int $position, int $expected, string $description) { + $writer = new FileWriter('/tmp/test.json'); + + $result = $writer->detectIndentation($content, $position); + + expect($result)->toBe($expected, $description); +})->with(indentationDetectionCases()); + +function mockFileOperations(bool $fileExists = false, string $content = '{}', bool $writeSuccess = true, ?string &$capturedPath = null, ?string &$capturedContent = null): void +{ + // Clear any existing File facade mock + File::clearResolvedInstances(); + File::partialMock(); + + File::shouldReceive('ensureDirectoryExists')->once(); + File::shouldReceive('exists')->andReturn($fileExists); + + if ($fileExists) { + File::shouldReceive('get')->once()->andReturn($content); + } + + // Check if either capture parameter is provided + if (! is_null($capturedPath) || ! is_null($capturedContent)) { + File::shouldReceive('put') + ->once() + ->with( + Mockery::capture($capturedPath), + Mockery::capture($capturedContent) + ) + ->andReturn($writeSuccess); + } else { + File::shouldReceive('put')->once()->andReturn($writeSuccess); + } +} + +function newFileServerConfigurations(): array +{ + return [ + 'single server without args or env' => [ + 'servers', + [ + 'im-new-here' => ['command' => './start-mcp'], + ], + '{"servers":{"im-new-here":{"command":"./start-mcp"}}}', + ], + 'single server with args' => [ + 'mcpServers', + [ + 'boost' => [ + 'command' => 'php', + 'args' => ['artisan', 'boost:mcp'], + ], + ], + '{"mcpServers":{"boost":{"command":"php","args":["artisan","boost:mcp"]}}}', + ], + 'single server with env' => [ + 'servers', + [ + 'mysql' => [ + 'command' => 'npx', + 'env' => ['DB_HOST' => 'localhost', 'DB_PORT' => '3306'], + ], + ], + '{"servers":{"mysql":{"command":"npx","env":{"DB_HOST":"localhost","DB_PORT":"3306"}}}}', + ], + 'multiple servers mixed' => [ + 'mcpServers', + [ + 'boost' => [ + 'command' => 'php', + 'args' => ['artisan', 'boost:mcp'], + ], + 'mysql' => [ + 'command' => 'npx', + 'args' => ['@benborla29/mcp-server-mysql'], + 'env' => ['DB_HOST' => 'localhost'], + ], + ], + '{"mcpServers":{"boost":{"command":"php","args":["artisan","boost:mcp"]},"mysql":{"command":"npx","args":["@benborla29/mcp-server-mysql"],"env":{"DB_HOST":"localhost"}}}}', + ], + 'custom config key' => [ + 'customKey', + [ + 'test' => ['command' => 'test-cmd'], + ], + '{"customKey":{"test":{"command":"test-cmd"}}}', + ], + ]; +} + +function commentDetectionCases(): array +{ + return [ + 'plain JSON no comments' => [ + '{"servers": {"test": {"command": "npm"}}}', + false, + 'Plain JSON should return false', + ], + 'JSON with comments in strings' => [ + '{"exampleCode": "// here is the example code\n [ + '{"servers": {"test": "value"} // this is a real comment}', + true, + 'Real JSON5 line comments should be detected', + ], + 'JSON5 with comment at start of line' => [ + '{\n // This is a comment\n "servers": {}\n}', + true, + 'Line comments at start should be detected', + ], + 'complex string with escaped quotes' => [ + '{"code": "console.log(\\"// not a comment\\");", "other": "value"}', + false, + 'Comments in strings with escaped quotes should not be detected', + ], + 'multiple comments in strings' => [ + '{"example1": "// comment 1", "example2": "some // comment 2 here"}', + false, + 'Multiple comments in different strings should not be detected', + ], + 'mixed real and string comments' => [ + '{"example": "// fake comment"} // real comment', + true, + 'Should detect real comment even when fake ones exist in strings', + ], + 'empty string' => [ + '', + false, + 'Empty string should return false', + ], + 'single slash not comment' => [ + '{"path": "/usr/bin/test"}', + false, + 'Single slash should not be detected as comment', + ], + ]; +} + +function trailingCommaCases(): array +{ + return [ + 'valid JSON no trailing comma' => [ + '{"servers": {"test": "value"}}', + true, + 'Valid JSON should return true (is plain JSON)', + ], + 'trailing comma in object same line' => [ + '{"servers": {"test": "value",}}', + false, + 'Trailing comma in object should return false (is JSON5)', + ], + 'trailing comma in array same line' => [ + '{"items": ["a", "b", "c",]}', + false, + 'Trailing comma in array should return false (is JSON5)', + ], + 'trailing comma across newlines in object' => [ + "{\n \"servers\": {\n \"test\": \"value\",\n }\n}", + false, + 'Trailing comma across newlines in object should be detected', + ], + 'trailing comma across newlines in array' => [ + "{\n \"items\": [\n \"a\",\n \"b\",\n ]\n}", + false, + 'Trailing comma across newlines in array should be detected', + ], + 'trailing comma with tabs and spaces' => [ + "{\n \"test\": \"value\",\t \n}", + false, + 'Trailing comma with mixed whitespace should be detected', + ], + 'comma in string not trailing' => [ + '{"example": "value,", "other": "test"}', + true, + 'Comma inside string should not be detected as trailing', + ], + ]; +} + +function indentationDetectionCases(): array +{ + return [ + 'mcp.json5 servers indentation' => [ + "{\n // Here are comments within my JSON\n \"servers\": {\n \"mysql\": {\n \"command\": \"npx\"\n },\n \"laravel-boost\": {\n \"command\": \"php\"\n }\n },\n \"inputs\": []\n}", + 200, // Position near end of servers block + 8, + 'Should detect 8 spaces for server definitions in mcp.json5', + ], + 'nested object with 4-space base indent' => [ + "{\n \"config\": {\n \"server1\": {\n \"command\": \"test\"\n }\n }\n}", + 80, + 8, + 'Should detect 8 spaces for nested server definitions', + ], + 'no previous server definitions' => [ + "{\n \"inputs\": []\n}", + 20, + 8, + 'Should fallback to 8 spaces when no server definitions found', + ], + 'deeper nesting with 2-space indent' => [ + "{\n \"config\": {\n \"servers\": {\n \"mysql\": {\n \"command\": \"test\"\n }\n }\n }\n}", + 80, + 6, + 'Should detect correct indentation in deeply nested structures', + ], + 'single server definition at root level' => [ + "{\n\"mysql\": {\n \"command\": \"npx\"\n}\n}", + 30, + 0, + 'Should detect no indentation for root-level server definitions', + ], + 'multiple server definitions with consistent indentation' => [ + "{\n \"servers\": {\n \"mysql\": {\n \"command\": \"npx\"\n },\n \"postgres\": {\n \"command\": \"pg\"\n }\n }\n}", + 150, + 8, + 'Should consistently detect indentation across multiple servers', + ], + 'server definition with comments' => [ + "{\n // Comment here\n \"servers\": {\n \"mysql\": { // inline comment\n \"command\": \"npx\"\n }\n }\n}", + 120, + 8, + 'Should detect indentation correctly when comments are present', + ], + 'empty content' => [ + '', + 0, + 8, + 'Should fallback to 8 spaces for empty content', + ], + ]; +} diff --git a/tests/fixtures/mcp-comments-in-strings.json b/tests/fixtures/mcp-comments-in-strings.json new file mode 100644 index 00000000..34fa2955 --- /dev/null +++ b/tests/fixtures/mcp-comments-in-strings.json @@ -0,0 +1,10 @@ +{ + "exampleCode": "// here is the example code\n